Compare commits
88 Commits
master
...
1b014841ab
Author | SHA1 | Date | |
---|---|---|---|
1b014841ab | |||
952e2ca296 | |||
341a5ad826 | |||
550a4b7520 | |||
32e6e80c7d | |||
dbb41d2691 | |||
3ffa6fc6c2 | |||
5134343fa6 | |||
b64ccf0de5 | |||
da4c90f6b7 | |||
039a2b5e23 | |||
be95cf68fc | |||
82211fa2a5 | |||
253dcd68b4 | |||
3a21efc525 | |||
34694a895d | |||
26a7fbf3c4 | |||
0fb809a553 | |||
5941662069 | |||
de3c69fc89 | |||
0ed8ab73ce | |||
59494ded32 | |||
9853a1396b | |||
e953506e6a | |||
9f5e5e01c4 | |||
b11177a943 | |||
2b276a5130 | |||
b9a02b1740 | |||
cb6ee80e43 | |||
dd7507225f | |||
e1775a864d | |||
a0903b91e0 | |||
9db2737f7f | |||
9d80f20e8a | |||
0c60954776 | |||
a3b5ef87f9 | |||
b08c1a3fc2 | |||
9bb6001adf | |||
0bdd6c2d74 | |||
ffe31504ee | |||
6c6f66cdf8 | |||
fe1556b099 | |||
51276d3831 | |||
7cb20cf8b1 | |||
bf6bac847b | |||
48a376f3bd | |||
f3b1a0d7e8 | |||
f7f10c5577 | |||
38800bb33c | |||
36da54e587 | |||
6da01fca39 | |||
f361a13c91 | |||
eee0a8dba2 | |||
61afbecda9 | |||
67439c1c49 | |||
0a23b786b0 | |||
fa924a8e8c | |||
06c2fd18bd | |||
dd113c3548 | |||
6dfef53243 | |||
81cb5ab304 | |||
f83a48ac3f | |||
5da1d3fb16 | |||
93472c061e | |||
91543e2d74 | |||
5886272585 | |||
6b0daecd31 | |||
a5cfdeff54 | |||
50da81889e | |||
26142084f6 | |||
1aba33fb91 | |||
e5d8e6c62f | |||
5e03b4385f | |||
bfa0bcd0bb | |||
aeac704e89 | |||
b84c7ab62a | |||
5c6fd4b5ae | |||
78d147344f | |||
ec47e02f9c | |||
a2781003c6 | |||
9d42b010c1 | |||
9005a446fe | |||
b782d97920 | |||
d503d49917 | |||
6837495eba | |||
b9866a8c19 | |||
8ea7b6a08d | |||
fc9b18141f |
42
.github/workflows/release.yml
vendored
Normal file
42
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
name: Handle Release
|
||||
jobs:
|
||||
generate:
|
||||
name: Create release-artifacts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
target: x86_64-unknown-linux-musl
|
||||
override: true
|
||||
- uses: davidB/rust-cargo-make@v1
|
||||
with:
|
||||
version: 'latest'
|
||||
- uses: jetli/wasm-pack-action@v0.3.0
|
||||
with:
|
||||
version: 'latest'
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo
|
||||
- name: Install musl-tools
|
||||
run: sudo apt-get install musl-tools
|
||||
- name: Build
|
||||
run: cargo make build_standalone
|
||||
- name: Rename
|
||||
run: mv /home/runner/work/pslink/pslink/target/x86_64-unknown-linux-musl/release/pslink pslink_linux_64bit
|
||||
- name: Upload the artifacts
|
||||
uses: skx/github-action-publish-binaries@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: 'pslink_linux_64bit'
|
26
.github/workflows/rust.yml
vendored
26
.github/workflows/rust.yml
vendored
@ -15,8 +15,28 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
target: x86_64-unknown-linux-musl
|
||||
override: true
|
||||
- uses: davidB/rust-cargo-make@v1
|
||||
with:
|
||||
version: 'latest'
|
||||
- uses: jetli/wasm-pack-action@v0.3.0
|
||||
with:
|
||||
version: 'latest'
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargols
|
||||
- name: Install musl-tools
|
||||
run: sudo apt-get install musl-tools
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
run: cargo make build_standalone
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,3 +5,5 @@ launch.json
|
||||
settings.json
|
||||
links.session.sql
|
||||
sqltemplates
|
||||
dist
|
||||
pkg
|
1500
Cargo.lock
generated
1500
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
76
Cargo.toml
76
Cargo.toml
@ -1,73 +1,13 @@
|
||||
[package]
|
||||
authors = ["Dietrich <dietrich@teilgedanken.de>"]
|
||||
build = "build.rs"
|
||||
categories = ["web-programming", "network-programming", "web-programming::http-server", "command-line-utilities"]
|
||||
description = "A simple webservice that allows registered users to create short links including qr-codes.\nAnyone can visit the shortened links. This is an ideal setup for small busines or for publishing papers."
|
||||
edition = "2018"
|
||||
keywords = ["url", "link", "webpage", "actix", "web"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
name = "pslink"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/enaut/pslink/"
|
||||
version = "0.3.1"
|
||||
[build-dependencies]
|
||||
actix-web-static-files = "3.0"
|
||||
|
||||
[dependencies]
|
||||
actix-identity = "0.3"
|
||||
actix-rt = "1.1"
|
||||
actix-slog = "0.2"
|
||||
actix-web = "3"
|
||||
actix-web-static-files = "3.0"
|
||||
anyhow = "1.0"
|
||||
argonautica = "0.2"
|
||||
clap = "2.33"
|
||||
dotenv = "0.15.0"
|
||||
fluent-langneg = "0.13"
|
||||
image = "0.23"
|
||||
opentelemetry = "0.13"
|
||||
opentelemetry-jaeger = "0.12"
|
||||
qrcode = "0.12"
|
||||
rand = "0.8"
|
||||
rpassword = "5.0"
|
||||
serde = "1.0"
|
||||
tera = "1.6"
|
||||
thiserror = "1.0"
|
||||
tracing-actix-web = "0.2.1"
|
||||
tracing-bunyan-formatter = "0.2.0"
|
||||
tracing-opentelemetry = "0.12"
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.fluent-templates]
|
||||
features = ["tera"]
|
||||
version = "0.6"
|
||||
|
||||
[dependencies.sqlx]
|
||||
features = ["sqlite", "macros", "runtime-actix-rustls", "chrono", "migrate", "offline"]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.tracing]
|
||||
features = ["log"]
|
||||
version = "0.1"
|
||||
|
||||
[dependencies.tracing-subscriber]
|
||||
features = ["registry", "env-filter"]
|
||||
version = "0.2.17"
|
||||
|
||||
[dev-dependencies]
|
||||
actix-server = "1.0.4"
|
||||
tempdir = "0.3"
|
||||
test_bin = "0.3"
|
||||
tokio = "0.2.25"
|
||||
|
||||
[dev-dependencies.reqwest]
|
||||
features = ["cookies"]
|
||||
version = "0.10.10"
|
||||
[workspace]
|
||||
members = [
|
||||
"shared",
|
||||
"locales",
|
||||
"pslink",
|
||||
"app",
|
||||
]
|
||||
|
||||
[profile]
|
||||
[profile.release]
|
||||
lto = true
|
||||
#codegen-units = 1
|
||||
#opt-level = 'z' # Z is not supported by argonautica
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Franz Dietrich
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
98
Makefile.toml
Normal file
98
Makefile.toml
Normal file
@ -0,0 +1,98 @@
|
||||
[config]
|
||||
default_to_workspace = false
|
||||
|
||||
# ---- BUILD ----
|
||||
|
||||
[tasks.build]
|
||||
description = "Build client and server"
|
||||
clear = true
|
||||
dependencies = ["build_client", "build_server"]
|
||||
|
||||
[tasks.build_release]
|
||||
extend = "build"
|
||||
description = "Build client and server in release mode"
|
||||
dependencies = ["build_client_release", "build_server_release"]
|
||||
|
||||
[tasks.build_standalone]
|
||||
extend = "build"
|
||||
description = "Build client and server with musl libc embedded"
|
||||
dependencies = ["build_client_release", "build_server_standalone"]
|
||||
|
||||
[tasks.build_client]
|
||||
description = "Build client"
|
||||
install_crate = { crate_name = "wasm-pack", binary = "wasm-pack", test_arg = "-V" }
|
||||
command = "wasm-pack"
|
||||
args = ["build", "app", "--target", "web", "--out-name", "app", "--out-dir", "../pslink/static/wasm/", "--dev"]
|
||||
|
||||
[tasks.build_client_release]
|
||||
extend = "build_client"
|
||||
description = "Build client in release mode"
|
||||
args = ["build", "app", "--target", "web", "--out-name", "app", "--out-dir", "../pslink/static/wasm/", "--release"]
|
||||
|
||||
[tasks.build_server]
|
||||
env = { SQLX_OFFLINE = 1 }
|
||||
description = "Build server"
|
||||
command = "cargo"
|
||||
args = ["build", "--package", "pslink"]
|
||||
|
||||
[tasks.build_server_release]
|
||||
extend = "build_server"
|
||||
description = "Build server in release mode"
|
||||
args = ["build", "--package", "pslink", "--release"]
|
||||
|
||||
[tasks.build_server_standalone]
|
||||
extend = "build_server"
|
||||
description = "Build server with the musl libc embedded"
|
||||
args = ["build", "--package", "pslink", "--release", "--target", "x86_64-unknown-linux-musl"]
|
||||
|
||||
# ---- START ----
|
||||
|
||||
[tasks.start]
|
||||
description = "Build and start the pslink server in debug mode"
|
||||
command = "cargo"
|
||||
args = ["run", "--package", "pslink", "--", "runserver"]
|
||||
dependencies = ["build"]
|
||||
|
||||
[tasks.start_release]
|
||||
extend = "start"
|
||||
description = "Build and start the pslink server in release mode"
|
||||
args = ["run", "--package", "pslink", "--release", "--", "runserver"]
|
||||
dependencies = ["build_release"]
|
||||
|
||||
[tasks.start_standalone]
|
||||
extend = "start"
|
||||
description = "Build and start the pslink server with the musl c library embedded."
|
||||
args = ["run", "--package", "pslink", "--release", "--", "runserver", "--target", "x86_64-unknown-linux-musl"]
|
||||
dependencies = ["build_standalone"]
|
||||
|
||||
# ---- TEST ----
|
||||
|
||||
[tasks.test_firefox]
|
||||
description = "Test with wasm-pack in Firefox"
|
||||
command = "wasm-pack"
|
||||
args = ["test", "client", "--firefox", "--headless"]
|
||||
|
||||
# ---- LINT ----
|
||||
|
||||
[tasks.fmt]
|
||||
description = "Format with rustfmt"
|
||||
install_crate = { crate_name = "rustfmt-nightly", rustup_component_name = "rustfmt", binary = "rustfmt", test_arg = "--help" }
|
||||
command = "cargo"
|
||||
args = ["fmt", "--all"]
|
||||
|
||||
[tasks.fmt_check]
|
||||
extend = "fmt"
|
||||
description = "Check format with rustfmt"
|
||||
args = ["fmt", "--all", "--", "--check"]
|
||||
|
||||
[tasks.clippy]
|
||||
description = "Lint with Clippy"
|
||||
clear = true
|
||||
install_crate = { rustup_component_name = "clippy", binary = "cargo-clippy", test_arg = "--help" }
|
||||
command = "cargo"
|
||||
args = ["clippy", "--all-features", "--",
|
||||
"--warn", "warnings",
|
||||
"--warn", "clippy::pedantic",
|
||||
"--warn", "clippy::nursery",
|
||||
"--allow", "clippy::future_not_send", # JS/WASM is single threaded
|
||||
]
|
123
README.md
123
README.md
@ -4,21 +4,62 @@ The target audience of this tool are small entities that need a url shortener. T
|
||||
|
||||
So in general this is more a shared short url bookmark webpage than a shorturl service.
|
||||
|
||||
![Screenshot](./doc/img/pslinkscreenshot.png)
|
||||
![Screenshot](./doc/img/screenshot.png)
|
||||
![Screenshot](./doc/img/screenshot_edit.png)
|
||||
|
||||
## What users can do
|
||||
|
||||
### Guests (no account)
|
||||
|
||||
* click on link get redirected to the page
|
||||
* error on invalid or deleted link
|
||||
|
||||
### Users (regular account)
|
||||
|
||||
* view all existing links
|
||||
* modify all own links
|
||||
* create new links
|
||||
* download qr-codes of the links
|
||||
* modify own "profile" settings
|
||||
|
||||
### Admins (priviledged account)
|
||||
|
||||
* everything from users
|
||||
* modify all links
|
||||
* list all users
|
||||
* modify all profiles
|
||||
* create new users
|
||||
* make users administrators
|
||||
* make administrators normal users
|
||||
|
||||
## What the program can do
|
||||
|
||||
The Page comes with a basic commandline interface to setup the environment.
|
||||
|
||||
### Commandline
|
||||
|
||||
* create and read from a `.env` file in the current directory
|
||||
* create and migrate the database
|
||||
* create an admin user
|
||||
* run the webserver
|
||||
|
||||
### Service
|
||||
|
||||
* admin interface via wasm
|
||||
* Rest+Json server
|
||||
* Tracing via Jaeger
|
||||
|
||||
## Usage
|
||||
|
||||
### install binary
|
||||
|
||||
The pslink binary can be downloaded from the latest release at: https://github.com/enaut/pslink/releases
|
||||
|
||||
These binaries are selfcontained and should run on any linux 64bit system. Just put them where you like them to be and make them executable. A sample install might be:
|
||||
These binaries are selfcontained and should run on any linux 64bit sy"stem. Just put them where you like them to be and make them executable. A sample install might be:
|
||||
|
||||
```bash
|
||||
# mkdir -p /opt/pslink
|
||||
# wget -o /opt/pslink/pslink https://github.com/enaut/pslink/releases/latest/download/pslink.linux.64bit
|
||||
# wget -o /opt/pslink/pslink https://github.com/enaut/pslink/releases/latest/download/pslink_linux_64bit
|
||||
# chmod +x /opt/pslink/pslink
|
||||
```
|
||||
|
||||
@ -26,57 +67,70 @@ You could now adjust your `PATH` or setup an alias or just call the binary with
|
||||
|
||||
### Install with cargo
|
||||
|
||||
Pslink can be compiled and installed with cargo. Setup cargo as guided here: https://doc.rust-lang.org/cargo/getting-started/installation.html
|
||||
|
||||
After that install pslink using:
|
||||
|
||||
```bash
|
||||
$ cargo install pslink
|
||||
```
|
||||
|
||||
If that succeeds you should now be able to call pslink.
|
||||
`cargo install pslink` does not (yet) produce a working binary! Use the "install binary" or "build from source" approach
|
||||
|
||||
### Build from source
|
||||
|
||||
When building manually with cargo you have to have a sqlite database present or build it in offline mode. So on your first build you will most likely need to call:
|
||||
Checkout the git repository and within its root folder issue the following commands. Internet es required and some packages will be installed during the process.
|
||||
|
||||
```bash
|
||||
SQLX_OFFLINE=1 cargo run
|
||||
# or
|
||||
$ export SQLX_OFFLINE=1
|
||||
$ cargo run
|
||||
$ cargo install cargo-make
|
||||
$ cargo make build_release
|
||||
# or to immediately start the server after building but
|
||||
# as you probably do not yet have a .env file or database
|
||||
# this will fail.
|
||||
$ cargo make start_release
|
||||
```
|
||||
|
||||
If pslink is built with `cargo build release --target=x86_64-unknown-linux-musl` everything is embedded and it should be portable to any 64bit linux system.
|
||||
Templates and migrations are embedded in the binary so it should run standalone without anything extra.
|
||||
If that succeeds you should now be able to call pslink. The binary is located at `target/release/pslink` and can be moved anywhere you want.
|
||||
|
||||
When building manually with cargo you may have to have a sqlite database present or build it in offline mode. So on your first build you will most likely need to call:
|
||||
|
||||
```bash
|
||||
SQLX_OFFLINE=1 cargo make build_release
|
||||
# or
|
||||
$ export SQLX_OFFLINE=1
|
||||
$ cargo make build_release
|
||||
```
|
||||
|
||||
If pslink is built with `cargo make build_standalone` everything is embedded and it should be portable to any 64bit linux system. Otherwise the same or newer version of libc needs to be installed on the target linux system. Note that you need to install `musl-gcc` for this to work using: `sudo dnf install musl-libc musl-gcc` or `sudo apt-get install musl-tools`.
|
||||
|
||||
Templates and migrations are allways embedded in the binary so it should run standalone without anything extra.
|
||||
|
||||
### Setup
|
||||
|
||||
To read the help and documentation of additional options call:
|
||||
|
||||
```pslink help```
|
||||
|
||||
To get Pslink up and running use the commands in the following order:
|
||||
|
||||
1. `pslink generate-env`
|
||||
|
||||
this will generate a `.env` file in the curent directory with the default settings. Edit this file to your liking. You can however skip this step and provide all the parameters via commandline or environmentvariable. It is **not** recommended to provide PSLINK_SECRET with commandline parameters as they can be read by every user on the system.
|
||||
|
||||
2. `pslink migrate-database`
|
||||
|
||||
will create a sqlite database in the location specified.
|
||||
|
||||
3. `pslink create-admin`
|
||||
|
||||
create an initial admin user. As the page has no "register" function this is required to do anything usefull.
|
||||
create an initial admin user. As the page has no "register" function this is required to do anything usefull. The command is interactive so you will be asked the username and password of the new admin user.
|
||||
|
||||
4. `pslink runserver`
|
||||
|
||||
If everything is set up correctly this command will start the service.
|
||||
If everything is set up correctly this command will start the service. You should now be able to go to your url at [http://localhost/app/] and be presented with a login screen.
|
||||
|
||||
### Run the service
|
||||
|
||||
If everything is correctly set up just do `pslink runserver`.
|
||||
If everything is correctly set up just do `pslink runserver` to launch the server.
|
||||
|
||||
### Update
|
||||
|
||||
To update to a newer version execute the commands in the following order
|
||||
|
||||
1. stop the service
|
||||
2. download and install the new binary
|
||||
2. download and install or build the new binary
|
||||
3. run `pslink migrate-database`
|
||||
4. run the server again `pslink runserver`
|
||||
|
||||
@ -114,3 +168,26 @@ ExecStart=/var/pslink/pslink runserver
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Setup a demo container
|
||||
|
||||
First build the standalone binary:
|
||||
|
||||
```bash
|
||||
$ cargo make build_standalone
|
||||
```
|
||||
|
||||
Create a temporary directory and copy the binary from above:
|
||||
|
||||
```bash
|
||||
$ mkdir /tmp/pslink-container/
|
||||
$ cp target/x86_64-unknown-linux-musl/release/pslink /tmp/pslink-container/
|
||||
```
|
||||
|
||||
Run the container (podman is used here but docker could be used exactly the same):
|
||||
|
||||
```bash
|
||||
$ podman run --expose 8080 -p=8080:8080 -it pslink-container ./pslink demo -i 0.0.0.0
|
||||
```
|
||||
|
||||
Note that this is **absolutely not for a production use** and only for demo purposes as the links are **deleted on every restart**.
|
||||
|
38
app/Cargo.toml
Normal file
38
app/Cargo.toml
Normal file
@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "pslink-app"
|
||||
authors = ["Dietrich <dietrich@teilgedanken.de>"]
|
||||
categories = ["web-programming", "network-programming", "web-programming::http-server", "command-line-utilities"]
|
||||
description = "A wasm frontend for pslink. The frontend communicates via json API."
|
||||
edition = "2018"
|
||||
keywords = ["url", "link", "webpage", "actix", "web"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/enaut/pslink/"
|
||||
version = "0.4.3"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
fluent = "0.15"
|
||||
seed = "0.8"
|
||||
serde = {version="1.0", features = ["derive"]}
|
||||
unic-langid = "0.9"
|
||||
strum_macros = "0.21"
|
||||
strum = "0.21"
|
||||
enum-map = "1"
|
||||
qrcode = "0.12"
|
||||
image = "0.23"
|
||||
|
||||
pslink-shared = { version="0.4", path = "../shared" }
|
||||
pslink-locales = { version="0.4", path = "../locales" }
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
features = [
|
||||
"IntersectionObserver",
|
||||
"IntersectionObserverInit",
|
||||
"IntersectionObserverEntry",
|
||||
]
|
11
app/README.md
Normal file
11
app/README.md
Normal file
@ -0,0 +1,11 @@
|
||||
# The Frontend for pslink
|
||||
|
||||
This part of `pslink` is the wasm binary for the frontend of `pslink`.
|
||||
|
||||
It provides:
|
||||
* a login screen
|
||||
* management for links
|
||||
* management for users
|
||||
* switching the language
|
||||
|
||||
The wasm binary communicates via a REST-JSON-Api with the server.
|
416
app/src/lib.rs
Normal file
416
app/src/lib.rs
Normal file
@ -0,0 +1,416 @@
|
||||
//! The admin interface of pslink. It communicates with the server mostly via https and json.
|
||||
pub mod navigation;
|
||||
pub mod pages;
|
||||
|
||||
use pages::list_links;
|
||||
use pages::list_users;
|
||||
use pslink_shared::{
|
||||
apirequests::users::LoginUser,
|
||||
datatypes::{Lang, Loadable, User},
|
||||
};
|
||||
use seed::window;
|
||||
use seed::IF;
|
||||
use seed::{attrs, button, div, input, label, log, prelude::*, App, Url, C};
|
||||
|
||||
use pslink_locales::I18n;
|
||||
|
||||
// ------ ------
|
||||
// Init
|
||||
// ------ ------
|
||||
|
||||
fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
||||
orders.subscribe(Msg::UrlChanged);
|
||||
orders.send_msg(Msg::GetLoggedUser);
|
||||
|
||||
let lang = I18n::new(Lang::EnUS);
|
||||
|
||||
Model {
|
||||
index: 0,
|
||||
location: Location::new(url),
|
||||
page: Page::NotFound,
|
||||
i18n: lang,
|
||||
user: Loadable::Data(None),
|
||||
login_form: LoginForm::default(),
|
||||
login_data: LoginUser::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// ------ ------
|
||||
// Model
|
||||
// ------ ------
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Model {
|
||||
index: usize,
|
||||
location: Location,
|
||||
page: Page,
|
||||
i18n: I18n,
|
||||
user: Loadable<User>,
|
||||
login_form: LoginForm,
|
||||
login_data: LoginUser,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
fn set_lang(&mut self, l: Lang) {
|
||||
self.i18n.set_lang(l);
|
||||
match &mut self.page {
|
||||
Page::Home(ref mut m) => m.set_lang(l),
|
||||
Page::ListUsers(ref mut m) => m.set_lang(l),
|
||||
Page::NotFound => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The input fields of the login dialog.
|
||||
#[derive(Default, Debug)]
|
||||
struct LoginForm {
|
||||
username: ElRef<web_sys::HtmlInputElement>,
|
||||
password: ElRef<web_sys::HtmlInputElement>,
|
||||
}
|
||||
|
||||
/// All information regarding the current location
|
||||
#[derive(Debug)]
|
||||
struct Location {
|
||||
host: String,
|
||||
base_url: Url,
|
||||
current_url: Url,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
fn new(url: Url) -> Self {
|
||||
let host = get_host();
|
||||
Self {
|
||||
host,
|
||||
base_url: Url::new().add_path_part("app"),
|
||||
current_url: url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the url from the address bar.
|
||||
#[must_use]
|
||||
pub fn get_host() -> String {
|
||||
window()
|
||||
.location()
|
||||
.host()
|
||||
.expect("Failed to extract the host of the url")
|
||||
}
|
||||
|
||||
/// The pages:
|
||||
/// * `Home` for listing of links
|
||||
/// * `ListUsers` for listing of users
|
||||
#[derive(Debug)]
|
||||
enum Page {
|
||||
Home(pages::list_links::Model),
|
||||
ListUsers(pages::list_users::Model),
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
fn init(mut url: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Self {
|
||||
log!(&url);
|
||||
url.next_path_part();
|
||||
let result = match url.next_path_part() {
|
||||
None | Some("list_links") => Self::Home(pages::list_links::init(
|
||||
url,
|
||||
&mut orders.proxy(Msg::ListLinks),
|
||||
i18n,
|
||||
)),
|
||||
Some("list_users") => Self::ListUsers(pages::list_users::init(
|
||||
url,
|
||||
&mut orders.proxy(Msg::ListUsers),
|
||||
i18n,
|
||||
)),
|
||||
_other => Self::NotFound,
|
||||
};
|
||||
|
||||
orders.perform_cmd(async {
|
||||
// create request
|
||||
let request = Request::new("/admin/json/get_language/");
|
||||
// perform and get response
|
||||
let response = unwrap_or_return!(fetch(request).await, Msg::NoMessage);
|
||||
// validate response status
|
||||
let response = unwrap_or_return!(response.check_status(), Msg::NoMessage);
|
||||
let lang: Lang = unwrap_or_return!(response.json().await, Msg::NoMessage);
|
||||
|
||||
Msg::LanguageChanged(lang)
|
||||
});
|
||||
|
||||
log!("Page initialized");
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// ------ ------
|
||||
// Update
|
||||
// ------ ------
|
||||
|
||||
/// The messages regarding authentication and settings.
|
||||
#[derive(Clone)]
|
||||
pub enum Msg {
|
||||
UrlChanged(subs::UrlChanged),
|
||||
ListLinks(list_links::Msg),
|
||||
ListUsers(list_users::Msg),
|
||||
GetLoggedUser,
|
||||
UserReceived(User),
|
||||
NoMessage,
|
||||
NotAuthenticated,
|
||||
Logout,
|
||||
Login,
|
||||
UsernameChanged(String),
|
||||
PasswordChanged(String),
|
||||
SetLanguage(Lang),
|
||||
LanguageChanged(Lang),
|
||||
}
|
||||
|
||||
/// react to settings and authentication changes.
|
||||
fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
match msg {
|
||||
Msg::UrlChanged(url) => {
|
||||
model.page = Page::init(url.0, orders, model.i18n.clone());
|
||||
}
|
||||
Msg::ListLinks(msg) => {
|
||||
if let Page::Home(model) = &mut model.page {
|
||||
list_links::update(msg, model, &mut orders.proxy(Msg::ListLinks));
|
||||
}
|
||||
}
|
||||
Msg::ListUsers(msg) => {
|
||||
if let Page::ListUsers(model) = &mut model.page {
|
||||
list_users::update(msg, model, &mut orders.proxy(Msg::ListUsers));
|
||||
}
|
||||
}
|
||||
Msg::NoMessage => (),
|
||||
Msg::GetLoggedUser => {
|
||||
model.user = Loadable::Loading;
|
||||
orders.perform_cmd(async {
|
||||
// create request
|
||||
let request = unwrap_or_return!(
|
||||
Request::new("/admin/json/get_logged_user/")
|
||||
.method(Method::Post)
|
||||
.json(&()),
|
||||
Msg::Logout
|
||||
);
|
||||
// perform and get response
|
||||
let response = unwrap_or_return!(fetch(request).await, Msg::Logout);
|
||||
// validate response status
|
||||
let response = unwrap_or_return!(response.check_status(), Msg::Logout);
|
||||
let user: User = unwrap_or_return!(response.json().await, Msg::Logout);
|
||||
|
||||
Msg::UserReceived(user)
|
||||
});
|
||||
}
|
||||
Msg::UserReceived(user) => {
|
||||
model.set_lang(user.language);
|
||||
model.user = Loadable::Data(Some(user));
|
||||
model.page = Page::init(
|
||||
model.location.current_url.clone(),
|
||||
orders,
|
||||
model.i18n.clone(),
|
||||
);
|
||||
}
|
||||
Msg::NotAuthenticated => {
|
||||
if model.user.is_some() {
|
||||
model.user = Loadable::Data(None);
|
||||
logout(orders);
|
||||
}
|
||||
model.user = Loadable::Data(None);
|
||||
}
|
||||
Msg::Logout => {
|
||||
model.user = Loadable::Data(None);
|
||||
logout(orders);
|
||||
}
|
||||
Msg::Login => login_user(model, orders),
|
||||
Msg::UsernameChanged(s) => model.login_data.username = s,
|
||||
Msg::PasswordChanged(s) => model.login_data.password = s,
|
||||
Msg::SetLanguage(l) => {
|
||||
change_language(l, orders);
|
||||
}
|
||||
Msg::LanguageChanged(l) => {
|
||||
log!("Changed Language", &l);
|
||||
model.set_lang(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// switch the language
|
||||
fn change_language(l: Lang, orders: &mut impl Orders<Msg>) {
|
||||
orders.perform_cmd(async move {
|
||||
// create request
|
||||
let request = unwrap_or_return!(
|
||||
Request::new("/admin/json/change_language/")
|
||||
.method(Method::Post)
|
||||
.json(&l),
|
||||
Msg::NoMessage
|
||||
);
|
||||
// perform and get response
|
||||
let response = unwrap_or_return!(fetch(request).await, Msg::NoMessage);
|
||||
// validate response status
|
||||
let response = unwrap_or_return!(response.check_status(), Msg::NoMessage);
|
||||
let l: Lang = unwrap_or_return!(response.json().await, Msg::NoMessage);
|
||||
|
||||
Msg::LanguageChanged(l)
|
||||
});
|
||||
}
|
||||
|
||||
/// logout on the server
|
||||
fn logout(orders: &mut impl Orders<Msg>) {
|
||||
orders.perform_cmd(async {
|
||||
let request = Request::new("/admin/logout/");
|
||||
unwrap_or_return!(fetch(request).await, Msg::GetLoggedUser);
|
||||
Msg::NotAuthenticated
|
||||
});
|
||||
}
|
||||
|
||||
/// login using username and password
|
||||
fn login_user(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
model.user = Loadable::Loading;
|
||||
let data = model.login_data.clone();
|
||||
|
||||
orders.perform_cmd(async {
|
||||
let data = data;
|
||||
// create request
|
||||
let request = unwrap_or_return!(
|
||||
Request::new("/admin/json/login_user/")
|
||||
.method(Method::Post)
|
||||
.json(&data),
|
||||
Msg::NotAuthenticated
|
||||
);
|
||||
// perform and get response
|
||||
let response = unwrap_or_return!(fetch(request).await, Msg::NotAuthenticated);
|
||||
// validate response status
|
||||
let response = unwrap_or_return!(response.check_status(), Msg::NotAuthenticated);
|
||||
let user: User = unwrap_or_return!(response.json().await, Msg::NotAuthenticated);
|
||||
|
||||
Msg::UserReceived(user)
|
||||
});
|
||||
}
|
||||
|
||||
/// to create urls for different subpages
|
||||
pub struct Urls<'a> {
|
||||
base_url: std::borrow::Cow<'a, Url>,
|
||||
}
|
||||
|
||||
impl<'a> Urls<'a> {
|
||||
/// Create a new `Urls` instance.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// Urls::new(base_url).home()
|
||||
/// ```
|
||||
pub fn new(base_url: impl Into<std::borrow::Cow<'a, Url>>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return base `Url`. If `base_url` isn't owned, it will be cloned.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub fn admin_urls(self) -> page::admin::Urls<'a> {
|
||||
/// page::admin::Urls::new(self.base_url().add_path_part(ADMIN))
|
||||
/// }
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn base_url(self) -> Url {
|
||||
self.base_url.into_owned()
|
||||
}
|
||||
#[must_use]
|
||||
pub fn home(self) -> Url {
|
||||
self.base_url()
|
||||
}
|
||||
#[must_use]
|
||||
pub fn list_links(self) -> Url {
|
||||
self.base_url().add_path_part("list_links")
|
||||
}
|
||||
#[must_use]
|
||||
pub fn create_link(self) -> Url {
|
||||
self.list_links().add_path_part("create_link")
|
||||
}
|
||||
#[must_use]
|
||||
pub fn list_users(self) -> Url {
|
||||
self.base_url().add_path_part("list_users")
|
||||
}
|
||||
#[must_use]
|
||||
pub fn create_user(self) -> Url {
|
||||
self.list_users().add_path_part("create_user")
|
||||
}
|
||||
}
|
||||
|
||||
// ------ ------
|
||||
// View
|
||||
// ------ ------
|
||||
|
||||
/// Render the menu and the subpages.
|
||||
fn view(model: &Model) -> Node<Msg> {
|
||||
div![
|
||||
C!["page"],
|
||||
match model.user {
|
||||
Loadable::Data(Some(ref user)) => div![
|
||||
navigation::navigation(&model.i18n, &model.location.base_url, user),
|
||||
view_content(&model.page, &model.location.base_url, user)
|
||||
],
|
||||
Loadable::Data(None) => view_login(&model.i18n, model),
|
||||
Loadable::Loading => div![C!("lds-ellipsis"), div!(), div!(), div!(), div!()],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/// Render the subpages.
|
||||
fn view_content(page: &Page, url: &Url, user: &User) -> Node<Msg> {
|
||||
div![
|
||||
C!["container"],
|
||||
match page {
|
||||
Page::Home(model) => pages::list_links::view(model, user).map_msg(Msg::ListLinks),
|
||||
Page::ListUsers(model) => pages::list_users::view(model, user).map_msg(Msg::ListUsers),
|
||||
Page::NotFound => div![div![url.to_string()], "Page not found!"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/// If not logged in render the login form
|
||||
fn view_login(lang: &I18n, model: &Model) -> Node<Msg> {
|
||||
let t = move |key: &str| lang.translate(key, None);
|
||||
|
||||
div![
|
||||
C!["center", "login"],
|
||||
div![
|
||||
label![t("username")],
|
||||
input![
|
||||
input_ev(Ev::Input, |s| { Msg::UsernameChanged(s) }),
|
||||
attrs![
|
||||
At::Type => "text",
|
||||
At::Placeholder => t("username"),
|
||||
At::Name => "username",
|
||||
At::Value => model.login_data.username],
|
||||
el_ref(&model.login_form.username)
|
||||
]
|
||||
],
|
||||
div![
|
||||
label![t("password")],
|
||||
input![
|
||||
input_ev(Ev::Input, |s| { Msg::PasswordChanged(s) }),
|
||||
keyboard_ev(Ev::KeyDown, |keyboard_event| {
|
||||
IF!(keyboard_event.key() == "Enter" => Msg::Login)
|
||||
}),
|
||||
attrs![
|
||||
At::Type => "password",
|
||||
At::Placeholder => t("password"),
|
||||
At::Name => "password",
|
||||
At::Value => model.login_data.password],
|
||||
el_ref(&model.login_form.password)
|
||||
]
|
||||
],
|
||||
button![t("login"), ev(Ev::Click, |_| Msg::Login)]
|
||||
]
|
||||
}
|
||||
|
||||
// ------ ------
|
||||
// Start
|
||||
// ------ ------
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn main() {
|
||||
App::start("app", init, update, view);
|
||||
}
|
76
app/src/navigation.rs
Normal file
76
app/src/navigation.rs
Normal file
@ -0,0 +1,76 @@
|
||||
//! Create the top menu of the app
|
||||
use fluent::fluent_args;
|
||||
use pslink_locales::I18n;
|
||||
use pslink_shared::{
|
||||
apirequests::users::Role,
|
||||
datatypes::{Lang, User},
|
||||
};
|
||||
use seed::{a, attrs, div, li, nav, nodes, ol, prelude::*, Url, C};
|
||||
|
||||
use crate::Msg;
|
||||
|
||||
/// Generate the top navigation menu of all pages.
|
||||
///
|
||||
/// The menu options are translated using the i18n module.
|
||||
#[must_use]
|
||||
pub fn navigation(i18n: &I18n, base_url: &Url, user: &User) -> Node<Msg> {
|
||||
// A shortcut for translating strings.
|
||||
let t = move |key: &str| i18n.translate(key, None);
|
||||
// Translate the welcome message
|
||||
let welcome = i18n.translate(
|
||||
"welcome-user",
|
||||
Some(&fluent_args![ "username" => user.username.clone()]),
|
||||
);
|
||||
nav![
|
||||
ol![
|
||||
// A button for the homepage, the list of URLs
|
||||
li![a![
|
||||
attrs! {At::Href => crate::Urls::new(base_url).list_links()},
|
||||
t("list-links"),
|
||||
],],
|
||||
// A button to create a new shortened URL
|
||||
li![a![
|
||||
attrs! {At::Href => crate::Urls::new(base_url).create_link()},
|
||||
ev(Ev::Click, |_| Msg::ListLinks(
|
||||
super::pages::list_links::Msg::Edit(
|
||||
super::pages::list_links::EditMsg::CreateNewLink
|
||||
)
|
||||
)),
|
||||
t("add-link"),
|
||||
],],
|
||||
if user.role == Role::Admin {
|
||||
nodes![
|
||||
// A button to create a new user
|
||||
li![a![
|
||||
attrs! {At::Href => crate::Urls::new(base_url).create_user()},
|
||||
ev(Ev::Click, |_| Msg::ListUsers(
|
||||
super::pages::list_users::Msg::Edit(
|
||||
super::pages::list_users::UserEditMsg::CreateNewUser
|
||||
)
|
||||
)),
|
||||
t("invite-user"),
|
||||
],]
|
||||
]
|
||||
} else {
|
||||
nodes!()
|
||||
},
|
||||
// A button to list all users
|
||||
li![a![
|
||||
attrs! {At::Href => crate::Urls::new(base_url).list_users()},
|
||||
t("list-users"),
|
||||
],],
|
||||
],
|
||||
ol![
|
||||
li![div![
|
||||
C!("languageselector"),
|
||||
t("language"),
|
||||
a![ev(Ev::Click, |_| Msg::SetLanguage(Lang::DeDE)), "de"],
|
||||
a![ev(Ev::Click, |_| Msg::SetLanguage(Lang::EnUS)), "en"]
|
||||
]],
|
||||
// The Welcome message
|
||||
li![div![welcome]],
|
||||
// The logout button
|
||||
li![a![ev(Ev::Click, |_| Msg::NotAuthenticated), t("logout"),]]
|
||||
]
|
||||
]
|
||||
}
|
888
app/src/pages/list_links.rs
Normal file
888
app/src/pages/list_links.rs
Normal file
@ -0,0 +1,888 @@
|
||||
//! List all the links the own links editable or if an admin is logged in all links editable.
|
||||
use std::ops::Deref;
|
||||
|
||||
use enum_map::EnumMap;
|
||||
use fluent::fluent_args;
|
||||
use image::{DynamicImage, ImageOutputFormat, Luma};
|
||||
use pslink_locales::I18n;
|
||||
use qrcode::{render::svg, QrCode};
|
||||
use seed::{
|
||||
a, attrs, div, h1, img, input, log, nodes, prelude::*, raw, section, span, table, td, th, tr,
|
||||
Url, C, IF,
|
||||
};
|
||||
use web_sys::{IntersectionObserver, IntersectionObserverEntry, IntersectionObserverInit};
|
||||
|
||||
use pslink_shared::{
|
||||
apirequests::general::Ordering,
|
||||
apirequests::{
|
||||
general::{EditMode, Message, Operation, Status},
|
||||
links::{LinkDelta, LinkOverviewColumns, LinkRequestForm},
|
||||
},
|
||||
datatypes::{FullLink, Lang, Loadable, User},
|
||||
};
|
||||
|
||||
use crate::{get_host, unwrap_or_return};
|
||||
|
||||
/// Setup the page
|
||||
pub fn init(mut url: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Model {
|
||||
// fetch the links to fill the list.
|
||||
orders.send_msg(Msg::Query(QueryMsg::Fetch));
|
||||
orders.perform_cmd(cmds::timeout(50, || Msg::SetupObserver));
|
||||
// if the url contains create_link set the edit_link variable.
|
||||
// This variable then opens the create link dialog.
|
||||
let dialog = match url.next_path_part() {
|
||||
Some("create_link") => Dialog::EditLink {
|
||||
link_delta: LinkDelta::default(),
|
||||
qr: Loadable::Data(None),
|
||||
},
|
||||
None | Some(_) => Dialog::None,
|
||||
};
|
||||
|
||||
Model {
|
||||
links: Vec::new(), // will contain the links to display
|
||||
load_more: ElRef::new(), // will contain a reference to the load more button to be able to load more links after scrolling to the bottom.
|
||||
i18n, // to translate
|
||||
formconfig: LinkRequestForm::default(), // when requesting links the form is stored here
|
||||
inputs: EnumMap::default(), // the input fields for the searches
|
||||
dialog,
|
||||
handle_render: None,
|
||||
handle_timeout: None,
|
||||
observer: None, // load more on scroll - this is used to see if the load-more button is completely visible
|
||||
observer_callback: None,
|
||||
observer_entries: None,
|
||||
everything_loaded: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Model {
|
||||
links: Vec<Cached<FullLink>>, // will contain the links to display
|
||||
load_more: ElRef<web_sys::Element>,
|
||||
i18n: I18n, // to translate
|
||||
formconfig: LinkRequestForm, // when requesting links the form is stored here
|
||||
inputs: EnumMap<LinkOverviewColumns, FilterInput>, // the input fields for the searches
|
||||
dialog: Dialog, // User interaction - there can only ever be one dialog open.
|
||||
handle_render: Option<CmdHandle>, // Rendering qr-codes takes time... it is aborted when this handle is dropped and replaced.
|
||||
handle_timeout: Option<CmdHandle>, // Rendering qr-codes takes time... it is aborted when this handle is dropped and replaced.
|
||||
|
||||
observer: Option<IntersectionObserver>,
|
||||
observer_callback: Option<Closure<dyn Fn(Vec<JsValue>)>>,
|
||||
observer_entries: Option<Vec<IntersectionObserverEntry>>,
|
||||
everything_loaded: bool,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn set_lang(&mut self, l: Lang) {
|
||||
self.i18n.set_lang(l);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Cached<T> {
|
||||
data: T,
|
||||
cache: String,
|
||||
}
|
||||
|
||||
impl<T> Deref for Cached<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.data
|
||||
}
|
||||
}
|
||||
|
||||
/// There can always be only one dialog.
|
||||
#[derive(Debug, Clone)]
|
||||
enum Dialog {
|
||||
EditLink {
|
||||
link_delta: LinkDelta,
|
||||
qr: Loadable<QrGuard>,
|
||||
},
|
||||
Message(Status),
|
||||
Question(EditMsg),
|
||||
None,
|
||||
}
|
||||
|
||||
/// A qr-code with `new` for creating a blob url and `Drop` for releasing the blob url.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QrGuard {
|
||||
svg: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl QrGuard {
|
||||
fn new(code: &str) -> Self {
|
||||
log!("Generating new QrCode");
|
||||
let svg = generate_qr_from_code(code);
|
||||
|
||||
let mut properties = web_sys::BlobPropertyBag::new();
|
||||
properties.type_("image/png");
|
||||
let png_vec = generate_qr_png(code);
|
||||
|
||||
let png_jsarray: JsValue = js_sys::Uint8Array::from(&png_vec[..]).into();
|
||||
// the buffer has to be an array of arrays
|
||||
let png_buffer: js_sys::Array = std::array::IntoIter::new([png_jsarray]).collect();
|
||||
let png_blob =
|
||||
web_sys::Blob::new_with_buffer_source_sequence_and_options(&png_buffer, &properties)
|
||||
.unwrap();
|
||||
let url = web_sys::Url::create_object_url_with_blob(&png_blob).unwrap();
|
||||
Self { svg, url }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for QrGuard {
|
||||
/// release the blob url
|
||||
fn drop(&mut self) {
|
||||
web_sys::Url::revoke_object_url(&self.url)
|
||||
.unwrap_or_else(|_| (log!("Failed to release url!")));
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter one column of the row.
|
||||
#[derive(Default, Debug, Clone)]
|
||||
struct FilterInput {
|
||||
filter_input: ElRef<web_sys::HtmlInputElement>,
|
||||
}
|
||||
|
||||
/// A message can either edit or query. (or set a dialog)
|
||||
#[derive(Clone)]
|
||||
pub enum Msg {
|
||||
Query(QueryMsg), // Messages related to querying links
|
||||
Edit(EditMsg), // Messages related to editing links
|
||||
ClearAll, // Clear all messages
|
||||
SetupObserver, // Make an observer for endless scroll
|
||||
Observed(Vec<IntersectionObserverEntry>),
|
||||
SetMessage(String), // Set a message to the user
|
||||
}
|
||||
|
||||
/// All the messages related to requesting information from the server.
|
||||
#[derive(Clone)]
|
||||
pub enum QueryMsg {
|
||||
Fetch,
|
||||
FetchAdditional,
|
||||
OrderBy(LinkOverviewColumns),
|
||||
Received(Vec<FullLink>),
|
||||
ReceivedAdditional(Vec<FullLink>),
|
||||
CodeFilterChanged(String),
|
||||
DescriptionFilterChanged(String),
|
||||
TargetFilterChanged(String),
|
||||
AuthorFilterChanged(String),
|
||||
}
|
||||
|
||||
/// All the messages on storing information on the server.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EditMsg {
|
||||
EditSelected(LinkDelta),
|
||||
GenerateQr(String),
|
||||
QrGenerated(Loadable<QrGuard>),
|
||||
CreateNewLink,
|
||||
Created(Status),
|
||||
EditCodeChanged(String),
|
||||
EditDescriptionChanged(String),
|
||||
EditTargetChanged(String),
|
||||
MayDeleteSelected(LinkDelta),
|
||||
DeleteSelected(LinkDelta),
|
||||
SaveLink,
|
||||
FailedToCreateLink,
|
||||
FailedToDeleteLink,
|
||||
DeletedLink(Status),
|
||||
}
|
||||
|
||||
/// hide all dialogs
|
||||
fn clear_all(model: &mut Model) {
|
||||
model.dialog = Dialog::None;
|
||||
}
|
||||
|
||||
/// Split the update to Query updates and Edit updates.
|
||||
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
match msg {
|
||||
Msg::Query(msg) => process_query_messages(msg, model, orders),
|
||||
Msg::Edit(msg) => process_edit_messages(msg, model, orders),
|
||||
Msg::ClearAll => clear_all(model),
|
||||
Msg::Observed(entries) => {
|
||||
if let Some(entry) = entries.first() {
|
||||
log!(entry);
|
||||
if entry.is_intersecting() {
|
||||
orders.send_msg(Msg::Query(QueryMsg::FetchAdditional));
|
||||
}
|
||||
}
|
||||
}
|
||||
Msg::SetMessage(msg) => {
|
||||
clear_all(model);
|
||||
model.dialog = Dialog::Message(Status::Error(Message { message: msg }));
|
||||
}
|
||||
Msg::SetupObserver => {
|
||||
orders.skip();
|
||||
|
||||
// ---- observer callback ----
|
||||
let sender = orders.msg_sender();
|
||||
let callback = move |entries: Vec<JsValue>| {
|
||||
let entries = entries
|
||||
.into_iter()
|
||||
.map(IntersectionObserverEntry::from)
|
||||
.collect();
|
||||
sender(Some(Msg::Observed(entries)));
|
||||
};
|
||||
let callback = Closure::wrap(Box::new(callback) as Box<dyn Fn(Vec<JsValue>)>);
|
||||
|
||||
// ---- observer options ----
|
||||
let mut options = IntersectionObserverInit::new();
|
||||
options.threshold(&JsValue::from(1));
|
||||
// ---- observer ----
|
||||
log!("Trying to register observer");
|
||||
if let Ok(observer) =
|
||||
IntersectionObserver::new_with_options(callback.as_ref().unchecked_ref(), &options)
|
||||
{
|
||||
if let Some(element) = model.load_more.get() {
|
||||
log!("element registered! ", element);
|
||||
observer.observe(&element);
|
||||
|
||||
// Note: Drop `observer` is not enough. We have to call `observer.disconnect()`.
|
||||
model.observer = Some(observer);
|
||||
model.observer_callback = Some(callback);
|
||||
} else {
|
||||
log!("element not yet registered! ");
|
||||
};
|
||||
} else {
|
||||
log!("Failed to get observer!");
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process all messages for loading the information from the server.
|
||||
pub fn process_query_messages(msg: QueryMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
match msg {
|
||||
QueryMsg::Fetch => {
|
||||
orders.skip(); // No need to rerender
|
||||
initial_load(model, orders);
|
||||
}
|
||||
QueryMsg::FetchAdditional => {
|
||||
orders.skip(); // No need to rerender
|
||||
consecutive_load(model, orders);
|
||||
}
|
||||
// Default to ascending ordering but if the links are already sorted according to this column toggle between ascending and descending ordering.
|
||||
QueryMsg::OrderBy(column) => {
|
||||
model.formconfig.order = model.formconfig.order.as_ref().map_or_else(
|
||||
|| {
|
||||
Some(Operation {
|
||||
column: column.clone(),
|
||||
value: Ordering::Ascending,
|
||||
})
|
||||
},
|
||||
|order| {
|
||||
Some(Operation {
|
||||
column: column.clone(),
|
||||
value: if order.column == column && order.value == Ordering::Ascending {
|
||||
Ordering::Descending
|
||||
} else {
|
||||
Ordering::Ascending
|
||||
},
|
||||
})
|
||||
},
|
||||
);
|
||||
// After setting up the ordering fetch the links from the server again with the new filter settings.
|
||||
// If the new filters and ordering include more links the list would be incomplete otherwise.
|
||||
orders.send_msg(Msg::Query(QueryMsg::Fetch));
|
||||
|
||||
// Also sort the links locally - can probably removed...
|
||||
model.links.sort_by(match column {
|
||||
LinkOverviewColumns::Code => {
|
||||
|o: &Cached<FullLink>, t: &Cached<FullLink>| o.link.code.cmp(&t.link.code)
|
||||
}
|
||||
LinkOverviewColumns::Description => {
|
||||
|o: &Cached<FullLink>, t: &Cached<FullLink>| o.link.title.cmp(&t.link.title)
|
||||
}
|
||||
LinkOverviewColumns::Target => {
|
||||
|o: &Cached<FullLink>, t: &Cached<FullLink>| o.link.target.cmp(&t.link.target)
|
||||
}
|
||||
LinkOverviewColumns::Author => |o: &Cached<FullLink>, t: &Cached<FullLink>| {
|
||||
o.user.username.cmp(&t.user.username)
|
||||
},
|
||||
LinkOverviewColumns::Statistics => |o: &Cached<FullLink>, t: &Cached<FullLink>| {
|
||||
o.clicks.number.cmp(&t.clicks.number)
|
||||
},
|
||||
});
|
||||
}
|
||||
QueryMsg::Received(response) => {
|
||||
model.links = response
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let cache = generate_qr_from_code(&l.link.code);
|
||||
Cached { data: l, cache }
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
QueryMsg::ReceivedAdditional(response) => {
|
||||
if response.len() < model.formconfig.amount {
|
||||
log!("There are no more links! ");
|
||||
model.everything_loaded = true;
|
||||
};
|
||||
let mut new_links = response
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let cache = generate_qr_from_code(&l.link.code);
|
||||
Cached { data: l, cache }
|
||||
})
|
||||
.collect();
|
||||
model.links.append(&mut new_links);
|
||||
}
|
||||
QueryMsg::CodeFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[LinkOverviewColumns::Code].sieve = sanit;
|
||||
orders.send_msg(Msg::Query(QueryMsg::Fetch));
|
||||
}
|
||||
QueryMsg::DescriptionFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[LinkOverviewColumns::Description].sieve = sanit;
|
||||
orders.send_msg(Msg::Query(QueryMsg::Fetch));
|
||||
}
|
||||
QueryMsg::TargetFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[LinkOverviewColumns::Target].sieve = sanit;
|
||||
orders.send_msg(Msg::Query(QueryMsg::Fetch));
|
||||
}
|
||||
QueryMsg::AuthorFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[LinkOverviewColumns::Author].sieve = sanit;
|
||||
orders.send_msg(Msg::Query(QueryMsg::Fetch));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn initial_load(model: &Model, orders: &mut impl Orders<Msg>) {
|
||||
let mut data = model.formconfig.clone();
|
||||
data.offset = 0;
|
||||
load_links(orders, data);
|
||||
}
|
||||
fn consecutive_load(model: &Model, orders: &mut impl Orders<Msg>) {
|
||||
let mut data = model.formconfig.clone();
|
||||
data.offset = model.links.len();
|
||||
load_links(orders, data);
|
||||
}
|
||||
|
||||
/// Perform a request to the server to load the links from the server.
|
||||
fn load_links(orders: &mut impl Orders<Msg>, data: LinkRequestForm) {
|
||||
orders.perform_cmd(async {
|
||||
let data = data;
|
||||
// create a request
|
||||
let request = unwrap_or_return!(
|
||||
Request::new("/admin/json/list_links/")
|
||||
.method(Method::Post)
|
||||
.json(&data),
|
||||
Msg::SetMessage("Failed to parse data".to_string())
|
||||
);
|
||||
// send the request and receive a response
|
||||
let response = unwrap_or_return!(
|
||||
fetch(request).await,
|
||||
Msg::SetMessage("Failed to send data".to_string())
|
||||
);
|
||||
// check the html status to be 200
|
||||
let response = unwrap_or_return!(
|
||||
response.check_status(),
|
||||
Msg::SetMessage("Wrong response code".to_string())
|
||||
);
|
||||
// unpack the response into the `Vec<FullLink>`
|
||||
let links: Vec<FullLink> = unwrap_or_return!(
|
||||
response.json().await,
|
||||
Msg::SetMessage("Invalid response".to_string())
|
||||
);
|
||||
// The message that is sent by perform_cmd after this async block is completed
|
||||
match data.offset.cmp(&0) {
|
||||
std::cmp::Ordering::Less => unreachable!(),
|
||||
std::cmp::Ordering::Equal => Msg::Query(QueryMsg::Received(links)),
|
||||
std::cmp::Ordering::Greater => Msg::Query(QueryMsg::ReceivedAdditional(links)),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Process all the events related to editing links.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn process_edit_messages(msg: EditMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
match msg {
|
||||
EditMsg::EditSelected(link) => {
|
||||
let link_delta = link;
|
||||
model.dialog = Dialog::EditLink {
|
||||
link_delta: link_delta.clone(),
|
||||
qr: Loadable::Data(None),
|
||||
};
|
||||
let code = link_delta.code;
|
||||
model.handle_render = None;
|
||||
model.handle_timeout = Some(orders.perform_cmd_with_handle(cmds::timeout(300, || {
|
||||
Msg::Edit(EditMsg::GenerateQr(code))
|
||||
})));
|
||||
}
|
||||
EditMsg::GenerateQr(code) => {
|
||||
model.handle_render = Some(orders.perform_cmd_with_handle(async move {
|
||||
let qr_code = Loadable::Data(Some(QrGuard::new(&code)));
|
||||
Msg::Edit(EditMsg::QrGenerated(qr_code))
|
||||
}));
|
||||
}
|
||||
EditMsg::QrGenerated(qr_code) => {
|
||||
let new_dialog = if let Dialog::EditLink {
|
||||
ref link_delta,
|
||||
qr: _,
|
||||
} = model.dialog
|
||||
{
|
||||
Some(Dialog::EditLink {
|
||||
link_delta: link_delta.clone(),
|
||||
qr: qr_code,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(dialog) = new_dialog {
|
||||
model.dialog = dialog;
|
||||
}
|
||||
}
|
||||
EditMsg::CreateNewLink => {
|
||||
clear_all(model);
|
||||
model.dialog = Dialog::EditLink {
|
||||
link_delta: LinkDelta::default(),
|
||||
qr: Loadable::Data(None),
|
||||
}
|
||||
}
|
||||
EditMsg::Created(success_msg) => {
|
||||
clear_all(model);
|
||||
model.dialog = Dialog::Message(success_msg);
|
||||
orders.send_msg(Msg::Query(QueryMsg::Fetch));
|
||||
}
|
||||
EditMsg::EditCodeChanged(s) => {
|
||||
if let Dialog::EditLink {
|
||||
mut link_delta,
|
||||
qr: _,
|
||||
} = model.dialog.clone()
|
||||
{
|
||||
link_delta.code = s.clone();
|
||||
model.handle_render = None;
|
||||
model.handle_timeout =
|
||||
Some(orders.perform_cmd_with_handle(cmds::timeout(300, || {
|
||||
Msg::Edit(EditMsg::GenerateQr(s))
|
||||
})));
|
||||
model.dialog = Dialog::EditLink {
|
||||
link_delta,
|
||||
qr: Loadable::Loading,
|
||||
};
|
||||
}
|
||||
}
|
||||
EditMsg::EditDescriptionChanged(s) => {
|
||||
if let Dialog::EditLink {
|
||||
ref mut link_delta, ..
|
||||
} = model.dialog
|
||||
{
|
||||
link_delta.title = s;
|
||||
}
|
||||
}
|
||||
EditMsg::EditTargetChanged(s) => {
|
||||
if let Dialog::EditLink {
|
||||
ref mut link_delta, ..
|
||||
} = model.dialog
|
||||
{
|
||||
link_delta.target = s;
|
||||
}
|
||||
}
|
||||
EditMsg::SaveLink => {
|
||||
if let Dialog::EditLink { link_delta, .. } = model.dialog.clone() {
|
||||
save_link(link_delta, orders);
|
||||
}
|
||||
}
|
||||
EditMsg::FailedToCreateLink => {
|
||||
orders.send_msg(Msg::SetMessage("Failed to create this link!".to_string()));
|
||||
log!("Failed to create Link");
|
||||
}
|
||||
// capture including the message part
|
||||
link @ EditMsg::MayDeleteSelected(..) => {
|
||||
clear_all(model);
|
||||
model.dialog = Dialog::Question(link);
|
||||
}
|
||||
EditMsg::DeleteSelected(link) => delete_link(link, orders),
|
||||
EditMsg::FailedToDeleteLink => log!("Failed to delete Link"),
|
||||
|
||||
EditMsg::DeletedLink(message) => {
|
||||
clear_all(model);
|
||||
model.dialog = Dialog::Message(message);
|
||||
orders.send_msg(Msg::Query(QueryMsg::Fetch));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a link save request to the server.
|
||||
fn save_link(link_delta: LinkDelta, orders: &mut impl Orders<Msg>) {
|
||||
let data = link_delta;
|
||||
orders.perform_cmd(async {
|
||||
let data = data;
|
||||
// create the request
|
||||
let request = unwrap_or_return!(
|
||||
Request::new(match data.edit {
|
||||
EditMode::Create => "/admin/json/create_link/",
|
||||
EditMode::Edit => "/admin/json/edit_link/",
|
||||
})
|
||||
.method(Method::Post)
|
||||
.json(&data),
|
||||
Msg::SetMessage("Failed to encode the link!".to_string())
|
||||
);
|
||||
// perform the request
|
||||
let response =
|
||||
unwrap_or_return!(fetch(request).await, Msg::Edit(EditMsg::FailedToCreateLink));
|
||||
|
||||
// check the response status
|
||||
let response = unwrap_or_return!(
|
||||
response.check_status(),
|
||||
Msg::SetMessage("Wrong response code".to_string())
|
||||
);
|
||||
// Parse the response
|
||||
let message: Status = unwrap_or_return!(
|
||||
response.json().await,
|
||||
Msg::SetMessage("Invalid response!".to_string())
|
||||
);
|
||||
|
||||
Msg::Edit(EditMsg::Created(message))
|
||||
});
|
||||
}
|
||||
|
||||
/// Send a link delete request to the server.
|
||||
fn delete_link(link_delta: LinkDelta, orders: &mut impl Orders<Msg>) {
|
||||
orders.perform_cmd(async move {
|
||||
// create the request
|
||||
let request = unwrap_or_return!(
|
||||
Request::new("/admin/json/delete_link/")
|
||||
.method(Method::Post)
|
||||
.json(&link_delta),
|
||||
Msg::SetMessage("serialization failed".to_string())
|
||||
);
|
||||
// perform the request and receive a response
|
||||
let response =
|
||||
unwrap_or_return!(fetch(request).await, Msg::Edit(EditMsg::FailedToDeleteLink));
|
||||
|
||||
// check the status of the response
|
||||
let response = unwrap_or_return!(
|
||||
response.check_status(),
|
||||
Msg::SetMessage("Wrong response code!".to_string())
|
||||
);
|
||||
// deserialize the response
|
||||
let message: Status = unwrap_or_return!(
|
||||
response.json().await,
|
||||
Msg::SetMessage(
|
||||
"Failed to parse the response! The link might or might not be deleted!".to_string()
|
||||
)
|
||||
);
|
||||
|
||||
Msg::Edit(EditMsg::DeletedLink(message))
|
||||
});
|
||||
}
|
||||
|
||||
/// view the page
|
||||
/// * messages
|
||||
/// * questions
|
||||
/// * the table of links including sorting and searching
|
||||
#[must_use]
|
||||
pub fn view(model: &Model, logged_in_user: &User) -> Node<Msg> {
|
||||
let lang = &model.i18n.clone();
|
||||
// shortcut for translating
|
||||
let t = move |key: &str| lang.translate(key, None);
|
||||
section![
|
||||
// display a message if any
|
||||
match &model.dialog {
|
||||
Dialog::EditLink { link_delta, qr } => nodes![edit_or_create_link(link_delta, qr, t)],
|
||||
Dialog::Message(message) => nodes![div![
|
||||
C!["message", "center"],
|
||||
close_button(),
|
||||
match message {
|
||||
Status::Success(m) | Status::Error(m) => &m.message,
|
||||
}
|
||||
]],
|
||||
Dialog::Question(question) => nodes![div![
|
||||
C!["message", "center"],
|
||||
close_button(),
|
||||
if let EditMsg::MayDeleteSelected(l) = question.clone() {
|
||||
nodes![div![
|
||||
lang.translate(
|
||||
"really-delete",
|
||||
Some(&fluent_args!["code" => l.code.clone()])
|
||||
),
|
||||
a![t("no"), C!["button"], ev(Ev::Click, |_| Msg::ClearAll)],
|
||||
a![t("yes"), C!["button"], {
|
||||
ev(Ev::Click, move |_| Msg::Edit(EditMsg::DeleteSelected(l)))
|
||||
}]
|
||||
]]
|
||||
} else {
|
||||
nodes!()
|
||||
}
|
||||
]],
|
||||
Dialog::None => nodes![],
|
||||
},
|
||||
// display the list of links
|
||||
table![
|
||||
// Add the headlines
|
||||
view_link_table_head(&t),
|
||||
// Add filter fields right below the headlines
|
||||
view_link_table_filter_input(model, &t),
|
||||
// Add all the content lines
|
||||
model.links.iter().map(|l| { view_link(l, logged_in_user) })
|
||||
],
|
||||
if not(model.everything_loaded) {
|
||||
a![
|
||||
C!["loadmore", "button"],
|
||||
el_ref(&model.load_more),
|
||||
ev(Ev::Click, move |_| Msg::Query(QueryMsg::FetchAdditional)),
|
||||
img![C!["reloadicon"], attrs!(At::Src => "/static/reload.svg")],
|
||||
t("load-more-links")
|
||||
]
|
||||
} else {
|
||||
a![C!["loadmore", "button"], t("no-more-links")]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/// Create the headlines of the link table
|
||||
fn view_link_table_head<F: Fn(&str) -> String>(t: F) -> Node<Msg> {
|
||||
tr![
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::Query(QueryMsg::OrderBy(
|
||||
LinkOverviewColumns::Code
|
||||
))),
|
||||
t("link-code")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::Query(QueryMsg::OrderBy(
|
||||
LinkOverviewColumns::Description
|
||||
))),
|
||||
t("link-description")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::Query(QueryMsg::OrderBy(
|
||||
LinkOverviewColumns::Target
|
||||
))),
|
||||
t("link-target")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::Query(QueryMsg::OrderBy(
|
||||
LinkOverviewColumns::Author
|
||||
))),
|
||||
t("username")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::Query(QueryMsg::OrderBy(
|
||||
LinkOverviewColumns::Statistics
|
||||
))),
|
||||
t("statistics")
|
||||
],
|
||||
th![],
|
||||
th![]
|
||||
]
|
||||
}
|
||||
|
||||
/// Create the filter fields in the table columns
|
||||
fn view_link_table_filter_input<F: Fn(&str) -> String>(model: &Model, t: F) -> Node<Msg> {
|
||||
tr![
|
||||
C!["filters"],
|
||||
td![input![
|
||||
attrs! {
|
||||
At::Value => &model.formconfig.filter[LinkOverviewColumns::Code].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, |s| Msg::Query(QueryMsg::CodeFilterChanged(s))),
|
||||
el_ref(&model.inputs[LinkOverviewColumns::Code].filter_input),
|
||||
]],
|
||||
td![input![
|
||||
attrs! {At::Value =>
|
||||
&model
|
||||
.formconfig.filter[LinkOverviewColumns::Description].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, |s| Msg::Query(
|
||||
QueryMsg::DescriptionFilterChanged(s)
|
||||
)),
|
||||
el_ref(&model.inputs[LinkOverviewColumns::Description].filter_input),
|
||||
]],
|
||||
td![input![
|
||||
attrs! {At::Value =>
|
||||
&model
|
||||
.formconfig.filter[LinkOverviewColumns::Target].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, |s| Msg::Query(QueryMsg::TargetFilterChanged(s))),
|
||||
el_ref(&model.inputs[LinkOverviewColumns::Target].filter_input),
|
||||
]],
|
||||
td![input![
|
||||
attrs! {At::Value =>
|
||||
&model
|
||||
.formconfig.filter[LinkOverviewColumns::Author].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, |s| Msg::Query(QueryMsg::AuthorFilterChanged(s))),
|
||||
el_ref(&model.inputs[LinkOverviewColumns::Author].filter_input),
|
||||
]],
|
||||
// statistics and the delete column cannot be filtered
|
||||
td![],
|
||||
td![],
|
||||
td![],
|
||||
]
|
||||
}
|
||||
|
||||
/// display a single table row containing one link
|
||||
fn view_link(l: &Cached<FullLink>, logged_in_user: &User) -> Node<Msg> {
|
||||
use pslink_shared::apirequests::users::Role;
|
||||
let link = LinkDelta::from(l.data.clone());
|
||||
tr![
|
||||
IF! (logged_in_user.role == Role::Admin
|
||||
|| (logged_in_user.role == Role::Regular) && l.user.id == logged_in_user.id =>
|
||||
ev(Ev::Click, |_| Msg::Edit(EditMsg::EditSelected(link)))),
|
||||
td![&l.link.code],
|
||||
td![&l.link.title],
|
||||
td![&l.link.target],
|
||||
td![&l.user.username],
|
||||
td![&l.clicks.number],
|
||||
{
|
||||
td![
|
||||
C!["table_qr"],
|
||||
a![
|
||||
ev(Ev::Click, |event| event.stop_propagation()),
|
||||
attrs![At::Href => format!("/admin/download/png/{}", &l.link.code),
|
||||
At::Download => true.as_at_value()],
|
||||
raw!(&l.cache)
|
||||
]
|
||||
]
|
||||
},
|
||||
if logged_in_user.role == Role::Admin
|
||||
|| (logged_in_user.role == Role::Regular) && l.user.id == logged_in_user.id
|
||||
{
|
||||
let link = LinkDelta::from(l.data.clone());
|
||||
td![
|
||||
ev(Ev::Click, |event| {
|
||||
event.stop_propagation();
|
||||
Msg::Edit(EditMsg::MayDeleteSelected(link))
|
||||
}),
|
||||
img![C!["trashicon"], attrs!(At::Src => "/static/trash.svg")]
|
||||
]
|
||||
} else {
|
||||
td![]
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// display a link editing dialog with save and close button
|
||||
fn edit_or_create_link<F: Fn(&str) -> String>(
|
||||
link: &LinkDelta,
|
||||
qr: &Loadable<QrGuard>,
|
||||
t: F,
|
||||
) -> Node<Msg> {
|
||||
div![
|
||||
// close button top right
|
||||
C!["editdialog", "center"],
|
||||
close_button(),
|
||||
h1![match &link.edit {
|
||||
EditMode::Edit => t("edit-link"),
|
||||
EditMode::Create => t("create-link"),
|
||||
}],
|
||||
table![
|
||||
tr![
|
||||
th![t("link-description")],
|
||||
td![input![
|
||||
attrs! {
|
||||
At::Value => &link.title,
|
||||
At::Type => "text",
|
||||
At::Placeholder => t("link-description")
|
||||
},
|
||||
input_ev(Ev::Input, |s| {
|
||||
Msg::Edit(EditMsg::EditDescriptionChanged(s))
|
||||
}),
|
||||
]]
|
||||
],
|
||||
tr![
|
||||
th![t("link-target")],
|
||||
td![input![
|
||||
attrs! {
|
||||
At::Value => &link.target,
|
||||
At::Type => "text",
|
||||
At::Placeholder => t("link-target")
|
||||
},
|
||||
input_ev(Ev::Input, |s| { Msg::Edit(EditMsg::EditTargetChanged(s)) }),
|
||||
]]
|
||||
],
|
||||
tr![
|
||||
th![t("link-code")],
|
||||
td![input![
|
||||
attrs! {
|
||||
At::Value => &link.code,
|
||||
At::Type => "text",
|
||||
At::Placeholder => t("link-code")
|
||||
},
|
||||
input_ev(Ev::Input, |s| { Msg::Edit(EditMsg::EditCodeChanged(s)) }),
|
||||
],]
|
||||
],
|
||||
tr![
|
||||
th![t("qr-code")],
|
||||
qr.as_ref().map_or_else(|| td!["Loading..."], render_qr),
|
||||
]
|
||||
],
|
||||
a![
|
||||
match &link.edit {
|
||||
EditMode::Edit => t("edit-link"),
|
||||
EditMode::Create => t("create-link"),
|
||||
},
|
||||
C!["button"],
|
||||
ev(Ev::Click, |_| Msg::Edit(EditMsg::SaveLink))
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
fn render_qr(qr: &QrGuard) -> Node<Msg> {
|
||||
td![a![
|
||||
span![C!["qrdownload"], "Download", raw!(&qr.svg),],
|
||||
attrs!(At::Href => qr.url, At::Download => "qr-code.png")
|
||||
]]
|
||||
}
|
||||
|
||||
/// generate a qr-code for a code
|
||||
fn generate_qr_from_code(code: &str) -> String {
|
||||
generate_qr_from_link(&format!("https://{}/{}", get_host(), code))
|
||||
}
|
||||
|
||||
/// generate a svg qr-code for a url
|
||||
fn generate_qr_from_link(url: &str) -> String {
|
||||
if let Ok(qr) = QrCode::with_error_correction_level(&url, qrcode::EcLevel::L) {
|
||||
let svg = qr
|
||||
.render()
|
||||
.min_dimensions(100, 100)
|
||||
.dark_color(svg::Color("#000000"))
|
||||
.light_color(svg::Color("#ffffff"))
|
||||
.build();
|
||||
svg
|
||||
} else {
|
||||
// should never (only on very huge codes) happen.
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// a close button for dialogs
|
||||
fn close_button() -> Node<Msg> {
|
||||
div![
|
||||
C!["closebutton"],
|
||||
a!["\u{d7}"],
|
||||
ev(Ev::Click, |_| Msg::ClearAll)
|
||||
]
|
||||
}
|
||||
|
||||
/// generate a png qr-code for a url
|
||||
fn generate_qr_png(code: &str) -> Vec<u8> {
|
||||
let qr = QrCode::with_error_correction_level(
|
||||
&format!("http://{}/{}", get_host(), code),
|
||||
qrcode::EcLevel::L,
|
||||
)
|
||||
.unwrap();
|
||||
let png = qr.render::<Luma<u8>>().quiet_zone(false).build();
|
||||
let mut temporary_data = std::io::Cursor::new(Vec::new());
|
||||
DynamicImage::ImageLuma8(png)
|
||||
.write_to(&mut temporary_data, ImageOutputFormat::Png)
|
||||
.unwrap();
|
||||
temporary_data.into_inner()
|
||||
}
|
582
app/src/pages/list_users.rs
Normal file
582
app/src/pages/list_users.rs
Normal file
@ -0,0 +1,582 @@
|
||||
//! List all users in case an admin views it, list the "self" user otherwise.
|
||||
|
||||
use enum_map::EnumMap;
|
||||
use pslink_locales::I18n;
|
||||
use pslink_shared::{
|
||||
apirequests::general::{Operation, Ordering},
|
||||
apirequests::{
|
||||
general::{EditMode, Status},
|
||||
users::{Role, UserDelta, UserOverviewColumns, UserRequestForm},
|
||||
},
|
||||
datatypes::{Lang, User},
|
||||
};
|
||||
use seed::{a, attrs, div, h1, input, log, p, prelude::*, section, table, td, th, tr, Url, C, IF};
|
||||
/*
|
||||
* init
|
||||
*/
|
||||
use crate::unwrap_or_return;
|
||||
#[must_use]
|
||||
pub fn init(mut url: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Model {
|
||||
orders.send_msg(Msg::Query(UserQueryMsg::Fetch));
|
||||
let user_edit = match url.next_path_part() {
|
||||
Some("create_user") => Some(UserDelta::default()),
|
||||
None | Some(_) => None,
|
||||
};
|
||||
Model {
|
||||
users: Vec::new(),
|
||||
i18n,
|
||||
formconfig: UserRequestForm::default(),
|
||||
inputs: EnumMap::default(),
|
||||
user_edit,
|
||||
last_message: None,
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Model {
|
||||
users: Vec<User>,
|
||||
i18n: I18n,
|
||||
formconfig: UserRequestForm,
|
||||
inputs: EnumMap<UserOverviewColumns, FilterInput>,
|
||||
user_edit: Option<UserDelta>,
|
||||
last_message: Option<Status>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// set the language of this page (part)
|
||||
pub fn set_lang(&mut self, l: Lang) {
|
||||
self.i18n.set_lang(l);
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// removing all open dialogs (often to open another afterwards).
|
||||
fn clean_dialogs(&mut self) {
|
||||
self.last_message = None;
|
||||
self.user_edit = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// A type containing one input field for later use.
|
||||
#[derive(Default, Debug, Clone)]
|
||||
struct FilterInput {
|
||||
filter_input: ElRef<web_sys::HtmlInputElement>,
|
||||
}
|
||||
|
||||
/// The message splits the contained message into messages related to querying and messages related to editing.
|
||||
#[derive(Clone)]
|
||||
pub enum Msg {
|
||||
Query(UserQueryMsg),
|
||||
Edit(UserEditMsg),
|
||||
ClearAll,
|
||||
}
|
||||
|
||||
/// All the messages on user Querying
|
||||
#[derive(Clone)]
|
||||
pub enum UserQueryMsg {
|
||||
Fetch,
|
||||
FailedToFetchUsers,
|
||||
OrderBy(UserOverviewColumns),
|
||||
Received(Vec<User>),
|
||||
IdFilterChanged(String),
|
||||
EmailFilterChanged(String),
|
||||
UsernameFilterChanged(String),
|
||||
}
|
||||
/// All the messages on user editing
|
||||
#[derive(Clone)]
|
||||
pub enum UserEditMsg {
|
||||
EditUserSelected(UserDelta),
|
||||
CreateNewUser,
|
||||
UserCreated(Status),
|
||||
EditUsernameChanged(String),
|
||||
EditEmailChanged(String),
|
||||
EditPasswordChanged(String),
|
||||
MakeAdmin(UserDelta),
|
||||
MakeRegular(UserDelta),
|
||||
SaveUser,
|
||||
FailedToCreateUser,
|
||||
}
|
||||
/*
|
||||
* update
|
||||
*/
|
||||
|
||||
/// Split the update to Query updates and Edit updates
|
||||
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
match msg {
|
||||
Msg::Query(msg) => process_query_messages(msg, model, orders),
|
||||
Msg::Edit(msg) => process_user_edit_messages(msg, model, orders),
|
||||
Msg::ClearAll => {
|
||||
model.clean_dialogs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process all updates related to getting data from the server.
|
||||
pub fn process_query_messages(msg: UserQueryMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
match msg {
|
||||
UserQueryMsg::Fetch => {
|
||||
orders.skip(); // No need to rerender only after the data is fetched the page has to be rerendered.
|
||||
load_users(model.formconfig.clone(), orders);
|
||||
}
|
||||
UserQueryMsg::OrderBy(column) => {
|
||||
model.formconfig.order = model.formconfig.order.as_ref().map_or_else(
|
||||
|| {
|
||||
Some(Operation {
|
||||
column: column.clone(),
|
||||
value: Ordering::Ascending,
|
||||
})
|
||||
},
|
||||
|order| {
|
||||
Some(Operation {
|
||||
column: column.clone(),
|
||||
value: if order.column == column && order.value == Ordering::Ascending {
|
||||
Ordering::Descending
|
||||
} else {
|
||||
Ordering::Ascending
|
||||
},
|
||||
})
|
||||
},
|
||||
);
|
||||
orders.send_msg(Msg::Query(UserQueryMsg::Fetch));
|
||||
|
||||
model.users.sort_by(match column {
|
||||
UserOverviewColumns::Id => |o: &User, t: &User| o.id.cmp(&t.id),
|
||||
UserOverviewColumns::Username => |o: &User, t: &User| o.username.cmp(&t.username),
|
||||
UserOverviewColumns::Email => |o: &User, t: &User| o.email.cmp(&t.email),
|
||||
});
|
||||
}
|
||||
UserQueryMsg::Received(response) => {
|
||||
model.users = response;
|
||||
}
|
||||
UserQueryMsg::IdFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_numeric()).collect();
|
||||
model.formconfig.filter[UserOverviewColumns::Id].sieve = sanit;
|
||||
orders.send_msg(Msg::Query(UserQueryMsg::Fetch));
|
||||
}
|
||||
UserQueryMsg::UsernameFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[UserOverviewColumns::Username].sieve = sanit;
|
||||
orders.send_msg(Msg::Query(UserQueryMsg::Fetch));
|
||||
}
|
||||
UserQueryMsg::EmailFilterChanged(s) => {
|
||||
log!("Filter is: ", &s);
|
||||
// FIXME: Sanitation does not work for @
|
||||
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
model.formconfig.filter[UserOverviewColumns::Email].sieve = sanit;
|
||||
orders.send_msg(Msg::Query(UserQueryMsg::Fetch));
|
||||
}
|
||||
|
||||
UserQueryMsg::FailedToFetchUsers => {
|
||||
log!("Failed to fetch users");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the list of users from the server.
|
||||
fn load_users(data: UserRequestForm, orders: &mut impl Orders<Msg>) {
|
||||
orders.perform_cmd(async {
|
||||
let data = data;
|
||||
// create the request
|
||||
let request = unwrap_or_return!(
|
||||
Request::new("/admin/json/list_users/")
|
||||
.method(Method::Post)
|
||||
.json(&data),
|
||||
Msg::Query(UserQueryMsg::FailedToFetchUsers)
|
||||
);
|
||||
// request and get response
|
||||
let response = unwrap_or_return!(
|
||||
fetch(request).await,
|
||||
Msg::Query(UserQueryMsg::FailedToFetchUsers)
|
||||
);
|
||||
// check the response status
|
||||
let response = unwrap_or_return!(
|
||||
response.check_status(),
|
||||
Msg::Query(UserQueryMsg::FailedToFetchUsers)
|
||||
);
|
||||
// deserialize the users list
|
||||
let users: Vec<User> = unwrap_or_return!(
|
||||
response.json().await,
|
||||
Msg::Query(UserQueryMsg::FailedToFetchUsers)
|
||||
);
|
||||
|
||||
Msg::Query(UserQueryMsg::Received(users))
|
||||
});
|
||||
}
|
||||
|
||||
/// Process all the messages related to editing users.
|
||||
pub fn process_user_edit_messages(
|
||||
msg: UserEditMsg,
|
||||
model: &mut Model,
|
||||
orders: &mut impl Orders<Msg>,
|
||||
) {
|
||||
match msg {
|
||||
UserEditMsg::EditUserSelected(user) => {
|
||||
model.clean_dialogs();
|
||||
model.user_edit = Some(user);
|
||||
}
|
||||
UserEditMsg::CreateNewUser => {
|
||||
model.clean_dialogs();
|
||||
model.user_edit = Some(UserDelta::default());
|
||||
}
|
||||
UserEditMsg::EditUsernameChanged(s) => {
|
||||
if let Some(ref mut ue) = model.user_edit {
|
||||
ue.username = s;
|
||||
};
|
||||
}
|
||||
UserEditMsg::EditEmailChanged(s) => {
|
||||
if let Some(ref mut ue) = model.user_edit {
|
||||
ue.email = s;
|
||||
};
|
||||
}
|
||||
UserEditMsg::EditPasswordChanged(s) => {
|
||||
if let Some(ref mut ue) = model.user_edit {
|
||||
ue.password = Some(s);
|
||||
};
|
||||
}
|
||||
UserEditMsg::SaveUser => {
|
||||
let data = model
|
||||
.user_edit
|
||||
.take()
|
||||
.expect("A user should always be there on save");
|
||||
log!("Saving User: ", &data.username);
|
||||
save_user(data, orders);
|
||||
}
|
||||
UserEditMsg::FailedToCreateUser => {
|
||||
log!("Failed to create user");
|
||||
}
|
||||
UserEditMsg::UserCreated(u) => {
|
||||
log!(u, "created user");
|
||||
model.last_message = Some(u);
|
||||
model.user_edit = None;
|
||||
orders.send_msg(Msg::Query(UserQueryMsg::Fetch));
|
||||
}
|
||||
UserEditMsg::MakeAdmin(user) | UserEditMsg::MakeRegular(user) => {
|
||||
update_privileges(user, orders);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the role of a user - this toggles between admin and regular.
|
||||
fn update_privileges(user: UserDelta, orders: &mut impl Orders<Msg>) {
|
||||
orders.perform_cmd(async {
|
||||
let data = user;
|
||||
// create the request
|
||||
let request = unwrap_or_return!(
|
||||
Request::new("/admin/json/update_privileges/")
|
||||
.method(Method::Post)
|
||||
.json(&data),
|
||||
Msg::Edit(UserEditMsg::FailedToCreateUser)
|
||||
);
|
||||
// perform the request and get the response
|
||||
let response = unwrap_or_return!(
|
||||
fetch(request).await,
|
||||
Msg::Edit(UserEditMsg::FailedToCreateUser)
|
||||
);
|
||||
// check for the status
|
||||
let response = unwrap_or_return!(
|
||||
response.check_status(),
|
||||
Msg::Edit(UserEditMsg::FailedToCreateUser)
|
||||
);
|
||||
// deserialize the response
|
||||
let message: Status = unwrap_or_return!(
|
||||
response.json().await,
|
||||
Msg::Edit(UserEditMsg::FailedToCreateUser)
|
||||
);
|
||||
|
||||
Msg::Edit(UserEditMsg::UserCreated(message))
|
||||
});
|
||||
}
|
||||
|
||||
/// Save a new user or edit an existing user
|
||||
fn save_user(user: UserDelta, orders: &mut impl Orders<Msg>) {
|
||||
orders.perform_cmd(async {
|
||||
let data = user;
|
||||
// create the request
|
||||
let request = unwrap_or_return!(
|
||||
Request::new(match data.edit {
|
||||
EditMode::Create => "/admin/json/create_user/",
|
||||
EditMode::Edit => "/admin/json/update_user/",
|
||||
})
|
||||
.method(Method::Post)
|
||||
.json(&data),
|
||||
Msg::Edit(UserEditMsg::FailedToCreateUser)
|
||||
);
|
||||
// perform the request and get the response
|
||||
let response = unwrap_or_return!(
|
||||
fetch(request).await,
|
||||
Msg::Edit(UserEditMsg::FailedToCreateUser)
|
||||
);
|
||||
// check for the status
|
||||
let response = unwrap_or_return!(
|
||||
response.check_status(),
|
||||
Msg::Edit(UserEditMsg::FailedToCreateUser)
|
||||
);
|
||||
// deserialize the response
|
||||
let message: Status = unwrap_or_return!(
|
||||
response.json().await,
|
||||
Msg::Edit(UserEditMsg::FailedToCreateUser)
|
||||
);
|
||||
|
||||
Msg::Edit(UserEditMsg::UserCreated(message))
|
||||
});
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// View the users page.
|
||||
pub fn view(model: &Model, logged_in_user: &User) -> Node<Msg> {
|
||||
let lang = model.i18n.clone();
|
||||
// shortcut for easier translations
|
||||
let t = move |key: &str| lang.translate(key, None);
|
||||
section![
|
||||
// Clear all dialogs on press of the ESC button.
|
||||
keyboard_ev(Ev::KeyDown, |keyboard_event| {
|
||||
IF!(keyboard_event.key() == "Escape" => Msg::ClearAll)
|
||||
}),
|
||||
// display the messages to the user
|
||||
if let Some(message) = &model.last_message {
|
||||
div![
|
||||
C!["message", "center"],
|
||||
close_button(),
|
||||
match message {
|
||||
Status::Success(m) | Status::Error(m) => {
|
||||
&m.message
|
||||
}
|
||||
}
|
||||
]
|
||||
} else {
|
||||
section![]
|
||||
},
|
||||
// display the table with users
|
||||
table![
|
||||
// Column Headlines
|
||||
view_user_table_head(&t),
|
||||
// Add filter fields right below the headlines
|
||||
view_user_table_filter_input(model, &t),
|
||||
// Add all the users one line for each
|
||||
model
|
||||
.users
|
||||
.iter()
|
||||
.map(|u| { view_user(u, logged_in_user, &t) })
|
||||
],
|
||||
// Display the user edit dialog if available
|
||||
if let Some(l) = &model.user_edit {
|
||||
edit_or_create_user(l.clone(), t)
|
||||
} else {
|
||||
section!()
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// View the headlines of the table
|
||||
fn view_user_table_head<F: Fn(&str) -> String>(t: F) -> Node<Msg> {
|
||||
tr![
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::Query(UserQueryMsg::OrderBy(
|
||||
UserOverviewColumns::Id
|
||||
))),
|
||||
t("userid")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::Query(UserQueryMsg::OrderBy(
|
||||
UserOverviewColumns::Email
|
||||
))),
|
||||
t("email")
|
||||
],
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::Query(UserQueryMsg::OrderBy(
|
||||
UserOverviewColumns::Username
|
||||
))),
|
||||
t("username")
|
||||
],
|
||||
th![t("role")],
|
||||
]
|
||||
}
|
||||
|
||||
/// Display the filter-boxes below the headlines
|
||||
fn view_user_table_filter_input<F: Fn(&str) -> String>(model: &Model, t: F) -> Node<Msg> {
|
||||
tr![
|
||||
C!["filters"],
|
||||
td![input![
|
||||
attrs! {
|
||||
At::Value => &model.formconfig.filter[UserOverviewColumns::Id].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, |s| {
|
||||
Msg::Query(UserQueryMsg::IdFilterChanged(s))
|
||||
}),
|
||||
el_ref(&model.inputs[UserOverviewColumns::Id].filter_input),
|
||||
]],
|
||||
td![input![
|
||||
attrs! {At::Value =>
|
||||
&model
|
||||
.formconfig.filter[UserOverviewColumns::Email].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, |s| {
|
||||
Msg::Query(UserQueryMsg::EmailFilterChanged(s))
|
||||
}),
|
||||
el_ref(&model.inputs[UserOverviewColumns::Email].filter_input),
|
||||
]],
|
||||
td![input![
|
||||
attrs! {At::Value =>
|
||||
&model
|
||||
.formconfig.filter[UserOverviewColumns::Username].sieve,
|
||||
At::Type => "search",
|
||||
At::Placeholder => t("search-placeholder")
|
||||
},
|
||||
input_ev(Ev::Input, |s| {
|
||||
Msg::Query(UserQueryMsg::UsernameFilterChanged(s))
|
||||
}),
|
||||
el_ref(&model.inputs[UserOverviewColumns::Username].filter_input),
|
||||
]],
|
||||
td![],
|
||||
]
|
||||
}
|
||||
|
||||
/// Display one user-line of the table
|
||||
fn view_user<F: Fn(&str) -> String>(l: &User, logged_in_user: &User, t: F) -> Node<Msg> {
|
||||
let user = UserDelta::from(l.clone());
|
||||
tr![
|
||||
{
|
||||
let user = user.clone();
|
||||
ev(Ev::Click, |_| {
|
||||
Msg::Edit(UserEditMsg::EditUserSelected(user))
|
||||
})
|
||||
},
|
||||
match l.role {
|
||||
Role::NotAuthenticated | Role::Disabled => C!("inactive"),
|
||||
Role::Regular => C!("regular"),
|
||||
Role::Admin => C!("admin"),
|
||||
},
|
||||
td![&l.id],
|
||||
td![&l.email],
|
||||
td![&l.username],
|
||||
match logged_in_user.role {
|
||||
Role::Admin => {
|
||||
match l.role {
|
||||
Role::NotAuthenticated | Role::Disabled | Role::Regular => td![
|
||||
ev(Ev::Click, |event| {
|
||||
event.stop_propagation();
|
||||
Msg::Edit(UserEditMsg::MakeAdmin(user))
|
||||
}),
|
||||
t("make-user-admin")
|
||||
],
|
||||
Role::Admin => td![
|
||||
ev(Ev::Click, |event| {
|
||||
event.stop_propagation();
|
||||
Msg::Edit(UserEditMsg::MakeRegular(user))
|
||||
}),
|
||||
t("make-user-regular"),
|
||||
],
|
||||
}
|
||||
}
|
||||
Role::Regular => match l.role {
|
||||
Role::NotAuthenticated | Role::Disabled | Role::Regular => td![t("user")],
|
||||
Role::Admin => td![t("admin")],
|
||||
},
|
||||
Role::NotAuthenticated | Role::Disabled => td![],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/// display the edit and create dialog
|
||||
fn edit_or_create_user<F: Fn(&str) -> String>(l: UserDelta, t: F) -> Node<Msg> {
|
||||
let user = l;
|
||||
let headline: Node<Msg> = match &user.role {
|
||||
Role::NotAuthenticated | Role::Disabled | Role::Regular => {
|
||||
h1![match &user.edit {
|
||||
EditMode::Edit => t("edit-user"),
|
||||
EditMode::Create => t("new-user"),
|
||||
}]
|
||||
}
|
||||
Role::Admin => {
|
||||
h1![match &user.edit {
|
||||
EditMode::Edit => t("edit-admin"),
|
||||
EditMode::Create => t("new-admin"),
|
||||
}]
|
||||
}
|
||||
};
|
||||
div![
|
||||
C!["editdialog", "center"],
|
||||
close_button(),
|
||||
headline,
|
||||
table![
|
||||
tr![
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::Query(UserQueryMsg::OrderBy(
|
||||
UserOverviewColumns::Username
|
||||
))),
|
||||
t("username")
|
||||
],
|
||||
td![input![
|
||||
attrs! {
|
||||
At::Value => &user.username,
|
||||
At::Type => "text",
|
||||
At::Placeholder => t("username")
|
||||
},
|
||||
input_ev(Ev::Input, |s| {
|
||||
Msg::Edit(UserEditMsg::EditUsernameChanged(s))
|
||||
}),
|
||||
]]
|
||||
],
|
||||
tr![
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::Query(UserQueryMsg::OrderBy(
|
||||
UserOverviewColumns::Email
|
||||
))),
|
||||
t("email")
|
||||
],
|
||||
td![input![
|
||||
attrs! {
|
||||
At::Value => &user.email,
|
||||
At::Type => "email",
|
||||
At::Placeholder => t("email")
|
||||
},
|
||||
input_ev(Ev::Input, |s| {
|
||||
Msg::Edit(UserEditMsg::EditEmailChanged(s))
|
||||
}),
|
||||
]]
|
||||
],
|
||||
tr![
|
||||
th![
|
||||
ev(Ev::Click, |_| Msg::Query(UserQueryMsg::OrderBy(
|
||||
UserOverviewColumns::Email
|
||||
))),
|
||||
t("password")
|
||||
],
|
||||
td![
|
||||
input![
|
||||
attrs! {
|
||||
At::Type => "password",
|
||||
At::Placeholder => t("password")
|
||||
},
|
||||
input_ev(Ev::Input, |s| {
|
||||
Msg::Edit(UserEditMsg::EditPasswordChanged(s))
|
||||
}),
|
||||
],
|
||||
IF!(user.edit == EditMode::Edit => p![t("leave-password-empty-hint")])
|
||||
]
|
||||
]
|
||||
],
|
||||
a![
|
||||
match &user.edit {
|
||||
EditMode::Edit => t("edit-user"),
|
||||
EditMode::Create => t("create-user"),
|
||||
},
|
||||
C!["button"],
|
||||
ev(Ev::Click, |_| Msg::Edit(UserEditMsg::SaveUser))
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
/// a close button for dialogs
|
||||
fn close_button() -> Node<Msg> {
|
||||
div![
|
||||
C!["closebutton"],
|
||||
a!["\u{d7}"],
|
||||
ev(Ev::Click, |_| Msg::ClearAll)
|
||||
]
|
||||
}
|
28
app/src/pages/mod.rs
Normal file
28
app/src/pages/mod.rs
Normal file
@ -0,0 +1,28 @@
|
||||
//! Containing the individual pages for the admin app so far one to list the links and one to list the users.
|
||||
pub mod list_links;
|
||||
pub mod list_users;
|
||||
|
||||
/// Unwrap a result and return it's content, or return from the function with another expression.
|
||||
#[macro_export]
|
||||
macro_rules! unwrap_or_return {
|
||||
( $e:expr, $result:expr) => {
|
||||
match $e {
|
||||
Ok(x) => x,
|
||||
Err(_) => return $result,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Unwrap a result and return it's content, or return from the function with another expression.
|
||||
#[macro_export]
|
||||
macro_rules! unwrap_or_send {
|
||||
( $e:expr, $result:expr, $orders:expr) => {
|
||||
match $e {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
$orders.send_msg($result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
1
doc/img/README.md
Normal file
1
doc/img/README.md
Normal file
@ -0,0 +1 @@
|
||||
Files that are used in documentation throughout the workspace.
|
Binary file not shown.
Before Width: | Height: | Size: 35 KiB |
BIN
doc/img/screenshot.png
Normal file
BIN
doc/img/screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 82 KiB |
BIN
doc/img/screenshot_edit.png
Normal file
BIN
doc/img/screenshot_edit.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 85 KiB |
21
locales/Cargo.toml
Normal file
21
locales/Cargo.toml
Normal file
@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "pslink-locales"
|
||||
authors = ["Dietrich <dietrich@teilgedanken.de>"]
|
||||
categories = ["web-programming", "network-programming", "web-programming::http-server", "command-line-utilities"]
|
||||
description = "The translation strings for pslink"
|
||||
edition = "2018"
|
||||
keywords = ["url", "link", "webpage", "actix", "web"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/enaut/pslink/"
|
||||
version = "0.4.3"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
|
||||
[dependencies]
|
||||
fluent = "0.15"
|
||||
serde = {version="1.0", features = ["derive"]}
|
||||
unic-langid = "0.9"
|
||||
|
||||
pslink-shared = { version="0.4", path = "../shared" }
|
8
locales/README.md
Normal file
8
locales/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Translations
|
||||
|
||||
In this directory all the translations are stored. They are formatted in the [fluent language](https://projectfluent.org/)
|
||||
|
||||
To add a new language several places need updating. A probably incomplete list:
|
||||
|
||||
* copying and renaming one of the directories here
|
||||
* update `app/navigation.rs` and `app/i18n.rs`
|
@ -3,18 +3,27 @@ add-link = Link hinzufügen
|
||||
invite-user = Benutzer einladen
|
||||
list-users = Liste der Benutzer
|
||||
welcome-user = Herzlich willkommen {$username}
|
||||
welcome = Herzlich willkommen
|
||||
logout = Abmelden
|
||||
login = Login
|
||||
yes = Ja
|
||||
no = Nein
|
||||
language = Sprache:
|
||||
|
||||
not-found = Dieser Link existiert nicht, oder wurde gelöscht.
|
||||
|
||||
load-more-links = Lade mehr Links
|
||||
no-more-links = Es gibt keine weiteren Links
|
||||
edit-link-headline = Zu editierender Link: {$linktitle}
|
||||
edit-link = Link Editieren
|
||||
create-link = Link Erstellen
|
||||
link-description = Beschreibung
|
||||
link-target = Link Ziel
|
||||
link-code = Link Code
|
||||
shortlink = Shortlink
|
||||
qr-code = QR-code
|
||||
search-placeholder = Filtern nach...
|
||||
really-delete = Wollen Sie {$code} wirklich löschen?
|
||||
|
||||
danger-zone = Achtung!
|
||||
danger-zone-text = Verändern Sie den Code von bereits veröffentlichten Links nicht. Sollte es dennoch geschehen werden veröffentlichte links unbenutzbar. Wird das Linkziel verändert, so zeigen auch die bereits veröffentlichten Links auf das neue Ziel.
|
||||
@ -22,15 +31,22 @@ danger-zone-text = Verändern Sie den Code von bereits veröffentlichten Links n
|
||||
save-edits = Speichere die Veränderungen
|
||||
delete-link = Diesen Link löschen
|
||||
|
||||
user = Benutzer
|
||||
admin = Administrator
|
||||
edit-user-headline = Benutzereinstellungen von: {$username}
|
||||
username = Benutzername
|
||||
email = Email
|
||||
password = Passwort
|
||||
password-placeholder = Leer lassen um das Passwort nicht zu ändern
|
||||
leave-password-empty-hint = Leer lassen um das Passwort nicht zu ändern
|
||||
save-user = Benutzer speichern
|
||||
edit-user = Benutzer editieren
|
||||
edit-admin = Administrator editieren
|
||||
create-user = Benutzer Erstellen
|
||||
new-user = Neuer Benutzer
|
||||
new-admin = Neuer Administrator
|
||||
make-user-admin = Zum Administrator befördern
|
||||
make-user-regular = Zurückstufen zum normalen Nutzer
|
||||
role = Rolle
|
||||
|
||||
userid = Benutzernummer
|
||||
statistics = Statistik
|
@ -3,17 +3,27 @@ add-link = Add a new link
|
||||
invite-user = Invite a new user
|
||||
list-users = List of existing users
|
||||
welcome-user = Welcome {$username}
|
||||
welcome = Welcome
|
||||
logout = Logout
|
||||
login = Login
|
||||
yes = Ja
|
||||
no = Nein
|
||||
language = Language:
|
||||
|
||||
not-found = This Link has not been found or has been deleted
|
||||
|
||||
load-more-links = load more links
|
||||
no-more-links = there are no additional links available
|
||||
edit-link-headline = Edit link: {$linktitle}
|
||||
edit-link = Edit link
|
||||
create-link = Create link
|
||||
link-description = Description
|
||||
link-target = Link target
|
||||
link-code = Link code
|
||||
qr-code = QR-code
|
||||
shortlink = Shortlink
|
||||
search-placeholder = Filter according to...
|
||||
really-delete = Do you really want to delete {$code}?
|
||||
|
||||
danger-zone = Danger Zone!
|
||||
danger-zone-text = Do not change the code of links that are published. If you do so the published links will become invalid! If you change the target the published links will point to the new target.
|
||||
@ -21,16 +31,23 @@ danger-zone-text = Do not change the code of links that are published. If you do
|
||||
save-edits = Save edits
|
||||
delete-link = Delete this link
|
||||
|
||||
user = Benutzer
|
||||
admin = Administrator
|
||||
user-headline = User Settings of: {$username}
|
||||
edit-user-headline = Change Settings of: {$username}
|
||||
username = Username
|
||||
email = Email
|
||||
password = Password
|
||||
password-placeholder = Leave this empty to keep the current password
|
||||
leave-password-empty-hint = Leave this empty to keep the current password
|
||||
save-user = Save this user
|
||||
edit-user = Edit this user
|
||||
edit-admin = Edit this administrator
|
||||
new-user = Neuer Benutzer
|
||||
create-user = Create user
|
||||
create-admin = Create administrator
|
||||
make-user-admin = Promote to admin
|
||||
make-user-regular = Demote to regular
|
||||
role = Role
|
||||
|
||||
userid = User ID
|
||||
statistics = Statistics
|
97
locales/src/lib.rs
Normal file
97
locales/src/lib.rs
Normal file
@ -0,0 +1,97 @@
|
||||
//! This modules contains the parts for making the app translatable.
|
||||
use std::sync::Arc;
|
||||
|
||||
use fluent::{FluentArgs, FluentBundle, FluentResource};
|
||||
use pslink_shared::datatypes::Lang;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
/// A struct containing the data, functions and the current language to query the localized strings.
|
||||
#[derive(Clone)]
|
||||
pub struct I18n {
|
||||
lang: Lang,
|
||||
ftl_bundle: Arc<FluentBundle<FluentResource>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for I18n {
|
||||
/// On debug print skip the bundle
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self.lang)
|
||||
}
|
||||
}
|
||||
|
||||
impl I18n {
|
||||
/// Create a new translator struct
|
||||
#[must_use]
|
||||
pub fn new(lang: Lang) -> Self {
|
||||
let ftl_bundle = Arc::new(Self::create_ftl_bundle(lang));
|
||||
Self { lang, ftl_bundle }
|
||||
}
|
||||
|
||||
/// Get the current language
|
||||
#[must_use]
|
||||
pub const fn lang(&self) -> &Lang {
|
||||
&self.lang
|
||||
}
|
||||
|
||||
/// Set the current language
|
||||
pub fn set_lang(&mut self, lang: Lang) {
|
||||
self.lang = lang;
|
||||
self.ftl_bundle = Arc::new(Self::create_ftl_bundle(lang));
|
||||
}
|
||||
|
||||
/// Get a localized string. Optionally with parameters provided in `args`.
|
||||
pub fn translate(&self, key: impl AsRef<str>, args: Option<&FluentArgs>) -> String {
|
||||
// log!(key.as_ref());
|
||||
let msg = self
|
||||
.ftl_bundle
|
||||
.get_message(key.as_ref())
|
||||
.expect("Failed to get fluent message for key {}");
|
||||
|
||||
let pattern = msg.value().expect("Failed to parse pattern");
|
||||
|
||||
self.ftl_bundle
|
||||
.format_pattern(pattern, args, &mut vec![])
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Prettyprint the language name
|
||||
#[must_use]
|
||||
pub const fn label(&self) -> &'static str {
|
||||
match self.lang {
|
||||
Lang::EnUS => "English (US)",
|
||||
Lang::DeDE => "Deutsch (Deutschland)",
|
||||
}
|
||||
}
|
||||
|
||||
/// include the fluent messages into the binary
|
||||
#[must_use]
|
||||
pub const fn ftl_messages(lang: Lang) -> &'static str {
|
||||
macro_rules! include_ftl_messages {
|
||||
( $lang_id:literal ) => {
|
||||
include_str!(concat!("../", $lang_id, "/main.ftl"))
|
||||
};
|
||||
}
|
||||
match lang {
|
||||
Lang::EnUS => include_ftl_messages!("en"),
|
||||
Lang::DeDE => include_ftl_messages!("de"),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn language_identifier(lang: Lang) -> LanguageIdentifier {
|
||||
lang.as_ref()
|
||||
.parse()
|
||||
.expect("parse Lang to LanguageIdentifier")
|
||||
}
|
||||
|
||||
/// Create and initialize a fluent bundle.
|
||||
#[must_use]
|
||||
pub fn create_ftl_bundle(lang: Lang) -> FluentBundle<FluentResource> {
|
||||
let ftl_resource = FluentResource::try_new(Self::ftl_messages(lang).to_owned())
|
||||
.expect("parse FTL messages");
|
||||
|
||||
let mut bundle = FluentBundle::new(vec![Self::language_identifier(lang)]);
|
||||
bundle.add_resource(ftl_resource).expect("add FTL resource");
|
||||
bundle
|
||||
}
|
||||
}
|
72
pslink/Cargo.toml
Normal file
72
pslink/Cargo.toml
Normal file
@ -0,0 +1,72 @@
|
||||
[package]
|
||||
authors = ["Dietrich <dietrich@teilgedanken.de>"]
|
||||
build = "build.rs"
|
||||
categories = ["web-programming", "network-programming", "web-programming::http-server", "command-line-utilities"]
|
||||
description = "A simple webservice that allows registered users to create short links including qr-codes.\nAnyone can visit the shortened links. This is an ideal setup for small busines or for publishing papers."
|
||||
edition = "2018"
|
||||
keywords = ["url", "link", "webpage", "actix", "web"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
name = "pslink"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/enaut/pslink/"
|
||||
version = "0.4.3"
|
||||
|
||||
[build-dependencies]
|
||||
actix-web-static-files = "3.0"
|
||||
|
||||
[dependencies]
|
||||
actix-identity = "0.3"
|
||||
actix-rt = "1.1"
|
||||
actix-web = "3"
|
||||
actix-web-static-files = "3"
|
||||
actix-files = "0.5"
|
||||
argonautica = "0.2"
|
||||
clap = "2.33"
|
||||
dotenv = "0.15.0"
|
||||
fluent-langneg = "0.13"
|
||||
image = "0.23"
|
||||
opentelemetry = "0.14"
|
||||
opentelemetry-jaeger = "0.12"
|
||||
qrcode = "0.12"
|
||||
rand = "0.8"
|
||||
rpassword = "5.0"
|
||||
serde = {version="1.0", features = ["derive"]}
|
||||
thiserror = "1.0"
|
||||
tracing-actix-web = "0.2.1"
|
||||
tracing-opentelemetry = "0.12"
|
||||
async-trait = "0.1"
|
||||
enum-map = {version="1", features = ["serde"]}
|
||||
|
||||
pslink-shared = {version="0.4", path = "../shared" }
|
||||
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.fluent-templates]
|
||||
version = "0.6"
|
||||
|
||||
[dependencies.sqlx]
|
||||
features = ["sqlite", "macros", "runtime-actix-rustls", "chrono", "migrate", "offline"]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.tracing]
|
||||
features = ["log"]
|
||||
version = "0.1"
|
||||
|
||||
[dependencies.tracing-subscriber]
|
||||
features = ["registry", "env-filter"]
|
||||
version = "0.2.17"
|
||||
|
||||
[dev-dependencies]
|
||||
actix-server = "1.0.4"
|
||||
tempdir = "0.3"
|
||||
test_bin = "0.3"
|
||||
tokio = "0.2.25"
|
||||
assert_cmd = "1.0.7"
|
||||
predicates = "2.0.0"
|
||||
|
||||
|
||||
[dev-dependencies.reqwest]
|
||||
features = ["cookies", "json"]
|
||||
version = "0.10.10"
|
18
pslink/README.md
Normal file
18
pslink/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# The pslink library and binary
|
||||
|
||||
DO NOT USE CARGO INSTALL TO INSTALL PSLINK AS THIS WILL NOT CREATE A WORKING BINARY INSTEAD DOWNLOAD THE BINARY FROM GITHUB OR COMPILE IT YOURSELF WITH CARGO MAKE!
|
||||
|
||||
This is the pslink server part. It provides a webserver to be run behind another webserver like apache or nginx. Everything needed to run is bundled in the pslink binary. So you can compile everything locally and the copy the single binary to your server and run it.
|
||||
|
||||
Library features:
|
||||
* models for writing and retriving information from the database
|
||||
|
||||
Server/Binary features:
|
||||
* creation and migration of the database
|
||||
* creating an admin user
|
||||
* creating a `.env` file with all available options
|
||||
* launch the server
|
||||
* serve the wasm-file
|
||||
* serve styling and js bindings
|
||||
* provide a REST-JSON-Api to get and modify entries in the database
|
||||
* authentication
|
@ -1,5 +1,5 @@
|
||||
use actix_web_static_files::resource_dir;
|
||||
|
||||
fn main() {
|
||||
resource_dir("./static").build().unwrap();
|
||||
resource_dir("./static/").build().unwrap();
|
||||
}
|
@ -10,54 +10,6 @@
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"04640e79c590ae8b845b6281a786ebe72060d02ee98eaec4839d1525bde3b0b5": {
|
||||
"query": "Select * from links where code = ? ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "target",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "author",
|
||||
"ordinal": 4,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Datetime"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"0d5cc1ab073e15c4306ef2bfc89aeefd6daa409766741c21f9eb0115b0f24eb1": {
|
||||
"query": "UPDATE users SET\n username = ?,\n email = ?,\n password = ?,\n role = ? where id = ?",
|
||||
"describe": {
|
||||
@ -116,8 +68,8 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"193ebdfd8bdb96da45f5054f83a6a5e23eaa311e3e5c4139095a3455f4764c64": {
|
||||
"query": "Select * from users",
|
||||
"2c544ae2e18b70271164f75ca06851ec971a7426222ef8ccab86e20226056408": {
|
||||
"query": "Select * from links where code = ? COLLATE NOCASE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@ -126,33 +78,33 @@
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"name": "title",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"name": "target",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password",
|
||||
"name": "code",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"name": "author",
|
||||
"ordinal": 4,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"name": "created_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
"type_info": "Datetime"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@ -174,6 +126,54 @@
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"5624dec3d87f37ae3f77fb265ace3075b8e46bdcc0af273e0f28d4e1a89b6e9e": {
|
||||
"query": "Select * from links where id = ? ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "target",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "author",
|
||||
"ordinal": 4,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Datetime"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"5919b8824209c31a76178f30b3d52f385931ee0f3aa17e65f8647ad15a3595d8": {
|
||||
"query": "Insert into links (\n title,\n target,\n code,\n author,\n created_at) VALUES (?,?,?,?,?)",
|
||||
"describe": {
|
@ -1,8 +1,9 @@
|
||||
use clap::{
|
||||
app_from_crate, crate_authors, crate_description, crate_name, crate_version, App, Arg,
|
||||
ArgMatches, SubCommand,
|
||||
app_from_crate, crate_authors, crate_description, crate_name, crate_version, App, AppSettings,
|
||||
Arg, ArgMatches, SubCommand,
|
||||
};
|
||||
use dotenv::dotenv;
|
||||
use pslink_shared::datatypes::{Secret, User};
|
||||
use sqlx::{migrate::Migrator, Pool, Sqlite};
|
||||
use std::{
|
||||
fs::File,
|
||||
@ -10,13 +11,17 @@ use std::{
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use pslink::{models::NewUser, models::User, ServerConfig, ServerError};
|
||||
use pslink::{
|
||||
models::{NewLink, NewUser, UserDbOperations},
|
||||
ServerConfig, ServerError,
|
||||
};
|
||||
|
||||
use tracing::{error, info, trace, warn};
|
||||
|
||||
static MIGRATOR: Migrator = sqlx::migrate!();
|
||||
|
||||
#[allow(clippy::clippy::too_many_lines)]
|
||||
/// generate the command line options available
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn generate_cli() -> App<'static, 'static> {
|
||||
app_from_crate!()
|
||||
.arg(
|
||||
@ -42,7 +47,7 @@ fn generate_cli() -> App<'static, 'static> {
|
||||
.short("u")
|
||||
.help("The host url or the page that will be part of the short urls.")
|
||||
.env("PSLINK_PUBLIC_URL")
|
||||
.default_value("localhost:8080")
|
||||
.default_value("127.0.0.1:8080")
|
||||
.global(true),
|
||||
)
|
||||
.arg(
|
||||
@ -58,7 +63,7 @@ fn generate_cli() -> App<'static, 'static> {
|
||||
Arg::with_name("brand_name")
|
||||
.long("brand-name")
|
||||
.short("b")
|
||||
.help("The Brandname that will apper in various places.")
|
||||
.help("The brand name that will appear in various places.")
|
||||
.env("PSLINK_BRAND_NAME")
|
||||
.default_value("Pslink")
|
||||
.global(true),
|
||||
@ -69,7 +74,7 @@ fn generate_cli() -> App<'static, 'static> {
|
||||
.short("i")
|
||||
.help("The host (ip) that will run the pslink service")
|
||||
.env("PSLINK_IP")
|
||||
.default_value("localhost")
|
||||
.default_value("127.0.0.1")
|
||||
.global(true),
|
||||
)
|
||||
.arg(
|
||||
@ -90,7 +95,7 @@ fn generate_cli() -> App<'static, 'static> {
|
||||
.long("secret")
|
||||
.help(concat!(
|
||||
"The secret that is used to encrypt the",
|
||||
" password database keep this as inacessable as possible.",
|
||||
" password database keep this as inaccessible as possible.",
|
||||
" As command line parameters are visible",
|
||||
" to all users",
|
||||
" it is not wise to use this as",
|
||||
@ -120,8 +125,15 @@ fn generate_cli() -> App<'static, 'static> {
|
||||
.about("Create an admin user.")
|
||||
.display_order(2),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("demo")
|
||||
.about("Create a database and demo user.")
|
||||
.display_order(3)
|
||||
.setting(AppSettings::Hidden),
|
||||
)
|
||||
}
|
||||
|
||||
/// parse the options to the [`ServerConfig`] struct
|
||||
async fn parse_args_to_config(config: ArgMatches<'_>) -> ServerConfig {
|
||||
let secret = config
|
||||
.value_of("secret")
|
||||
@ -136,7 +148,7 @@ async fn parse_args_to_config(config: ArgMatches<'_>) -> ServerConfig {
|
||||
warn!("If you change the secret all passwords will be invalid");
|
||||
warn!("Using an auto generated one for this run.");
|
||||
} else {
|
||||
warn!("The provided secret was too short. Using an autogenerated one.")
|
||||
warn!("The provided secret was too short. Using an auto generated one.");
|
||||
}
|
||||
|
||||
thread_rng()
|
||||
@ -147,7 +159,7 @@ async fn parse_args_to_config(config: ArgMatches<'_>) -> ServerConfig {
|
||||
} else {
|
||||
secret
|
||||
};
|
||||
let secret = pslink::Secret::new(secret);
|
||||
let secret = Secret::new(secret);
|
||||
let db = config
|
||||
.value_of("database")
|
||||
.expect(concat!(
|
||||
@ -200,12 +212,16 @@ async fn parse_args_to_config(config: ArgMatches<'_>) -> ServerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn setup() -> Result<Option<crate::ServerConfig>, ServerError> {
|
||||
/// Setup and launch the command
|
||||
///
|
||||
/// # Panics
|
||||
/// This function panics if preconditions like the availability of the database are not met.
|
||||
pub async fn setup() -> Result<Option<crate::ServerConfig>, ServerError> {
|
||||
// load the environment .env file if available.
|
||||
dotenv().ok();
|
||||
|
||||
// Print launch info
|
||||
info!("Launching Pslink a 'Private short link generator'");
|
||||
trace!("logging initialized");
|
||||
|
||||
let app = generate_cli();
|
||||
|
||||
@ -224,7 +240,8 @@ pub(crate) async fn setup() -> Result<Option<crate::ServerConfig>, ServerError>
|
||||
if !db.exists() {
|
||||
trace!("No database file found {}", db.display());
|
||||
if !(config.subcommand_matches("migrate-database").is_none()
|
||||
| config.subcommand_matches("generate-env").is_none())
|
||||
| config.subcommand_matches("generate-env").is_none()
|
||||
| config.subcommand_matches("demo").is_none())
|
||||
{
|
||||
let msg = format!(
|
||||
concat!(
|
||||
@ -258,12 +275,40 @@ pub(crate) async fn setup() -> Result<Option<crate::ServerConfig>, ServerError>
|
||||
};
|
||||
}
|
||||
if let Some(_create_config) = config.subcommand_matches("create-admin") {
|
||||
return match create_admin(&server_config).await {
|
||||
return match request_admin_credentials(&server_config).await {
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(_runserver_config) = config.subcommand_matches("demo") {
|
||||
let num_users = User::count_admins(&server_config).await;
|
||||
|
||||
match num_users {
|
||||
Err(_) => {
|
||||
generate_env_file(&server_config).expect("Failed to generate env file.");
|
||||
apply_migrations(&server_config)
|
||||
.await
|
||||
.expect("Failed to apply migrations.");
|
||||
let new_admin = NewUser::new(
|
||||
"demo".to_string(),
|
||||
"demo@teilgedanken.de".to_string(),
|
||||
"demo",
|
||||
&server_config.secret,
|
||||
)
|
||||
.expect("Failed to generate new user credentials.");
|
||||
create_admin(&new_admin, &server_config)
|
||||
.await
|
||||
.expect("Failed to create admin");
|
||||
add_example_links(&server_config).await;
|
||||
return Ok(Some(server_config));
|
||||
}
|
||||
_ => {
|
||||
return Err(ServerError::User("The database is not empty aborting because this could mean that creating a demo instance would lead in data loss.".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(_runserver_config) = config.subcommand_matches("runserver") {
|
||||
let num_users = User::count_admins(&server_config).await?;
|
||||
|
||||
@ -284,8 +329,54 @@ pub(crate) async fn setup() -> Result<Option<crate::ServerConfig>, ServerError>
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_example_links(server_config: &ServerConfig) {
|
||||
NewLink {
|
||||
title: "Pslink Repository".to_owned(),
|
||||
target: "https://github.com/enaut/pslink".to_owned(),
|
||||
code: "pslink".to_owned(),
|
||||
author: 1,
|
||||
created_at: chrono::Local::now().naive_utc(),
|
||||
}
|
||||
.insert(server_config)
|
||||
.await
|
||||
.expect("Failed to insert example 1");
|
||||
|
||||
NewLink {
|
||||
title: "Seed".to_owned(),
|
||||
target: "https://seed-rs.org/".to_owned(),
|
||||
code: "seed".to_owned(),
|
||||
author: 1,
|
||||
created_at: chrono::Local::now().naive_utc(),
|
||||
}
|
||||
.insert(server_config)
|
||||
.await
|
||||
.expect("Failed to insert example 1");
|
||||
|
||||
NewLink {
|
||||
title: "actix".to_owned(),
|
||||
target: "https://actix.rs/".to_owned(),
|
||||
code: "actix".to_owned(),
|
||||
author: 1,
|
||||
created_at: chrono::Local::now().naive_utc(),
|
||||
}
|
||||
.insert(server_config)
|
||||
.await
|
||||
.expect("Failed to insert example 1");
|
||||
|
||||
NewLink {
|
||||
title: "rust".to_owned(),
|
||||
target: "https://www.rust-lang.org/".to_owned(),
|
||||
code: "rust".to_owned(),
|
||||
author: 1,
|
||||
created_at: chrono::Local::now().naive_utc(),
|
||||
}
|
||||
.insert(server_config)
|
||||
.await
|
||||
.expect("Failed to insert example 1");
|
||||
}
|
||||
|
||||
/// Interactively create a new admin user.
|
||||
async fn create_admin(config: &ServerConfig) -> Result<(), ServerError> {
|
||||
async fn request_admin_credentials(config: &ServerConfig) -> Result<(), ServerError> {
|
||||
info!("Creating an admin user.");
|
||||
let sin = io::stdin();
|
||||
|
||||
@ -296,7 +387,7 @@ async fn create_admin(config: &ServerConfig) -> Result<(), ServerError> {
|
||||
io::stdout().flush().unwrap();
|
||||
let new_username = sin.lock().lines().next().unwrap().unwrap();
|
||||
|
||||
print!("Please enter the emailadress for {}: ", new_username);
|
||||
print!("Please enter the email address for {}: ", new_username);
|
||||
io::stdout().flush().unwrap();
|
||||
let new_email = sin.lock().lines().next().unwrap().unwrap();
|
||||
|
||||
@ -308,17 +399,27 @@ async fn create_admin(config: &ServerConfig) -> Result<(), ServerError> {
|
||||
&new_username, &new_email
|
||||
);
|
||||
|
||||
let new_admin = NewUser::new(new_username.clone(), new_email.clone(), &password, config)?;
|
||||
let new_admin = NewUser::new(
|
||||
new_username.clone(),
|
||||
new_email.clone(),
|
||||
&password,
|
||||
&config.secret,
|
||||
)?;
|
||||
|
||||
new_admin.insert_user(config).await?;
|
||||
let created_user = User::get_user_by_name(&new_username, config).await?;
|
||||
create_admin(&new_admin, config).await
|
||||
}
|
||||
|
||||
async fn create_admin(new_user: &NewUser, config: &ServerConfig) -> Result<(), ServerError> {
|
||||
new_user.insert_user(config).await?;
|
||||
let created_user = User::get_user_by_name(&new_user.username, config).await?;
|
||||
created_user.toggle_admin(config).await?;
|
||||
|
||||
info!("Admin user created: {}", new_username);
|
||||
info!("Admin user created: {}", &new_user.username);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply any pending migrations to the database. The migrations are embedded in the binary and don't need any additional files.
|
||||
async fn apply_migrations(config: &ServerConfig) -> Result<(), ServerError> {
|
||||
info!(
|
||||
"Creating a database file and running the migrations in the file {}:",
|
||||
@ -328,6 +429,7 @@ async fn apply_migrations(config: &ServerConfig) -> Result<(), ServerError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The command line parameters provided or if missing the default parameters can be converted and written to a .env file. That way the configuration is saved and automatically reused for subsequent launches.
|
||||
fn generate_env_file(server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
if std::path::Path::new(".env").exists() {
|
||||
return Err(ServerError::User(
|
||||
@ -346,7 +448,7 @@ fn generate_env_file(server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
|
||||
for line in &conf_file_content {
|
||||
file.write_all(line.as_bytes())
|
||||
.expect("failed to write .env file")
|
||||
.expect("failed to write .env file");
|
||||
}
|
||||
info!("Successfully created the env file!");
|
||||
|
180
pslink/src/bin/pslink/main.rs
Normal file
180
pslink/src/bin/pslink/main.rs
Normal file
@ -0,0 +1,180 @@
|
||||
extern crate sqlx;
|
||||
|
||||
mod cli;
|
||||
mod views;
|
||||
|
||||
use actix_files::Files;
|
||||
use actix_identity::{CookieIdentityPolicy, IdentityService};
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use pslink::ServerConfig;
|
||||
|
||||
use tracing::instrument;
|
||||
use tracing::{subscriber::set_global_default, Subscriber};
|
||||
use tracing_opentelemetry::OpenTelemetryLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry};
|
||||
|
||||
use tracing::{error, info};
|
||||
use tracing_actix_web::TracingLogger;
|
||||
|
||||
/// Compose multiple layers into a `tracing`'s subscriber.
|
||||
#[must_use]
|
||||
pub fn get_subscriber(name: &str, env_filter: &str) -> impl Subscriber + Send + Sync {
|
||||
let env_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter));
|
||||
// Create a jaeger exporter pipeline for a `trace_demo` service.
|
||||
let tracer = opentelemetry_jaeger::new_pipeline()
|
||||
.with_service_name(name)
|
||||
.install_simple()
|
||||
.expect("Error initializing Jaeger exporter");
|
||||
let formatting_layer = tracing_subscriber::fmt::layer().with_target(false);
|
||||
|
||||
// Create a layer with the configured tracer
|
||||
let otel_layer = OpenTelemetryLayer::new(tracer);
|
||||
|
||||
// Use the tracing subscriber `Registry`, or any other subscriber
|
||||
// that impls `LookupSpan`
|
||||
Registry::default()
|
||||
.with(otel_layer)
|
||||
.with(env_filter)
|
||||
.with(formatting_layer)
|
||||
}
|
||||
|
||||
/// Register a subscriber as global default to process span data.
|
||||
///
|
||||
/// It should only be called once!
|
||||
pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
|
||||
set_global_default(subscriber).expect("Failed to set subscriber");
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::result::Result<(), std::io::Error> {
|
||||
let subscriber = get_subscriber("pslink", "info");
|
||||
init_subscriber(subscriber);
|
||||
|
||||
match cli::setup().await {
|
||||
Ok(Some(server_config)) => {
|
||||
webservice(server_config)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
println!("{:?}", e);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
std::process::exit(0);
|
||||
})
|
||||
.expect("Failed to launch the service")
|
||||
.await
|
||||
}
|
||||
Ok(None) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("\nError: {}", e);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// include the static files into the binary
|
||||
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
|
||||
|
||||
/// Launch the pslink-web-service
|
||||
///
|
||||
/// # Errors
|
||||
/// This produces a [`ServerError`] if:
|
||||
/// * The server failed to bind to the designated port.
|
||||
#[allow(
|
||||
clippy::future_not_send,
|
||||
clippy::too_many_lines,
|
||||
unknown_lints,
|
||||
clippy::unused_async
|
||||
)]
|
||||
pub async fn webservice(
|
||||
server_config: ServerConfig,
|
||||
) -> Result<actix_web::dev::Server, std::io::Error> {
|
||||
let host_port = format!("{}:{}", &server_config.internal_ip, &server_config.port);
|
||||
info!(
|
||||
"Running on: {}://{}/app/",
|
||||
&server_config.protocol, host_port
|
||||
);
|
||||
info!(
|
||||
"If the public url is set up correctly it should be accessible via: {}://{}/app/",
|
||||
&server_config.protocol, &server_config.public_url
|
||||
);
|
||||
|
||||
let server = HttpServer::new(move || {
|
||||
let generated = generate();
|
||||
App::new()
|
||||
.data(server_config.clone())
|
||||
.wrap(TracingLogger)
|
||||
.wrap(IdentityService::new(
|
||||
CookieIdentityPolicy::new(&[0; 32])
|
||||
.name("auth-cookie")
|
||||
.secure(true),
|
||||
))
|
||||
.service(actix_web_static_files::ResourceFiles::new(
|
||||
"/static", generated,
|
||||
))
|
||||
// directly go to the main page set the target with the environment variable.
|
||||
.route("/", web::get().to(views::redirect_empty))
|
||||
// admin block
|
||||
.service(
|
||||
web::scope("/admin")
|
||||
.route("/logout/", web::to(views::logout))
|
||||
.service(
|
||||
web::scope("/download")
|
||||
.route("/png/{redirect_id}", web::get().to(views::download_png)),
|
||||
)
|
||||
.service(
|
||||
web::scope("/json")
|
||||
.route("/list_links/", web::post().to(views::index_json))
|
||||
.route("/get_language/", web::get().to(views::get_language))
|
||||
.route("/change_language/", web::post().to(views::set_language))
|
||||
.route(
|
||||
"/create_link/",
|
||||
web::post().to(views::process_create_link_json),
|
||||
)
|
||||
.route(
|
||||
"/edit_link/",
|
||||
web::post().to(views::process_update_link_json),
|
||||
)
|
||||
.route(
|
||||
"/delete_link/",
|
||||
web::post().to(views::process_delete_link_json),
|
||||
)
|
||||
.route("/list_users/", web::post().to(views::index_users_json))
|
||||
.route(
|
||||
"/create_user/",
|
||||
web::post().to(views::process_create_user_json),
|
||||
)
|
||||
.route(
|
||||
"/update_user/",
|
||||
web::post().to(views::process_update_user_json),
|
||||
)
|
||||
.route("/update_privileges/", web::post().to(views::toggle_admin))
|
||||
.route(
|
||||
"/get_logged_user/",
|
||||
web::post().to(views::get_logged_user_json),
|
||||
)
|
||||
.route("/login_user/", web::post().to(views::process_login_json)),
|
||||
)
|
||||
.default_service(web::to(views::to_admin)),
|
||||
)
|
||||
// Serve the Wasm App for the admin interface.
|
||||
.service(
|
||||
web::scope("/app")
|
||||
.service(Files::new("/pkg", "./app/pkg"))
|
||||
.default_service(web::get().to(views::wasm_app)),
|
||||
)
|
||||
// redirect to the url hidden behind the code
|
||||
.route("/{redirect_id}", web::get().to(views::redirect))
|
||||
})
|
||||
.bind(host_port)
|
||||
.map_err(|e| {
|
||||
error!("Failed to bind to port!");
|
||||
e
|
||||
})?
|
||||
.run();
|
||||
Ok(server)
|
||||
}
|
408
pslink/src/bin/pslink/views.rs
Normal file
408
pslink/src/bin/pslink/views.rs
Normal file
@ -0,0 +1,408 @@
|
||||
use std::time::SystemTime;
|
||||
|
||||
use actix_identity::Identity;
|
||||
use actix_web::{
|
||||
http::header::{CacheControl, CacheDirective, ContentType, Expires},
|
||||
web, HttpRequest, HttpResponse,
|
||||
};
|
||||
use argonautica::Verifier;
|
||||
use fluent_langneg::{
|
||||
convert_vec_str_to_langids_lossy, negotiate_languages, parse_accepted_languages,
|
||||
NegotiationStrategy,
|
||||
};
|
||||
use fluent_templates::LanguageIdentifier;
|
||||
use image::{DynamicImage, ImageOutputFormat, Luma};
|
||||
use pslink::queries::{authenticate, RoleGuard};
|
||||
use pslink_shared::{
|
||||
apirequests::{
|
||||
general::{Message, Status},
|
||||
links::{LinkDelta, LinkRequestForm},
|
||||
users::{LoginUser, UserDelta, UserRequestForm},
|
||||
},
|
||||
datatypes::Lang,
|
||||
};
|
||||
use qrcode::QrCode;
|
||||
use tracing::{error, info, instrument, warn};
|
||||
|
||||
use pslink::queries;
|
||||
use pslink::ServerError;
|
||||
|
||||
#[instrument]
|
||||
fn redirect_builder(target: &str) -> HttpResponse {
|
||||
HttpResponse::SeeOther()
|
||||
.set(CacheControl(vec![
|
||||
CacheDirective::NoCache,
|
||||
CacheDirective::NoStore,
|
||||
CacheDirective::MustRevalidate,
|
||||
]))
|
||||
.set(Expires(SystemTime::now().into()))
|
||||
.set_header(actix_web::http::header::LOCATION, target)
|
||||
.body(format!("Redirect to {}", target))
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
fn detect_language(request: &HttpRequest) -> Result<Lang, ServerError> {
|
||||
let requested = parse_accepted_languages(
|
||||
request
|
||||
.headers()
|
||||
.get(actix_web::http::header::ACCEPT_LANGUAGE)
|
||||
.ok_or_else(|| ServerError::User("Failed to get Accept_Language".to_owned()))?
|
||||
.to_str()
|
||||
.map_err(|_| {
|
||||
ServerError::User("Failed to convert Accept_language to str".to_owned())
|
||||
})?,
|
||||
);
|
||||
info!("accepted languages: {:?}", requested);
|
||||
let available = convert_vec_str_to_langids_lossy(&["de", "en"]);
|
||||
info!("available languages: {:?}", available);
|
||||
let default: LanguageIdentifier = "en"
|
||||
.parse()
|
||||
.map_err(|_| ServerError::User("Failed to parse a langid.".to_owned()))?;
|
||||
|
||||
let supported = negotiate_languages(
|
||||
&requested,
|
||||
&available,
|
||||
Some(&default),
|
||||
NegotiationStrategy::Filtering,
|
||||
);
|
||||
info!("supported languages: {:?}", supported);
|
||||
|
||||
if let Some(language_code) = supported.get(0) {
|
||||
info!("Supported Language: {}", language_code);
|
||||
Ok(language_code
|
||||
.to_string()
|
||||
.parse()
|
||||
.expect("Failed to parse 2 language"))
|
||||
} else {
|
||||
info!("Unsupported language using default!");
|
||||
Ok("enEN".parse::<Lang>().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument()]
|
||||
pub async fn wasm_app(config: web::Data<crate::ServerConfig>) -> Result<HttpResponse, ServerError> {
|
||||
Ok(HttpResponse::Ok().body(
|
||||
r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<meta name="author" content="Franz Dietrich">
|
||||
<meta http-equiv="robots" content="[noindex|nofollow]">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<title>Pslink your urls</title>
|
||||
</head>
|
||||
<body>
|
||||
<section id="app"><div class="lds-ellipsis">Loading: <div></div><div></div><div></div><div></div></div></section>
|
||||
<script type="module">
|
||||
import init from '/static/wasm/app.js';
|
||||
init('/static/wasm/app_bg.wasm');
|
||||
</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
))
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn index_json(
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
form: web::Json<LinkRequestForm>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
info!("Listing Links to Json api");
|
||||
match queries::list_all_allowed(&id, &config, form.0).await {
|
||||
Ok(links) => Ok(HttpResponse::Ok().json2(&links.list)),
|
||||
Err(e) => {
|
||||
error!("Failed to access database: {:?}", e);
|
||||
warn!("Not logged in - redirecting to login page");
|
||||
Ok(HttpResponse::Unauthorized().body("Failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn index_users_json(
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
form: web::Json<UserRequestForm>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
info!("Listing Users to Json api");
|
||||
if let Ok(users) = queries::list_users(&id, &config, form.0).await {
|
||||
Ok(HttpResponse::Ok().json2(&users.list))
|
||||
} else {
|
||||
Ok(redirect_builder("/admin/login"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_logged_user_json(
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
let user = authenticate(&id, &config).await?;
|
||||
match user {
|
||||
RoleGuard::NotAuthenticated | RoleGuard::Disabled => {
|
||||
Ok(HttpResponse::Unauthorized().finish())
|
||||
}
|
||||
RoleGuard::Regular { user } | RoleGuard::Admin { user } => {
|
||||
Ok(HttpResponse::Ok().json2(&user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn download_png(
|
||||
id: Identity,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
link_code: web::Path<String>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
match queries::get_link(&id, &link_code.0, &config).await {
|
||||
Ok(query) => {
|
||||
let qr = QrCode::with_error_correction_level(
|
||||
&format!("http://{}/{}", config.public_url, &query.item.code),
|
||||
qrcode::EcLevel::L,
|
||||
)
|
||||
.unwrap();
|
||||
let png = qr.render::<Luma<u8>>().quiet_zone(false).build();
|
||||
let mut temporary_data = std::io::Cursor::new(Vec::new());
|
||||
DynamicImage::ImageLuma8(png)
|
||||
.write_to(&mut temporary_data, ImageOutputFormat::Png)
|
||||
.unwrap();
|
||||
let image_data = temporary_data.into_inner();
|
||||
Ok(HttpResponse::Ok().set(ContentType::png()).body(image_data))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn process_create_user_json(
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
data: web::Json<UserDelta>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
info!("Listing Users to Json api");
|
||||
match queries::create_user(&id, data.into_inner(), &config).await {
|
||||
Ok(item) => Ok(HttpResponse::Ok().json2(&Status::Success(Message {
|
||||
message: format!("Successfully saved user: {}", item.item.username),
|
||||
}))),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn process_update_user_json(
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
form: web::Json<UserDelta>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
info!("Listing Users to Json api");
|
||||
match queries::update_user(&id, &form, &config).await {
|
||||
Ok(item) => Ok(HttpResponse::Ok().json2(&Status::Success(Message {
|
||||
message: format!("Successfully saved user: {}", item.item.username),
|
||||
}))),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn toggle_admin(
|
||||
user: web::Json<UserDelta>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
let update = queries::toggle_admin(&id, user.id, &config).await?;
|
||||
Ok(HttpResponse::Ok().json2(&Status::Success(Message {
|
||||
message: format!(
|
||||
"Successfully changed privileges or user: {}",
|
||||
update.item.username
|
||||
),
|
||||
})))
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn get_language(
|
||||
id: Option<Identity>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
if let Some(id) = id {
|
||||
let user = authenticate(&id, &config).await?;
|
||||
match user {
|
||||
RoleGuard::NotAuthenticated | RoleGuard::Disabled => {
|
||||
Ok(HttpResponse::Ok().json2(&detect_language(&req)?))
|
||||
}
|
||||
RoleGuard::Regular { user } | RoleGuard::Admin { user } => {
|
||||
Ok(HttpResponse::Ok().json2(&user.language))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(HttpResponse::Ok().json2(&detect_language(&req)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn set_language(
|
||||
data: web::Json<Lang>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
queries::set_language(&id, data.0, &config).await?;
|
||||
Ok(HttpResponse::Ok().json2(&data.0))
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn process_login_json(
|
||||
data: web::Json<LoginUser>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
// query the username to see if a user by that name exists.
|
||||
let user = queries::get_user_by_name(&data.username, &config).await;
|
||||
|
||||
match user {
|
||||
Ok(u) => {
|
||||
// get the password hash
|
||||
if let Some(hash) = &u.password.secret {
|
||||
// get the servers secret
|
||||
let secret = &config.secret;
|
||||
// validate the secret
|
||||
let valid = Verifier::default()
|
||||
.with_hash(hash)
|
||||
.with_password(&data.password)
|
||||
.with_secret_key(secret.secret.as_ref().expect("No secret available"))
|
||||
.verify()?;
|
||||
|
||||
// login the user
|
||||
if valid {
|
||||
info!("Log-in of user: {}", &u.username);
|
||||
let session_token = u.username.clone();
|
||||
id.remember(session_token);
|
||||
Ok(HttpResponse::Ok().json2(&u))
|
||||
} else {
|
||||
info!("Invalid password for user: {}", &u.username);
|
||||
Ok(HttpResponse::Unauthorized().json2(&Status::Error(Message {
|
||||
message: "Failed to Login".to_string(),
|
||||
})))
|
||||
}
|
||||
} else {
|
||||
// should fail earlier if secret is missing.
|
||||
Ok(HttpResponse::Unauthorized().json2(&Status::Error(Message {
|
||||
message: "Failed to Login".to_string(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Failed to login: {}", e);
|
||||
Ok(HttpResponse::Unauthorized().json2(&Status::Error(Message {
|
||||
message: "Failed to Login".to_string(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn logout(id: Identity) -> Result<HttpResponse, ServerError> {
|
||||
info!("Logging out the user");
|
||||
id.forget();
|
||||
Ok(redirect_builder("/app/"))
|
||||
}
|
||||
|
||||
#[instrument()]
|
||||
pub async fn to_admin() -> Result<HttpResponse, ServerError> {
|
||||
let response = HttpResponse::PermanentRedirect()
|
||||
.set_header(actix_web::http::header::LOCATION, "/app/")
|
||||
.body(r#"The admin interface moved to <a href="/app/">/app/</a>"#);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[instrument()]
|
||||
pub async fn redirect(
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
data: web::Path<String>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
info!("Redirecting to {:?}", data);
|
||||
let link = queries::get_link_simple(&data.0, &config).await;
|
||||
info!("link: {:?}", link);
|
||||
match link {
|
||||
Ok(link) => {
|
||||
queries::click_link(link.id, &config).await?;
|
||||
Ok(redirect_builder(&link.target))
|
||||
}
|
||||
Err(ServerError::Database(e)) => {
|
||||
info!(
|
||||
"Link was not found: http://{}/{} \n {}",
|
||||
&config.public_url, &data.0, e
|
||||
);
|
||||
Ok(HttpResponse::NotFound().body(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{title}}</title>
|
||||
<meta name="author" content="Franz Dietrich">
|
||||
<meta http-equiv="robots" content="[noindex|nofollow]">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
This link was either deleted or does not exist.
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
pub async fn redirect_empty(
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
Ok(redirect_builder(&config.empty_forward_url))
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn process_create_link_json(
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
data: web::Json<LinkDelta>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
let new_link = queries::create_link(&id, data.into_inner(), &config).await;
|
||||
match new_link {
|
||||
Ok(item) => Ok(HttpResponse::Ok().json2(&Status::Success(Message {
|
||||
message: format!("Successfully saved link: {}", item.item.code),
|
||||
}))),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn process_update_link_json(
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
data: web::Json<LinkDelta>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
let new_link = queries::update_link(&id, data.into_inner(), &config).await;
|
||||
match new_link {
|
||||
Ok(item) => Ok(HttpResponse::Ok().json2(&Status::Success(Message {
|
||||
message: format!("Successfully updated link: {}", item.item.code),
|
||||
}))),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn process_delete_link_json(
|
||||
id: Identity,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
data: web::Json<LinkDelta>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
queries::delete_link(&id, &data.code, &config).await?;
|
||||
Ok(HttpResponse::Ok().json2(&Status::Success(Message {
|
||||
message: format!("Successfully deleted link: {}", &data.code),
|
||||
})))
|
||||
}
|
184
pslink/src/lib.rs
Normal file
184
pslink/src/lib.rs
Normal file
@ -0,0 +1,184 @@
|
||||
pub mod models;
|
||||
pub mod queries;
|
||||
|
||||
use actix_web::HttpResponse;
|
||||
use pslink_shared::datatypes::Secret;
|
||||
use qrcode::types::QrError;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use std::{fmt::Display, path::PathBuf, str::FromStr};
|
||||
use thiserror::Error;
|
||||
use tracing::error;
|
||||
|
||||
/// The Error type that is returned by most function calls if anything failed.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ServerError {
|
||||
#[error("Failed to encrypt the password {0} - aborting!")]
|
||||
Argonautica(argonautica::Error),
|
||||
#[error("The database could not be used: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("The database could not be migrated: {0}")]
|
||||
DatabaseMigration(#[from] sqlx::migrate::MigrateError),
|
||||
#[error("The environment file could not be read")]
|
||||
Environment(#[from] std::env::VarError),
|
||||
#[error("The qr-code could not be generated: {0}")]
|
||||
Qr(#[from] QrError),
|
||||
#[error("Some error happened during input and output: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Error: {0}")]
|
||||
User(String),
|
||||
}
|
||||
|
||||
impl From<argonautica::Error> for ServerError {
|
||||
fn from(e: argonautica::Error) -> Self {
|
||||
Self::Argonautica(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Any error can be rendered to a html string.
|
||||
impl ServerError {
|
||||
fn render_error(title: &str, content: &str) -> String {
|
||||
format!(
|
||||
"<!DOCTYPE html>
|
||||
<html lang=\"en\">
|
||||
<head>
|
||||
<meta charset=\"utf-8\">
|
||||
<title>{0}</title>
|
||||
<meta name=\"author\" content=\"Franz Dietrich\">
|
||||
<meta http-equiv=\"robots\" content=\"[noindex|nofollow]\">
|
||||
<link rel=\"stylesheet\" href=\"/static/style.css\">
|
||||
</head>
|
||||
<body>
|
||||
<section class=\"centered\">
|
||||
<h1>{0}</h1>
|
||||
<div class=\"container\">
|
||||
{1}
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>",
|
||||
title, content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the error type work nicely with the actix server.
|
||||
impl actix_web::error::ResponseError for ServerError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
match self {
|
||||
Self::Argonautica(e) => {
|
||||
eprintln!("Argonautica Error happened: {:?}", e);
|
||||
HttpResponse::InternalServerError()
|
||||
.body("Failed to encrypt the password - Aborting!")
|
||||
}
|
||||
Self::Database(e) => {
|
||||
eprintln!("Database Error happened: {:?}", e);
|
||||
HttpResponse::InternalServerError().body(&Self::render_error(
|
||||
"Server Error",
|
||||
"Database could not be accessed! - It could be that this value already was in the database! If you are the admin look into the logs for a more detailed error.",
|
||||
))
|
||||
}
|
||||
Self::DatabaseMigration(e) => {
|
||||
eprintln!("Migration Error happened: {:?}", e);
|
||||
unimplemented!("A migration error should never be rendered")
|
||||
}
|
||||
Self::Environment(e) => {
|
||||
eprintln!("Environment Error happened: {:?}", e);
|
||||
HttpResponse::InternalServerError().body(&Self::render_error(
|
||||
"Server Error",
|
||||
"This Server is not properly configured, if you are the admin look into the installation- or update instructions!",
|
||||
))
|
||||
}
|
||||
Self::Qr(e) => {
|
||||
eprintln!("QR Error happened: {:?}", e);
|
||||
HttpResponse::InternalServerError().body(&Self::render_error(
|
||||
"Server Error",
|
||||
"Could not generate the QR-code!",
|
||||
))
|
||||
}
|
||||
Self::Io(e) => {
|
||||
eprintln!("Io Error happened: {:?}", e);
|
||||
HttpResponse::InternalServerError().body(&Self::render_error(
|
||||
"Server Error",
|
||||
"Some Files could not be read or written. If you are the admin look into the logfiles for more details.",
|
||||
))
|
||||
}
|
||||
Self::User(data) => {
|
||||
eprintln!("User Error happened: {:?}", data);
|
||||
HttpResponse::InternalServerError().body(&Self::render_error(
|
||||
"Server Error",
|
||||
&format!("An error happened: {}", data),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The qr-code can contain two different protocolls
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Protocol {
|
||||
Http,
|
||||
Https,
|
||||
}
|
||||
|
||||
impl Display for Protocol {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Http => f.write_str("http"),
|
||||
Self::Https => f.write_str("https"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Protocol {
|
||||
type Err = ServerError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"http" => Ok(Self::Http),
|
||||
"https" => Ok(Self::Https),
|
||||
_ => Err(ServerError::User("Failed to parse Protocol".to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The configuration of the server. It is accessible by the views and other parts of the program. Globally valid settings should be stored here.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub secret: Secret,
|
||||
pub db: PathBuf,
|
||||
pub db_pool: Pool<Sqlite>,
|
||||
pub public_url: String,
|
||||
pub internal_ip: String,
|
||||
pub port: u32,
|
||||
pub protocol: Protocol,
|
||||
pub empty_forward_url: String,
|
||||
pub brand_name: String,
|
||||
}
|
||||
|
||||
/// The configuration can be serialized into an environment-file.
|
||||
impl ServerConfig {
|
||||
#[must_use]
|
||||
pub fn to_env_strings(&self) -> Vec<String> {
|
||||
vec![
|
||||
format!("PSLINK_DATABASE=\"{}\"\n", self.db.display()),
|
||||
format!("PSLINK_PORT={}\n", self.port),
|
||||
format!("PSLINK_PUBLIC_URL=\"{}\"\n", self.public_url),
|
||||
format!("PSLINK_EMPTY_FORWARD_URL=\"{}\"\n", self.empty_forward_url),
|
||||
format!("PSLINK_BRAND_NAME=\"{}\"\n", self.brand_name),
|
||||
format!("PSLINK_IP=\"{}\"\n", self.internal_ip),
|
||||
format!("PSLINK_PROTOCOL=\"{}\"\n", self.protocol),
|
||||
concat!(
|
||||
"# The SECRET_KEY variable is used for password encryption.\n",
|
||||
"# If it is changed all existing passwords are invalid.\n"
|
||||
)
|
||||
.to_owned(),
|
||||
format!(
|
||||
"PSLINK_SECRET=\"{}\"\n",
|
||||
self.secret
|
||||
.secret
|
||||
.as_ref()
|
||||
.expect("A Secret was not specified!")
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
422
pslink/src/models.rs
Normal file
422
pslink/src/models.rs
Normal file
@ -0,0 +1,422 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{Secret, ServerConfig, ServerError};
|
||||
|
||||
use argonautica::Hasher;
|
||||
use async_trait::async_trait;
|
||||
use dotenv::dotenv;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use pslink_shared::{
|
||||
apirequests::{links::LinkDelta, users::Role},
|
||||
datatypes::{Count, Lang, Link, User},
|
||||
};
|
||||
use sqlx::Row;
|
||||
use tracing::{error, info, instrument};
|
||||
|
||||
/// The operations a User should support.
|
||||
#[async_trait]
|
||||
pub trait UserDbOperations<T> {
|
||||
async fn get_user(id: i64, server_config: &ServerConfig) -> Result<T, ServerError>;
|
||||
async fn get_user_by_name(name: &str, server_config: &ServerConfig) -> Result<T, ServerError>;
|
||||
async fn get_all_users(server_config: &ServerConfig) -> Result<Vec<T>, ServerError>;
|
||||
async fn update_user(&self, server_config: &ServerConfig) -> Result<(), ServerError>;
|
||||
async fn toggle_admin(self, server_config: &ServerConfig) -> Result<(), ServerError>;
|
||||
async fn set_language(
|
||||
self,
|
||||
server_config: &ServerConfig,
|
||||
new_language: Lang,
|
||||
) -> Result<(), ServerError>;
|
||||
async fn count_admins(server_config: &ServerConfig) -> Result<Count, ServerError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserDbOperations<Self> for User {
|
||||
/// get a user by its id
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the user does not exist or the database cannot be acessed.
|
||||
#[instrument()]
|
||||
async fn get_user(id: i64, server_config: &ServerConfig) -> Result<Self, ServerError> {
|
||||
let user = sqlx::query!("Select * from users where id = ? ", id)
|
||||
.fetch_one(&server_config.db_pool)
|
||||
.await
|
||||
.map(|row| Self {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
password: Secret::new(row.password),
|
||||
role: Role::convert(row.role),
|
||||
language: Lang::from_str(&row.language).expect("Should parse"),
|
||||
});
|
||||
user.map_err(ServerError::Database)
|
||||
}
|
||||
|
||||
/// get a user by its username
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the user does not exist or the database cannot be acessed.
|
||||
#[instrument()]
|
||||
async fn get_user_by_name(
|
||||
name: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Self, ServerError> {
|
||||
let user = sqlx::query!("Select * from users where username = ? ", name)
|
||||
.fetch_one(&server_config.db_pool)
|
||||
.await
|
||||
.map(|row| Self {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
password: Secret::new(row.password),
|
||||
role: Role::convert(row.role),
|
||||
language: Lang::from_str(&row.language).expect("Should parse"),
|
||||
});
|
||||
user.map_err(ServerError::Database)
|
||||
}
|
||||
|
||||
/// get all users
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed.
|
||||
#[instrument()]
|
||||
async fn get_all_users(server_config: &ServerConfig) -> Result<Vec<Self>, ServerError> {
|
||||
let user = sqlx::query("Select * from users")
|
||||
.fetch_all(&server_config.db_pool)
|
||||
.await
|
||||
.map(|row| {
|
||||
row.into_iter()
|
||||
.map(|r| Self {
|
||||
id: r.get("id"),
|
||||
username: r.get("username"),
|
||||
email: r.get("email"),
|
||||
password: Secret::new(r.get("password")),
|
||||
role: Role::convert(r.get("role")),
|
||||
language: Lang::from_str(r.get("language"))
|
||||
.expect("should parse correctly"),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
user.map_err(ServerError::Database)
|
||||
}
|
||||
|
||||
/// change a user
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the user does not exist, some constraints are not satisfied or the database cannot be acessed.
|
||||
#[instrument()]
|
||||
async fn update_user(&self, server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
let role_i64 = self.role.to_i64();
|
||||
sqlx::query!(
|
||||
"UPDATE users SET
|
||||
username = ?,
|
||||
email = ?,
|
||||
password = ?,
|
||||
role = ? where id = ?",
|
||||
self.username,
|
||||
self.email,
|
||||
self.password.secret,
|
||||
role_i64,
|
||||
self.id
|
||||
)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change an admin user to normal user and a normal user to admin
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed. (the user should exist)
|
||||
#[instrument()]
|
||||
async fn toggle_admin(self, server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
let new_role = match self.role {
|
||||
r @ (Role::NotAuthenticated | Role::Disabled) => r,
|
||||
Role::Regular => Role::Admin,
|
||||
Role::Admin => Role::Regular,
|
||||
};
|
||||
let role_i64 = new_role.to_i64();
|
||||
sqlx::query!("UPDATE users SET role = ? where id = ?", role_i64, self.id)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// set the language setting of a user
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the user does not exist or the database cannot be acessed.
|
||||
#[instrument()]
|
||||
async fn set_language(
|
||||
self,
|
||||
server_config: &ServerConfig,
|
||||
new_language: Lang,
|
||||
) -> Result<(), ServerError> {
|
||||
let lang_code = new_language.to_string();
|
||||
sqlx::query!(
|
||||
"UPDATE users SET language = ? where id = ?",
|
||||
lang_code,
|
||||
self.id
|
||||
)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Count the admin accounts
|
||||
///
|
||||
/// this is usefull for determining if any admins exist at all.
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed.
|
||||
#[instrument()]
|
||||
async fn count_admins(server_config: &ServerConfig) -> Result<Count, ServerError> {
|
||||
let num = sqlx::query_as!(Count, "select count(*) as number from users where role = 2")
|
||||
.fetch_one(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(num)
|
||||
}
|
||||
}
|
||||
|
||||
/// Relevant parameters when creating a new user
|
||||
/// Use the [`NewUser::new`] constructor to store the password encrypted. Otherwise it will not work.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NewUser {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl NewUser {
|
||||
/// Create a new user that can then be inserted in the database
|
||||
///
|
||||
/// The password is encrypted using the secret before creating.
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the password could not be encrypted.
|
||||
#[instrument()]
|
||||
pub fn new(
|
||||
username: String,
|
||||
email: String,
|
||||
password: &str,
|
||||
secret: &Secret,
|
||||
) -> Result<Self, ServerError> {
|
||||
let hash = Self::hash_password(password, secret)?;
|
||||
|
||||
Ok(Self {
|
||||
username,
|
||||
email,
|
||||
password: hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// encrypt the password.
|
||||
///
|
||||
/// This function uses the Secret from the config settings to encrypt the password
|
||||
#[instrument()]
|
||||
pub(crate) fn hash_password(password: &str, secret: &Secret) -> Result<String, ServerError> {
|
||||
dotenv().ok();
|
||||
|
||||
let hash = Hasher::default()
|
||||
.with_password(password)
|
||||
.with_secret_key(secret.secret.as_ref().expect("A secret key was not given"))
|
||||
.hash()?;
|
||||
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Insert this user into the database
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed.
|
||||
#[instrument()]
|
||||
pub async fn insert_user(&self, server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
sqlx::query!(
|
||||
"Insert into users (
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
role) VALUES (?,?,?,1)",
|
||||
self.username,
|
||||
self.email,
|
||||
self.password,
|
||||
)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Operations that should be supported by links
|
||||
#[async_trait]
|
||||
pub trait LinkDbOperations<T> {
|
||||
async fn get_link_by_code(code: &str, server_config: &ServerConfig) -> Result<T, ServerError>;
|
||||
async fn get_link_by_id(id: i64, server_config: &ServerConfig) -> Result<T, ServerError>;
|
||||
async fn delete_link_by_code(
|
||||
code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<(), ServerError>;
|
||||
async fn update_link(&self, server_config: &ServerConfig) -> Result<(), ServerError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LinkDbOperations<Self> for Link {
|
||||
/// Get a link by its code (the short url code)
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed or the link is not found.
|
||||
#[instrument()]
|
||||
async fn get_link_by_code(
|
||||
code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Self, ServerError> {
|
||||
let link = sqlx::query_as!(
|
||||
Self,
|
||||
"Select * from links where code = ? COLLATE NOCASE",
|
||||
code
|
||||
)
|
||||
.fetch_one(&server_config.db_pool)
|
||||
.await;
|
||||
tracing::info!("Found link: {:?}", &link);
|
||||
link.map_err(ServerError::Database)
|
||||
}
|
||||
|
||||
/// Get a link by its id
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed or the link is not found.
|
||||
#[instrument()]
|
||||
async fn get_link_by_id(id: i64, server_config: &ServerConfig) -> Result<Self, ServerError> {
|
||||
let link = sqlx::query_as!(Self, "Select * from links where id = ? ", id)
|
||||
.fetch_one(&server_config.db_pool)
|
||||
.await;
|
||||
tracing::info!("Found link: {:?}", &link);
|
||||
link.map_err(ServerError::Database)
|
||||
}
|
||||
|
||||
/// Delete a link by its code
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed or the link is not found.
|
||||
#[instrument()]
|
||||
async fn delete_link_by_code(
|
||||
code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<(), ServerError> {
|
||||
sqlx::query!("DELETE from links where code = ? ", code)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a link with new values, carful when changing the code the old link becomes invalid.
|
||||
/// This could be a problem when it is printed or published somewhere.
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed or the link is not found.
|
||||
#[instrument()]
|
||||
async fn update_link(&self, server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
info!("{:?}", self);
|
||||
let qry = sqlx::query!(
|
||||
"UPDATE links SET
|
||||
title = ?,
|
||||
target = ?,
|
||||
code = ?,
|
||||
author = ?,
|
||||
created_at = ? where id = ?",
|
||||
self.title,
|
||||
self.target,
|
||||
self.code,
|
||||
self.author,
|
||||
self.created_at,
|
||||
self.id
|
||||
);
|
||||
match qry.execute(&server_config.db_pool).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
//error!("{}", qry);
|
||||
error!("{}", e);
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Relevant parameters when creating a new link.
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct NewLink {
|
||||
pub title: String,
|
||||
pub target: String,
|
||||
pub code: String,
|
||||
pub author: i64,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
impl NewLink {
|
||||
/// Take a [`LinkDelta`] and create a [`NewLink`] instance. `created_at` is populated with the current time.
|
||||
pub(crate) fn from_link_delta(link: LinkDelta, uid: i64) -> Self {
|
||||
Self {
|
||||
title: link.title,
|
||||
target: link.target,
|
||||
code: link.code,
|
||||
author: uid,
|
||||
created_at: chrono::Local::now().naive_utc(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert the new link into the database
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed or constraints are not met.
|
||||
pub async fn insert(self, server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
sqlx::query!(
|
||||
"Insert into links (
|
||||
title,
|
||||
target,
|
||||
code,
|
||||
author,
|
||||
created_at) VALUES (?,?,?,?,?)",
|
||||
self.title,
|
||||
self.target,
|
||||
self.code,
|
||||
self.author,
|
||||
self.created_at,
|
||||
)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whenever a link is clicked the click is registered for statistical purposes.
|
||||
#[derive(Serialize)]
|
||||
pub struct NewClick {
|
||||
pub link: i64,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
impl NewClick {
|
||||
#[must_use]
|
||||
pub fn new(link_id: i64) -> Self {
|
||||
Self {
|
||||
link: link_id,
|
||||
created_at: chrono::Local::now().naive_utc(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn insert_click(
|
||||
self,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<(), ServerError> {
|
||||
sqlx::query!(
|
||||
"Insert into clicks (
|
||||
link,
|
||||
created_at) VALUES (?,?)",
|
||||
self.link,
|
||||
self.created_at,
|
||||
)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
704
pslink/src/queries.rs
Normal file
704
pslink/src/queries.rs
Normal file
@ -0,0 +1,704 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use actix_identity::Identity;
|
||||
use enum_map::EnumMap;
|
||||
use pslink_shared::{
|
||||
apirequests::{
|
||||
general::{EditMode, Filter, Operation, Ordering},
|
||||
links::{LinkDelta, LinkOverviewColumns, LinkRequestForm},
|
||||
users::{Role, UserDelta, UserOverviewColumns, UserRequestForm},
|
||||
},
|
||||
datatypes::{Count, FullLink, Lang, Link, Secret, User},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use sqlx::Row;
|
||||
use tracing::{info, instrument, warn};
|
||||
|
||||
use super::models::NewUser;
|
||||
use crate::{
|
||||
models::{LinkDbOperations, NewClick, NewLink, UserDbOperations},
|
||||
ServerConfig, ServerError,
|
||||
};
|
||||
|
||||
/// This type is used to guard the Roles. The typesystem enforces that the user can only be extracted if permissions are considered.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RoleGuard {
|
||||
NotAuthenticated,
|
||||
Disabled,
|
||||
Regular { user: User },
|
||||
Admin { user: User },
|
||||
}
|
||||
|
||||
impl RoleGuard {
|
||||
fn create(user: &User) -> Self {
|
||||
match user.role {
|
||||
Role::NotAuthenticated => Self::NotAuthenticated,
|
||||
Role::Disabled => Self::Disabled,
|
||||
Role::Regular => Self::Regular { user: user.clone() },
|
||||
Role::Admin => Self::Admin { user: user.clone() },
|
||||
}
|
||||
}
|
||||
/// Determin if the user is admin or the given user id is his own. This is used for things where users can edit or view their own entries, whereas admins can do so for all entries.
|
||||
const fn admin_or_self(&self, id: i64) -> bool {
|
||||
match self {
|
||||
Self::Admin { .. } => true,
|
||||
Self::Regular { user } => user.id == id,
|
||||
Self::NotAuthenticated | Self::Disabled => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// queries the user matching the given [`actix_identity::Identity`] and determins its authentication and permission level. Returns a [`RoleGuard`] containing the user if it is authenticated.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails only if there are issues using the database.
|
||||
#[instrument(skip(id))]
|
||||
pub async fn authenticate(
|
||||
id: &Identity,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<RoleGuard, ServerError> {
|
||||
if let Some(username) = id.identity() {
|
||||
info!("Looking for user {}", username);
|
||||
let user = User::get_user_by_name(&username, server_config).await?;
|
||||
info!("Found user {:?}", user);
|
||||
|
||||
return Ok(RoleGuard::create(&user));
|
||||
}
|
||||
Ok(RoleGuard::NotAuthenticated)
|
||||
}
|
||||
|
||||
/// A generic list returntype containing the User and a Vec containing e.g. Links or Users
|
||||
#[derive(Serialize)]
|
||||
pub struct ListWithOwner<T> {
|
||||
pub user: User,
|
||||
pub list: Vec<T>,
|
||||
}
|
||||
|
||||
/// Returns a List of `FullLink` meaning `Links` enriched by their author and statistics. This returns all links if the user is either Admin or Regular user.
|
||||
///
|
||||
/// Todo: this function only naively protects agains SQL-injections use better variants.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails.
|
||||
#[instrument(skip(id))]
|
||||
pub async fn list_all_allowed(
|
||||
id: &Identity,
|
||||
server_config: &ServerConfig,
|
||||
parameters: LinkRequestForm,
|
||||
) -> Result<ListWithOwner<FullLink>, ServerError> {
|
||||
use sqlx::Row;
|
||||
match authenticate(id, server_config).await? {
|
||||
RoleGuard::Admin { user } | RoleGuard::Regular { user } => {
|
||||
let mut querystring = "select
|
||||
links.id as lid,
|
||||
links.title as ltitle,
|
||||
links.target as ltarget,
|
||||
links.code as lcode,
|
||||
links.author as lauthor,
|
||||
links.created_at as ldate,
|
||||
users.id as usid,
|
||||
users.username as usern,
|
||||
users.email as uemail,
|
||||
users.role as urole,
|
||||
users.language as ulang,
|
||||
count(clicks.id) as counter
|
||||
from
|
||||
links
|
||||
join users on links.author = users.id
|
||||
left join clicks on links.id = clicks.link"
|
||||
.to_string();
|
||||
querystring.push_str(&generate_filter_sql(¶meters.filter));
|
||||
querystring.push_str("\n GROUP BY links.id");
|
||||
if let Some(order) = parameters.order {
|
||||
querystring.push_str(&generate_order_sql(&order));
|
||||
}
|
||||
querystring.push_str(&format!("\n LIMIT {}", parameters.amount));
|
||||
querystring.push_str(&format!("\n OFFSET {}", parameters.offset));
|
||||
info!("{}", querystring);
|
||||
|
||||
let links = sqlx::query(&querystring)
|
||||
.fetch_all(&server_config.db_pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|v| FullLink {
|
||||
link: Link {
|
||||
id: v.get("lid"),
|
||||
title: v.get("ltitle"),
|
||||
target: v.get("ltarget"),
|
||||
code: v.get("lcode"),
|
||||
author: v.get("lauthor"),
|
||||
created_at: v.get("ldate"),
|
||||
},
|
||||
user: User {
|
||||
id: v.get("usid"),
|
||||
username: v.get("usern"),
|
||||
email: v.get("uemail"),
|
||||
password: Secret::new("invalid".to_string()),
|
||||
role: Role::convert(v.get("urole")),
|
||||
language: Lang::from_str(v.get("ulang")).expect("Should parse"),
|
||||
},
|
||||
clicks: Count {
|
||||
number: v.get("counter"), /* count is never None */
|
||||
},
|
||||
});
|
||||
// show all links
|
||||
let all_links: Vec<FullLink> = links.collect();
|
||||
Ok(ListWithOwner {
|
||||
user,
|
||||
list: all_links,
|
||||
})
|
||||
}
|
||||
RoleGuard::Disabled | RoleGuard::NotAuthenticated => {
|
||||
Err(ServerError::User("Not allowed".to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a filter statement for the SQL-Query according to the parameters...
|
||||
///
|
||||
/// Todo: this function only naively protects agains SQL-injections use better variants.
|
||||
fn generate_filter_sql(filters: &EnumMap<LinkOverviewColumns, Filter>) -> String {
|
||||
let mut result = String::new();
|
||||
let filterstring = filters
|
||||
.iter()
|
||||
.filter_map(|(column, sieve)| {
|
||||
// avoid sql injections
|
||||
let sieve: String = sieve.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
if sieve.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(match column {
|
||||
LinkOverviewColumns::Code => {
|
||||
format!("\n lcode LIKE '%{}%'", sieve)
|
||||
}
|
||||
LinkOverviewColumns::Description => {
|
||||
format!("\n ltitle LIKE '%{}%'", sieve)
|
||||
}
|
||||
LinkOverviewColumns::Target => {
|
||||
format!("\n ltarget LIKE '%{}%'", sieve)
|
||||
}
|
||||
LinkOverviewColumns::Author => {
|
||||
format!("\n usern LIKE '%{}%'", sieve)
|
||||
}
|
||||
LinkOverviewColumns::Statistics => {
|
||||
format!("\n counter LIKE '%{}%'", sieve)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(" AND ");
|
||||
if filterstring.len() > 1 {
|
||||
result.push_str("\n WHERE ");
|
||||
result.push_str(&filterstring);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// A macro to translate the Ordering Type into a sql ordering string.
|
||||
macro_rules! ts {
|
||||
($ordering:expr) => {
|
||||
match $ordering {
|
||||
Ordering::Ascending => "ASC",
|
||||
Ordering::Descending => "DESC",
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/// Generate a order statement for the SQL-Query according to the parameters...
|
||||
fn generate_order_sql(order: &Operation<LinkOverviewColumns, Ordering>) -> String {
|
||||
let filterstring = match order.column {
|
||||
LinkOverviewColumns::Code => {
|
||||
format!("\n ORDER BY lcode {}", ts!(order.value))
|
||||
}
|
||||
LinkOverviewColumns::Description => {
|
||||
format!("\n ORDER BY ltitle {}", ts!(order.value))
|
||||
}
|
||||
LinkOverviewColumns::Target => {
|
||||
format!("\n ORDER BY ltarget {}", ts!(order.value))
|
||||
}
|
||||
LinkOverviewColumns::Author => {
|
||||
format!("\n ORDER BY usern {}", ts!(order.value))
|
||||
}
|
||||
LinkOverviewColumns::Statistics => {
|
||||
format!("\n ORDER BY counter {}", ts!(order.value))
|
||||
}
|
||||
};
|
||||
filterstring
|
||||
}
|
||||
|
||||
/// Only admins can list all users other users will only see themselves.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails.
|
||||
#[instrument(skip(id))]
|
||||
pub async fn list_users(
|
||||
id: &Identity,
|
||||
server_config: &ServerConfig,
|
||||
parameters: UserRequestForm,
|
||||
) -> Result<ListWithOwner<User>, ServerError> {
|
||||
match authenticate(id, server_config).await? {
|
||||
RoleGuard::Admin { user } => {
|
||||
let mut querystring = "Select * from users".to_string();
|
||||
querystring.push_str(&generate_filter_users_sql(¶meters.filter));
|
||||
if let Some(order) = parameters.order {
|
||||
querystring.push_str(&generate_order_users_sql(&order));
|
||||
}
|
||||
querystring.push_str(&format!("\n LIMIT {}", parameters.amount));
|
||||
info!("{}", querystring);
|
||||
|
||||
let users: Vec<User> = sqlx::query(&querystring)
|
||||
.fetch_all(&server_config.db_pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|v| User {
|
||||
id: v.get("id"),
|
||||
username: v.get("username"),
|
||||
email: v.get("email"),
|
||||
password: Secret::new("".to_string()),
|
||||
role: Role::convert(v.get("role")),
|
||||
language: Lang::from_str(v.get("language")).expect("Should parse"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ListWithOwner { user, list: users })
|
||||
}
|
||||
RoleGuard::Regular { user } => Ok(ListWithOwner {
|
||||
user: user.clone(),
|
||||
list: vec![user],
|
||||
}),
|
||||
_ => Err(ServerError::User(
|
||||
"Administrator permissions required".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a filter statement for the SQL-Query according to the parameters...
|
||||
///
|
||||
/// Todo: this function only naively protects agains SQL-injections use better variants.
|
||||
fn generate_filter_users_sql(filters: &EnumMap<UserOverviewColumns, Filter>) -> String {
|
||||
let mut result = String::new();
|
||||
let filterstring = filters
|
||||
.iter()
|
||||
.filter_map(|(column, sieve)| {
|
||||
// avoid sql injections
|
||||
let sieve: String = sieve.chars().filter(|x| x.is_alphanumeric()).collect();
|
||||
if sieve.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(match column {
|
||||
UserOverviewColumns::Id => {
|
||||
format!("\n id LIKE '%{}%'", sieve)
|
||||
}
|
||||
UserOverviewColumns::Username => {
|
||||
format!("\n username LIKE '%{}%'", sieve)
|
||||
}
|
||||
UserOverviewColumns::Email => {
|
||||
format!("\n email LIKE '%{}%'", sieve)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(" AND ");
|
||||
if filterstring.len() > 1 {
|
||||
result.push_str("\n WHERE ");
|
||||
result.push_str(&filterstring);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Generate a order statement for the SQL-Query according to the parameters...
|
||||
fn generate_order_users_sql(order: &Operation<UserOverviewColumns, Ordering>) -> String {
|
||||
let filterstring = match order.column {
|
||||
UserOverviewColumns::Id => {
|
||||
format!("\n ORDER BY id {}", ts!(order.value))
|
||||
}
|
||||
UserOverviewColumns::Username => {
|
||||
format!("\n ORDER BY username {}", ts!(order.value))
|
||||
}
|
||||
UserOverviewColumns::Email => {
|
||||
format!("\n ORDER BY email {}", ts!(order.value))
|
||||
}
|
||||
};
|
||||
filterstring
|
||||
}
|
||||
|
||||
/// A generic returntype containing the User and a single item
|
||||
pub struct Item<T> {
|
||||
pub user: User,
|
||||
pub item: T,
|
||||
}
|
||||
|
||||
/// Get a user if permissions are accordingly
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
#[allow(clippy::missing_panics_doc)]
|
||||
#[instrument(skip(id))]
|
||||
pub async fn get_user(
|
||||
id: &Identity,
|
||||
user_id: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<User>, ServerError> {
|
||||
if let Ok(uid) = user_id.parse::<i64>() {
|
||||
info!("Getting user {}", uid);
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
if auth.admin_or_self(uid) {
|
||||
match auth {
|
||||
RoleGuard::Admin { user } | RoleGuard::Regular { user } => {
|
||||
let viewed_user = User::get_user(uid as i64, server_config).await?;
|
||||
Ok(Item {
|
||||
user,
|
||||
item: viewed_user,
|
||||
})
|
||||
}
|
||||
RoleGuard::Disabled | RoleGuard::NotAuthenticated => {
|
||||
unreachable!("should already be unreachable because of `admin_or_self`")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(ServerError::User("Permission Denied".to_owned()))
|
||||
}
|
||||
} else {
|
||||
Err(ServerError::User("Permission Denied".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a user **without permission checks** (needed for login)
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails.
|
||||
#[instrument()]
|
||||
pub async fn get_user_by_name(
|
||||
username: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<User, ServerError> {
|
||||
let user = User::get_user_by_name(username, server_config).await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Create a new user and save it to the database
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the user already exists.
|
||||
#[instrument(skip(id))]
|
||||
pub async fn create_user(
|
||||
id: &Identity,
|
||||
data: UserDelta,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<User>, ServerError> {
|
||||
info!("Creating a User: {:?}", &data);
|
||||
if data.edit != EditMode::Create {
|
||||
return Err(ServerError::User("Wrong Request".to_string()));
|
||||
}
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
|
||||
// Require a password on user creation!
|
||||
let password = match &data.password {
|
||||
Some(pass) => pass,
|
||||
None => {
|
||||
return Err(ServerError::User(
|
||||
"A new users does require a password".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
match auth {
|
||||
RoleGuard::Admin { user } => {
|
||||
let new_user = NewUser::new(
|
||||
data.username.clone(),
|
||||
data.email.clone(),
|
||||
password,
|
||||
&server_config.secret,
|
||||
)?;
|
||||
|
||||
new_user.insert_user(server_config).await?;
|
||||
|
||||
// querry the new user
|
||||
let new_user = get_user_by_name(&data.username, server_config).await?;
|
||||
Ok(Item {
|
||||
user,
|
||||
item: new_user,
|
||||
})
|
||||
}
|
||||
RoleGuard::Regular { .. } | RoleGuard::Disabled | RoleGuard::NotAuthenticated => {
|
||||
Err(ServerError::User("Permission denied!".to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a [`actix_web::web::Form<NewUser>`] and update the corresponding entry in the database.
|
||||
/// The password is only updated if a new password of at least 4 characters is provided.
|
||||
/// The `user_id` is never changed.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions, or the given data is malformed.
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn update_user(
|
||||
id: &Identity,
|
||||
data: &UserDelta,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<User>, ServerError> {
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
if let Some(uid) = data.id {
|
||||
let unmodified_user = User::get_user(uid, server_config).await?;
|
||||
if auth.admin_or_self(uid) {
|
||||
match auth {
|
||||
RoleGuard::Admin { .. } | RoleGuard::Regular { .. } => {
|
||||
info!("Updating userinfo: ");
|
||||
let password = match &data.password {
|
||||
Some(password) if password.len() > 4 => {
|
||||
Secret::new(NewUser::hash_password(password, &server_config.secret)?)
|
||||
}
|
||||
_ => unmodified_user.password,
|
||||
};
|
||||
let new_user = User {
|
||||
id: uid,
|
||||
username: data.username.clone(),
|
||||
email: data.email.clone(),
|
||||
password,
|
||||
role: unmodified_user.role,
|
||||
language: unmodified_user.language,
|
||||
};
|
||||
new_user.update_user(server_config).await?;
|
||||
let changed_user = User::get_user(uid, server_config).await?;
|
||||
Ok(Item {
|
||||
user: changed_user.clone(),
|
||||
item: changed_user,
|
||||
})
|
||||
}
|
||||
RoleGuard::NotAuthenticated | RoleGuard::Disabled => {
|
||||
unreachable!("Should be unreachable because of the `admin_or_self`")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(ServerError::User("Not a valid UID".to_owned()))
|
||||
}
|
||||
} else {
|
||||
Err(ServerError::User("Not a valid UID".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Demote an admin user to a normal user or promote a normal user to admin privileges.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the user does not exist.
|
||||
#[instrument(skip(id))]
|
||||
pub async fn toggle_admin(
|
||||
id: &Identity,
|
||||
user_id: Option<i64>,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<User>, ServerError> {
|
||||
if let Some(uid) = user_id {
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
match auth {
|
||||
RoleGuard::Admin { .. } => {
|
||||
info!("Changing administrator privileges: ");
|
||||
|
||||
let unchanged_user = User::get_user(uid, server_config).await?;
|
||||
|
||||
let old = unchanged_user.role;
|
||||
unchanged_user.toggle_admin(server_config).await?;
|
||||
|
||||
info!("Toggling role: old was {:?}", old);
|
||||
|
||||
let changed_user = User::get_user(uid, server_config).await?;
|
||||
info!("Toggled role: new is {:?}", changed_user.role);
|
||||
Ok(Item {
|
||||
user: changed_user.clone(),
|
||||
item: changed_user,
|
||||
})
|
||||
}
|
||||
RoleGuard::Regular { .. } | RoleGuard::NotAuthenticated | RoleGuard::Disabled => {
|
||||
Err(ServerError::User("Permission denied".to_owned()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(ServerError::User("Permission denied".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the language of a given user
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the language given is invalid.
|
||||
#[instrument(skip(id))]
|
||||
pub async fn set_language(
|
||||
id: &Identity,
|
||||
lang_code: Lang,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<(), ServerError> {
|
||||
match authenticate(id, server_config).await? {
|
||||
RoleGuard::Admin { user } | RoleGuard::Regular { user } => {
|
||||
user.set_language(server_config, lang_code).await
|
||||
}
|
||||
RoleGuard::Disabled | RoleGuard::NotAuthenticated => {
|
||||
Err(ServerError::User("Not Allowed".to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get one link if permissions are accordingly.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
#[instrument(skip(id))]
|
||||
pub async fn get_link(
|
||||
id: &Identity,
|
||||
link_code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<Link>, ServerError> {
|
||||
match authenticate(id, server_config).await? {
|
||||
RoleGuard::Admin { user } | RoleGuard::Regular { user } => {
|
||||
let link = Link::get_link_by_code(link_code, server_config).await?;
|
||||
Ok(Item { user, item: link })
|
||||
}
|
||||
RoleGuard::Disabled | RoleGuard::NotAuthenticated => {
|
||||
warn!("User could not be authenticated!");
|
||||
Err(ServerError::User("Not Allowed".to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get one link if permissions are accordingly.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
#[instrument(skip(id))]
|
||||
pub async fn get_link_by_id(
|
||||
id: &Identity,
|
||||
lid: i64,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<Link>, ServerError> {
|
||||
match authenticate(id, server_config).await? {
|
||||
RoleGuard::Admin { user } | RoleGuard::Regular { user } => {
|
||||
let link = Link::get_link_by_id(lid, server_config).await?;
|
||||
Ok(Item { user, item: link })
|
||||
}
|
||||
RoleGuard::Disabled | RoleGuard::NotAuthenticated => {
|
||||
warn!("User could not be authenticated!");
|
||||
Err(ServerError::User("Not Allowed".to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get link **without authentication**
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails.
|
||||
#[instrument()]
|
||||
pub async fn get_link_simple(
|
||||
link_code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Link, ServerError> {
|
||||
info!("Getting link for {:?}", link_code);
|
||||
|
||||
let link = Link::get_link_by_code(link_code, server_config).await?;
|
||||
info!("Foun d link for {:?}", link);
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Click on a link
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails.
|
||||
#[instrument()]
|
||||
pub async fn click_link(link_id: i64, server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
info!("Clicking on {:?}", link_id);
|
||||
let new_click = NewClick::new(link_id);
|
||||
new_click.insert_click(server_config).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a link
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
#[instrument(skip(id))]
|
||||
pub async fn delete_link(
|
||||
id: &Identity,
|
||||
link_code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<(), ServerError> {
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
let link: Link = get_link_simple(link_code, server_config).await?;
|
||||
if auth.admin_or_self(link.author) {
|
||||
Link::delete_link_by_code(link_code, server_config).await?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ServerError::User("Permission denied!".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new link
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
#[instrument(skip(id))]
|
||||
pub async fn create_link(
|
||||
id: &Identity,
|
||||
data: LinkDelta,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<Link>, ServerError> {
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
match auth {
|
||||
RoleGuard::Admin { user } | RoleGuard::Regular { user } => {
|
||||
let code = data.code.clone();
|
||||
info!("Creating link for: {}", &code);
|
||||
let new_link = NewLink::from_link_delta(data, user.id);
|
||||
info!("Creating link for: {:?}", &new_link);
|
||||
|
||||
new_link.insert(server_config).await?;
|
||||
let new_link: Link = get_link_simple(&code, server_config).await?;
|
||||
Ok(Item {
|
||||
user,
|
||||
item: new_link,
|
||||
})
|
||||
}
|
||||
RoleGuard::Disabled | RoleGuard::NotAuthenticated => {
|
||||
Err(ServerError::User("Permission denied!".to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a link if the user is admin or it is its own link.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
#[instrument(skip(ident))]
|
||||
pub async fn update_link(
|
||||
ident: &Identity,
|
||||
data: LinkDelta,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<Link>, ServerError> {
|
||||
let auth = authenticate(ident, server_config).await?;
|
||||
match auth {
|
||||
RoleGuard::Admin { .. } | RoleGuard::Regular { .. } => {
|
||||
if let Some(id) = data.id {
|
||||
let query: Item<Link> = get_link_by_id(ident, id, server_config).await?;
|
||||
if auth.admin_or_self(query.item.author) {
|
||||
let mut link = query.item;
|
||||
let LinkDelta {
|
||||
title,
|
||||
target,
|
||||
code,
|
||||
..
|
||||
} = data;
|
||||
link.code = code.clone();
|
||||
link.target = target;
|
||||
link.title = title;
|
||||
link.update_link(server_config).await?;
|
||||
get_link(ident, &code, server_config).await
|
||||
} else {
|
||||
Err(ServerError::User("Invalid Request".to_owned()))
|
||||
}
|
||||
} else {
|
||||
Err(ServerError::User("Not Allowed".to_owned()))
|
||||
}
|
||||
}
|
||||
RoleGuard::Disabled | RoleGuard::NotAuthenticated => {
|
||||
Err(ServerError::User("Not Allowed".to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
245
pslink/static/admin.css
Normal file
245
pslink/static/admin.css
Normal file
@ -0,0 +1,245 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
position: fixed;
|
||||
width: 600px;
|
||||
max-width: 100%;
|
||||
height: 500px;
|
||||
max-height: 100%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -300px;
|
||||
margin-top: -250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 30px;
|
||||
color: #333;
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.center input {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 1px;
|
||||
border: 1px solid rgb(90, 90, 90);
|
||||
font-family: inherit;
|
||||
background-color: #eae9ea;
|
||||
}
|
||||
|
||||
div.login div {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
display: table;
|
||||
}
|
||||
|
||||
div.login input {
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.center table p {
|
||||
font-size: x-small;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: center;
|
||||
border: 1px solid #ccc;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
td.table_qr svg {
|
||||
max-height: 40px;
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
table tr:nth-child(odd) {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
table tr:nth-child(even).admin {
|
||||
background-color: rgb(240, 142, 142);
|
||||
}
|
||||
|
||||
table tr:nth-child(odd).admin {
|
||||
background-color: rgb(255, 204, 169);
|
||||
}
|
||||
|
||||
table tr.filters input {
|
||||
background-image: url("/static/search.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
table tr.filters td {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
nav ol {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
nav li a,
|
||||
nav li div.willkommen {
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 14px 16px;
|
||||
text-decoration: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
nav li a {
|
||||
background: rgb(2, 0, 36);
|
||||
background: linear-gradient(180deg, rgba(2, 0, 36, 1) 0%, rgba(9, 9, 121, 1) 35%, rgb(0, 145, 174) 100%);
|
||||
}
|
||||
|
||||
nav li {
|
||||
float: left;
|
||||
}
|
||||
|
||||
nav li a:hover {
|
||||
background: rgb(2, 0, 36);
|
||||
background: linear-gradient(180deg, rgba(2, 0, 36, 1) 0%, rgba(9, 9, 121, 1) 35%, rgb(60, 170, 255) 100%);
|
||||
}
|
||||
|
||||
nav li {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
nav li div {
|
||||
background-color: burlywood;
|
||||
text-align: center;
|
||||
padding: 14px 16px;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
div.actions {
|
||||
margin-left: 5px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
color: #333;
|
||||
flex-flow: row wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
div.danger {
|
||||
background-color: rgb(235, 127, 77);
|
||||
font-size: smaller;
|
||||
border: 2px solid crimson;
|
||||
}
|
||||
|
||||
div.danger h3 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a.button,
|
||||
div.actions input {
|
||||
width: 250px;
|
||||
display: block;
|
||||
padding: 15px;
|
||||
margin-left: 15px;
|
||||
text-align: center;
|
||||
border-radius: 1px;
|
||||
border: 1px solid rgb(90, 90, 90);
|
||||
font-family: inherit;
|
||||
background-color: #eae9ea;
|
||||
}
|
||||
|
||||
div.editdialog {
|
||||
background-color: aliceblue;
|
||||
border: 5px solid rgb(90, 90, 90);
|
||||
}
|
||||
|
||||
div.closebutton a {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
font-size: xx-large;
|
||||
}
|
||||
|
||||
div.message {
|
||||
background-color: aliceblue;
|
||||
border: 5px solid rgb(90, 90, 90);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
img.trashicon {
|
||||
width: 0.5cm;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.qrdownload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.languageselector a {
|
||||
height: 70%;
|
||||
padding: 5px;
|
||||
margin: 3px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
a.loadmore {
|
||||
display: flex;
|
||||
color: darkgray;
|
||||
margin: auto;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
}
|
3
pslink/static/reload.svg
Normal file
3
pslink/static/reload.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 12.7 12.7" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="rotate(220.03)" d="m-6.5691-4.5466c-1.7227-1.0829-3.96-0.86298-5.4388 0.53473-1.4788 1.3977-1.8246 3.619-0.84044 5.4 0.98412 1.781 3.0487 2.6705 5.019 2.1624 1.9704-0.50816 3.3472-2.2852 3.3472-4.32l-1.902 1.7128 1.902-1.7128 0.72362 2.5702" style="fill:none;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.1607;stroke:#a8a8a8"/>
|
||||
</svg>
|
After Width: | Height: | Size: 467 B |
1
pslink/static/search.svg
Normal file
1
pslink/static/search.svg
Normal file
@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg height="32px" version="1.1" viewBox="0 0 32 32" width="32px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title/><desc/><defs/><g fill="none" fill-rule="evenodd" id="Page-1" stroke="none" stroke-width="1"><g fill="#929292" id="icon-111-search"><path d="M19.4271164,20.4271164 C18.0372495,21.4174803 16.3366522,22 14.5,22 C9.80557939,22 6,18.1944206 6,13.5 C6,8.80557939 9.80557939,5 14.5,5 C19.1944206,5 23,8.80557939 23,13.5 C23,15.8472103 22.0486052,17.9722103 20.5104077,19.5104077 L26.5077736,25.5077736 C26.782828,25.782828 26.7761424,26.2238576 26.5,26.5 C26.2219324,26.7780676 25.7796227,26.7796227 25.5077736,26.5077736 L19.4271164,20.4271164 L19.4271164,20.4271164 Z M14.5,21 C18.6421358,21 22,17.6421358 22,13.5 C22,9.35786417 18.6421358,6 14.5,6 C10.3578642,6 7,9.35786417 7,13.5 C7,17.6421358 10.3578642,21 14.5,21 L14.5,21 Z" id="search"/></g></g></svg>
|
After Width: | Height: | Size: 927 B |
95
pslink/static/style.css
Normal file
95
pslink/static/style.css
Normal file
@ -0,0 +1,95 @@
|
||||
|
||||
*, *:before, *:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin:0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -200px;
|
||||
margin-top: -200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 30px;
|
||||
color: #333;
|
||||
}
|
||||
.center input {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
margin: 5px;
|
||||
border-radius: 1px;
|
||||
border: 1px solid rgb(90, 90, 90);
|
||||
font-family: inherit;
|
||||
background-color: #eae9ea;
|
||||
}
|
||||
|
||||
.lds-ellipsis {
|
||||
display: block;
|
||||
position: fixed;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: -40px;
|
||||
margin-left: -40px;
|
||||
color:rgb(130, 0, 0);
|
||||
}
|
||||
.lds-ellipsis div {
|
||||
position: absolute;
|
||||
top: 33px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: rgb(130, 0, 0);
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
}
|
||||
.lds-ellipsis div:nth-child(1) {
|
||||
left: 8px;
|
||||
animation: lds-ellipsis1 0.6s infinite;
|
||||
}
|
||||
.lds-ellipsis div:nth-child(2) {
|
||||
left: 8px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
.lds-ellipsis div:nth-child(3) {
|
||||
left: 32px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
.lds-ellipsis div:nth-child(4) {
|
||||
left: 56px;
|
||||
animation: lds-ellipsis3 0.6s infinite;
|
||||
}
|
||||
@keyframes lds-ellipsis1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes lds-ellipsis3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@keyframes lds-ellipsis2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(24px, 0);
|
||||
}
|
||||
}
|
19
pslink/static/trash.svg
Normal file
19
pslink/static/trash.svg
Normal file
@ -0,0 +1,19 @@
|
||||
<svg width="99.857mm" height="134.86mm" version="1.1" viewBox="0 0 99.857 134.86" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<metadata>
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
<dc:title/>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g transform="translate(-58.021 -74.624)">
|
||||
<path d="m65.101 94.716h84.426l-10.99 112.77h-62.431z" style="fill:none;stroke-linecap:round;stroke-linejoin:round;stroke-width:4;stroke:#000"/>
|
||||
<path d="m107.57 113.88v74.637" style="fill:none;stroke-linecap:round;stroke-width:3;stroke:#000"/>
|
||||
<path d="m86.154 113.62 5.4485 74.438" style="fill:none;stroke-linecap:round;stroke-width:3;stroke:#000"/>
|
||||
<path d="m129.73 114.36-5.5378 74.432" style="fill:none;stroke-linecap:round;stroke-width:3;stroke:#000"/>
|
||||
<path d="m59.882 87.653h96.136" style="fill:none;stroke-linecap:round;stroke-width:3.721;stroke:#000"/>
|
||||
<path d="m93.588 87.521c13.139-19.351 20.218-10.383 27.029-0.1697" style="fill:none;stroke-linecap:round;stroke-width:3;stroke:#000"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
1
pslink/static/wasm/.gitignore
vendored
Normal file
1
pslink/static/wasm/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*
|
11
pslink/static/wasm/README.md
Normal file
11
pslink/static/wasm/README.md
Normal file
@ -0,0 +1,11 @@
|
||||
# The Frontend for pslink
|
||||
|
||||
This part of `pslink` is the wasm binary for the frontend of `pslink`.
|
||||
|
||||
It provides:
|
||||
* a login screen
|
||||
* management for links
|
||||
* management for users
|
||||
* switching the language
|
||||
|
||||
The wasm binary communicates via a REST-JSON-Api with the server.
|
559
pslink/tests/integration-tests.rs
Normal file
559
pslink/tests/integration-tests.rs
Normal file
@ -0,0 +1,559 @@
|
||||
use assert_cmd::prelude::*; // Add methods on commands
|
||||
use reqwest::header::HeaderMap;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::Read,
|
||||
process::{Child, Command},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_help_of_command_for_breaking_changes() {
|
||||
let output = test_bin::get_test_bin("pslink")
|
||||
.output()
|
||||
.expect("Failed to start pslink");
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("USAGE"));
|
||||
|
||||
let output = test_bin::get_test_bin("pslink")
|
||||
.args(&["--help"])
|
||||
.output()
|
||||
.expect("Failed to start pslink");
|
||||
let outstring = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
let args = &[
|
||||
"USAGE",
|
||||
"-h",
|
||||
"--help",
|
||||
"-b",
|
||||
"-e",
|
||||
"-i",
|
||||
"-p",
|
||||
"-t",
|
||||
"-u",
|
||||
"runserver",
|
||||
"create-admin",
|
||||
"generate-env",
|
||||
"migrate-database",
|
||||
"help",
|
||||
];
|
||||
|
||||
for s in args {
|
||||
assert!(
|
||||
outstring.contains(s),
|
||||
"{} was not found in the help - this is a breaking change",
|
||||
s
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_env() {
|
||||
use std::io::BufRead;
|
||||
let tmp_dir = tempdir::TempDir::new("pslink_test_env").expect("create temp dir");
|
||||
let output = test_bin::get_test_bin("pslink")
|
||||
.args(&["generate-env", "--secret", "abcdefghijklmnopqrstuvw"])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed to start pslink");
|
||||
let envfile = tmp_dir.path().join(".env");
|
||||
let dbfile = tmp_dir.path().join("links.db");
|
||||
println!("{}", envfile.display());
|
||||
println!("{}", dbfile.display());
|
||||
println!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
assert!(envfile.exists(), "No .env-file was created!");
|
||||
assert!(dbfile.exists(), "No database-file was created!");
|
||||
|
||||
let envfile = std::fs::File::open(envfile).unwrap();
|
||||
let envcontent: Vec<Result<String, _>> = std::io::BufReader::new(envfile).lines().collect();
|
||||
assert!(
|
||||
envcontent
|
||||
.iter()
|
||||
.any(|s| s.as_ref().unwrap().starts_with("PSLINK_PORT=")),
|
||||
"Failed to find PSLINK_PORT in the generated .env file."
|
||||
);
|
||||
assert!(
|
||||
envcontent
|
||||
.iter()
|
||||
.any(|s| s.as_ref().unwrap().starts_with("PSLINK_SECRET=")),
|
||||
"Failed to find PSLINK_SECRET in the generated .env file."
|
||||
);
|
||||
assert!(
|
||||
!envcontent.iter().any(|s| {
|
||||
let r = s.as_ref().unwrap().contains("***SECRET***");
|
||||
r
|
||||
}),
|
||||
"It seems that a censored secret was used in the .env file."
|
||||
);
|
||||
assert!(
|
||||
envcontent.iter().any(|s| {
|
||||
let r = s.as_ref().unwrap().contains("abcdefghijklmnopqrstuvw");
|
||||
r
|
||||
}),
|
||||
"The secret has not made it into the .env file!"
|
||||
);
|
||||
let output = test_bin::get_test_bin("pslink")
|
||||
.args(&["generate-env"])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed to start pslink");
|
||||
let second_out = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(!second_out.contains("secret"));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_migrate_database() {
|
||||
use std::io::Write;
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub struct Count {
|
||||
pub number: i32,
|
||||
}
|
||||
|
||||
let tmp_dir = tempdir::TempDir::new("pslink_test_env").expect("create temp dir");
|
||||
// generate .env file
|
||||
let _output = test_bin::get_test_bin("pslink")
|
||||
.args(&["generate-env"])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed generate .env");
|
||||
|
||||
// migrate the database
|
||||
let output = test_bin::get_test_bin("pslink")
|
||||
.args(&["migrate-database"])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed to migrate the database");
|
||||
println!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
|
||||
// check if the users table exists by counting the number of admins.
|
||||
let db_pool = sqlx::pool::Pool::<sqlx::sqlite::Sqlite>::connect(
|
||||
&tmp_dir.path().join("links.db").display().to_string(),
|
||||
)
|
||||
.await
|
||||
.expect("Error: Failed to connect to database!");
|
||||
let num = sqlx::query_as!(Count, "select count(*) as number from users where role = 2")
|
||||
.fetch_one(&db_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// initially no admin is present
|
||||
assert_eq!(num.number, 0, "Failed to create the database!");
|
||||
|
||||
// create a new admin
|
||||
let mut input = test_bin::get_test_bin("pslink")
|
||||
.args(&["create-admin"])
|
||||
.current_dir(&tmp_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to migrate the database");
|
||||
let mut procin = input.stdin.take().unwrap();
|
||||
|
||||
procin.write_all(b"test\n").unwrap();
|
||||
procin.write_all(b"test@mail.test\n").unwrap();
|
||||
procin.write_all(b"testpw\n").unwrap();
|
||||
|
||||
let r = input.wait().unwrap();
|
||||
println!("Exitstatus is: {}", r);
|
||||
|
||||
println!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
let num = sqlx::query_as!(Count, "select count(*) as number from users where role = 2")
|
||||
.fetch_one(&db_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// now 1 admin is there
|
||||
assert_eq!(num.number, 1, "Failed to create an admin!");
|
||||
}
|
||||
|
||||
struct RunningServer {
|
||||
server: Child,
|
||||
port: i32,
|
||||
}
|
||||
|
||||
impl Drop for RunningServer {
|
||||
fn drop(&mut self) {
|
||||
self.server.kill().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_server() -> RunningServer {
|
||||
use std::io::Write;
|
||||
|
||||
use rand::thread_rng;
|
||||
use rand::Rng;
|
||||
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub struct Count {
|
||||
pub number: i32,
|
||||
}
|
||||
|
||||
let mut rng = thread_rng();
|
||||
let port = rng.gen_range(12000..20000);
|
||||
let tmp_dir = tempdir::TempDir::new("pslink_test_env").expect("create temp dir");
|
||||
// generate .env file
|
||||
let _output = Command::cargo_bin("pslink")
|
||||
.expect("Failed to get binary executable")
|
||||
.args(&[
|
||||
"generate-env",
|
||||
"--secret",
|
||||
"abcdefghijklmnopqrstuvw",
|
||||
"--port",
|
||||
&port.to_string(),
|
||||
])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed generate .env");
|
||||
// migrate the database
|
||||
let output = Command::cargo_bin("pslink")
|
||||
.unwrap()
|
||||
.args(&["migrate-database"])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed to migrate the database");
|
||||
|
||||
// create a database connection.
|
||||
let db_pool = sqlx::pool::Pool::<sqlx::sqlite::Sqlite>::connect(
|
||||
&tmp_dir.path().join("links.db").display().to_string(),
|
||||
)
|
||||
.await
|
||||
.expect("Error: Failed to connect to database!"); // create a new admin
|
||||
let mut input = Command::cargo_bin("pslink")
|
||||
.unwrap()
|
||||
.args(&["create-admin"])
|
||||
.current_dir(&tmp_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to migrate the database");
|
||||
let mut procin = input.stdin.take().unwrap();
|
||||
|
||||
procin.write_all(b"test\n").unwrap();
|
||||
procin.write_all(b"test@mail.test\n").unwrap();
|
||||
procin.write_all(b"testpw\n").unwrap();
|
||||
|
||||
let r = input.wait().unwrap();
|
||||
println!("Exitstatus is: {}", r);
|
||||
|
||||
println!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
let num = sqlx::query_as!(Count, "select count(*) as number from users where role = 2")
|
||||
.fetch_one(&db_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// now 1 admin is there
|
||||
assert_eq!(
|
||||
num.number, 1,
|
||||
"Failed to create an admin! See previous tests!"
|
||||
);
|
||||
|
||||
let mut server = Command::cargo_bin("pslink")
|
||||
.unwrap()
|
||||
.args(&["runserver"])
|
||||
.current_dir(&tmp_dir)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
// Wait until the server signals it is up and running.
|
||||
let mut sout = server.stdout.take().unwrap();
|
||||
let mut buffer = [0; 15];
|
||||
println!("Running the webserver for testing #############");
|
||||
loop {
|
||||
let num = sout.read(&mut buffer[..]).unwrap();
|
||||
println!("{}", num);
|
||||
let t = std::str::from_utf8(&buffer).unwrap();
|
||||
println!("{:?}", std::str::from_utf8(&buffer));
|
||||
if num > 0 && t.contains("/app") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
RunningServer { server, port }
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_web_paths() {
|
||||
let server = run_server().await;
|
||||
|
||||
// We need to bring in `reqwest`
|
||||
// to perform HTTP requests against our application.
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let base_url = "http://localhost:".to_string() + &server.port.to_string() + "/";
|
||||
println!("{}", base_url);
|
||||
|
||||
// Act
|
||||
let response = client
|
||||
.get(&base_url.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// The basic redirection is working!
|
||||
assert!(response.status().is_redirection());
|
||||
let location = response.headers().get("location").unwrap();
|
||||
assert!(location.to_str().unwrap().contains("github"));
|
||||
|
||||
let app_url = base_url.clone() + "app/";
|
||||
// Act
|
||||
let response = client
|
||||
.get(&app_url.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
println!("{:?}", response);
|
||||
|
||||
// The app page is reachable and contains the wasm file!
|
||||
assert!(response.status().is_success());
|
||||
let content = response.text().await.unwrap();
|
||||
assert!(
|
||||
content.contains(r#"init('/static/wasm/app_bg.wasm');"#),
|
||||
"The app page has unexpected content!"
|
||||
);
|
||||
|
||||
// Act
|
||||
let mut formdata = HashMap::new();
|
||||
formdata.insert("username", "test");
|
||||
formdata.insert("password", "testpw");
|
||||
let response = client
|
||||
.post(&(base_url.clone() + "admin/json/login_user/"))
|
||||
.json(&formdata)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
println!("Login response:\n {:?}", response);
|
||||
|
||||
// It is possible to login
|
||||
assert!(response.status().is_success());
|
||||
|
||||
// Extract the cookie as it is not automatically saved for some reason.
|
||||
let cookie = {
|
||||
response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.expect("A auth cookie is not set even though authentication succeeds")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
println!("{:?}", cookie);
|
||||
assert!(cookie.starts_with("auth-cookie="));
|
||||
|
||||
let content = response.text().await.unwrap();
|
||||
println!("Content: {:?}", content);
|
||||
assert!(content.contains(r#""id":1"#), "id missing in content");
|
||||
|
||||
let mut custom_headers = HeaderMap::new();
|
||||
custom_headers.insert("content-type", "application/json".parse().unwrap());
|
||||
custom_headers.insert("Cookie", cookie.parse().unwrap());
|
||||
|
||||
// After login this should return an empty list
|
||||
let query = client
|
||||
.post(&(base_url.clone() + "admin/json/list_links/"))
|
||||
.headers(custom_headers.clone())
|
||||
.body(r#"{"filter":{"Code":{"sieve":""},"Description":{"sieve":""},"Target":{"sieve":""},"Author":{"sieve":""},"Statistics":{"sieve":""}},"order":null,"amount":20}"#).build().unwrap();
|
||||
println!("{:?}", query);
|
||||
let response = client
|
||||
.execute(query)
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
println!("List urls response:\n {:?}", response);
|
||||
|
||||
// Make sure the list was retrieved and the status codes are correct
|
||||
assert!(response.status().is_success());
|
||||
|
||||
// Make sure that the content is an empty list as until now no links were created.
|
||||
let content = response.text().await.unwrap();
|
||||
println!("Content: {:?}", content);
|
||||
assert!(content.contains(r#"[]"#), "id missing in content");
|
||||
|
||||
// Create a link
|
||||
let query = client
|
||||
.post(&(base_url.clone() + "admin/json/create_link/"))
|
||||
.headers(custom_headers.clone())
|
||||
.body(r#"{"edit":"Create","id":null,"title":"ein testlink","target":"https://github.com/enaut/pslink","code":"test","author":0,"created_at":null}"#)
|
||||
.build()
|
||||
.unwrap();
|
||||
println!("{:?}", query);
|
||||
let response = client
|
||||
.execute(query)
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
println!("List urls response:\n {:?}", response);
|
||||
|
||||
// Make sure the status codes are correct
|
||||
assert!(response.status().is_success());
|
||||
|
||||
// Make sure that the content is a success message
|
||||
let content = response.text().await.unwrap();
|
||||
println!("Content: {:?}", content);
|
||||
assert!(
|
||||
content.contains(r#""Success":"#),
|
||||
"Make sure the link creation response contains Success"
|
||||
);
|
||||
|
||||
// After inserting a link make sure the link is saved
|
||||
let query = client
|
||||
.post(&(base_url.clone() + "admin/json/list_links/"))
|
||||
.headers(custom_headers.clone())
|
||||
.body(r#"{"filter":{"Code":{"sieve":""},"Description":{"sieve":""},"Target":{"sieve":""},"Author":{"sieve":""},"Statistics":{"sieve":""}},"order":null,"amount":20}"#).build().unwrap();
|
||||
println!("{:?}", query);
|
||||
let response = client
|
||||
.execute(query)
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
println!("List urls response:\n {:?}", response);
|
||||
|
||||
// Make sure the list was retrieved and the status codes are correct
|
||||
assert!(response.status().is_success());
|
||||
|
||||
// Make sure that the content now contains the newly created link
|
||||
let content = response.text().await.unwrap();
|
||||
println!("Content: {:?}", content);
|
||||
assert!(
|
||||
content.contains(r#""target":"https://github.com/enaut/pslink","code":"test""#),
|
||||
"the new target and the new code are not in the result"
|
||||
);
|
||||
|
||||
// Create a duplicate which should fail
|
||||
let query = client
|
||||
.post(&(base_url.clone() + "admin/json/create_link/"))
|
||||
.headers(custom_headers.clone())
|
||||
.body(r#"{"edit":"Create","id":null,"title":"ein testlink","target":"https://github.com/enaut/pslink","code":"test","author":0,"created_at":null}"#)
|
||||
.build()
|
||||
.unwrap();
|
||||
println!("{:?}", query);
|
||||
let response = client
|
||||
.execute(query)
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
println!("List urls response:\n {:?}", response);
|
||||
|
||||
// Make sure the status codes are correct
|
||||
assert!(response.status().is_server_error());
|
||||
|
||||
// Make sure that the content is a error message
|
||||
let content = response.text().await.unwrap();
|
||||
println!("Content: {:?}", content);
|
||||
assert!(
|
||||
content.contains(r#"error"#),
|
||||
"Make sure the link creation response contains error"
|
||||
);
|
||||
|
||||
// Create a second link
|
||||
let query = client
|
||||
.post(&(base_url.clone() + "admin/json/create_link/"))
|
||||
.headers(custom_headers.clone())
|
||||
.body(r#"{"edit":"Create","id":null,"title":"ein second testlink","target":"https://crates.io/crates/pslink","code":"x","author":0,"created_at":null}"#)
|
||||
.build()
|
||||
.unwrap();
|
||||
println!("{:?}", query);
|
||||
let response = client
|
||||
.execute(query)
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
println!("List urls response:\n {:?}", response);
|
||||
|
||||
// Make sure the status codes are correct
|
||||
assert!(response.status().is_success());
|
||||
|
||||
// Make sure that the content is a success message
|
||||
let content = response.text().await.unwrap();
|
||||
println!("Content: {:?}", content);
|
||||
assert!(
|
||||
content.contains(r#""Success":"#),
|
||||
"Make sure the link creation response contains Success"
|
||||
);
|
||||
|
||||
// After inserting a link make sure the link is saved
|
||||
let query = client
|
||||
.post(&(base_url.clone() + "admin/json/list_links/"))
|
||||
.headers(custom_headers.clone())
|
||||
.body(r#"{"filter":{"Code":{"sieve":""},"Description":{"sieve":""},"Target":{"sieve":""},"Author":{"sieve":""},"Statistics":{"sieve":""}},"order":null,"amount":20}"#).build().unwrap();
|
||||
println!("{:?}", query);
|
||||
let response = client
|
||||
.execute(query)
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
println!("List urls response:\n {:?}", response);
|
||||
|
||||
// Make sure the list was retrieved and the status codes are correct
|
||||
assert!(response.status().is_success());
|
||||
|
||||
// Make sure that the content now contains the newly created link
|
||||
let content = response.text().await.unwrap();
|
||||
println!("Content: {:?}", content);
|
||||
assert!(
|
||||
content.contains(r#""target":"https://crates.io/crates/pslink","code":"x""#),
|
||||
"the new target and the new code are not in the result"
|
||||
);
|
||||
assert!(
|
||||
content.contains(r#""target":"https://github.com/enaut/pslink","code":"test""#),
|
||||
"the new target and the new code are not in the result"
|
||||
);
|
||||
|
||||
// After inserting two links make sure the filters work (searching for a description containing se)
|
||||
let query = client
|
||||
.post(&(base_url.clone() + "admin/json/list_links/"))
|
||||
.headers(custom_headers.clone())
|
||||
.body(r#"{"filter":{"Code":{"sieve":""},"Description":{"sieve":"se"},"Target":{"sieve":""},"Author":{"sieve":""},"Statistics":{"sieve":""}},"order":null,"amount":20}"#).build().unwrap();
|
||||
println!("{:?}", query);
|
||||
let response = client
|
||||
.execute(query)
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
println!("List urls response:\n {:?}", response);
|
||||
|
||||
// Make sure the list was retrieved and the status codes are correct
|
||||
assert!(response.status().is_success());
|
||||
|
||||
// Make sure that the content now contains the newly created link
|
||||
let content = response.text().await.unwrap();
|
||||
println!("Content: {:?}", content);
|
||||
// Code x should be in the result but not code test
|
||||
assert!(
|
||||
content.contains(r#""target":"https://crates.io/crates/pslink","code":"x""#),
|
||||
"the new target and the new code are not in the result"
|
||||
);
|
||||
assert!(
|
||||
!content.contains(r#""target":"https://github.com/enaut/pslink","code":"test""#),
|
||||
"the new target and the new code are not in the result"
|
||||
);
|
||||
|
||||
// Make sure we are redirected correctly.
|
||||
let response = client
|
||||
.get(&(base_url.clone() + "test"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// The basic redirection is working!
|
||||
assert!(response.status().is_redirection());
|
||||
let location = response.headers().get("location").unwrap();
|
||||
assert!(location
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("https://github.com/enaut/pslink"));
|
||||
|
||||
// And for the second link - also check that casing is correctly ignored
|
||||
let response = client
|
||||
.get(&(base_url.clone() + "X"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// The basic redirection is working!
|
||||
assert!(response.status().is_redirection());
|
||||
let location = response.headers().get("location").unwrap();
|
||||
assert!(location
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("https://crates.io/crates/pslink"));
|
||||
|
||||
drop(server);
|
||||
}
|
18
shared/Cargo.toml
Normal file
18
shared/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
||||
[package]
|
||||
authors = ["Dietrich <dietrich@teilgedanken.de>"]
|
||||
categories = ["web-programming", "network-programming", "web-programming::http-server", "command-line-utilities"]
|
||||
description = "A simple webservice that allows registered users to create short links including qr-codes.\nAnyone can visit the shortened links. This is an ideal setup for small busines or for publishing papers."
|
||||
edition = "2018"
|
||||
keywords = ["url", "link", "webpage", "actix", "web"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
name = "pslink-shared"
|
||||
readme = "../pslink/README.md"
|
||||
repository = "https://github.com/enaut/pslink/"
|
||||
version = "0.4.3"
|
||||
|
||||
[dependencies]
|
||||
serde = {version="1.0", features = ["derive"]}
|
||||
chrono = {version = "0.4", features = ["serde"] }
|
||||
enum-map = {version="1", features = ["serde"]}
|
||||
strum_macros = "0.21"
|
||||
strum = "0.21"
|
1
shared/README.md
Normal file
1
shared/README.md
Normal file
@ -0,0 +1 @@
|
||||
A collection of datatypes and related functionality that is used in the wasm as well as in the pslink binary.
|
57
shared/src/apirequests/general.rs
Normal file
57
shared/src/apirequests/general.rs
Normal file
@ -0,0 +1,57 @@
|
||||
//! The more generic request datatypes
|
||||
use std::ops::Deref;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
/// Filter one column according to the containing string.
|
||||
#[derive(Clone, Deserialize, Serialize, Debug, Default)]
|
||||
pub struct Filter {
|
||||
pub sieve: String,
|
||||
}
|
||||
|
||||
impl Deref for Filter {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.sieve
|
||||
}
|
||||
}
|
||||
|
||||
/// Possible order directions
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq, Debug)]
|
||||
pub enum Ordering {
|
||||
Ascending,
|
||||
Descending,
|
||||
}
|
||||
|
||||
/// An operation on a column
|
||||
#[derive(Clone, Deserialize, Serialize, Debug)]
|
||||
pub struct Operation<T, V> {
|
||||
pub column: T,
|
||||
pub value: V,
|
||||
}
|
||||
|
||||
/// To differentiate between creating a new record and editing.
|
||||
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq)]
|
||||
pub enum EditMode {
|
||||
Create,
|
||||
Edit,
|
||||
}
|
||||
|
||||
impl Default for EditMode {
|
||||
fn default() -> Self {
|
||||
Self::Create
|
||||
}
|
||||
}
|
||||
|
||||
/// When a message is sent between client and server (like for a dialog).
|
||||
#[derive(Clone, Deserialize, Serialize, Debug)]
|
||||
pub struct Message {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Send a message on success and also one on error.
|
||||
#[derive(Clone, Deserialize, Serialize, Debug)]
|
||||
pub enum Status {
|
||||
Success(Message),
|
||||
Error(Message),
|
||||
}
|
100
shared/src/apirequests/links.rs
Normal file
100
shared/src/apirequests/links.rs
Normal file
@ -0,0 +1,100 @@
|
||||
//! types for link requesting and saving.
|
||||
|
||||
use enum_map::{Enum, EnumMap};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::datatypes::{FullLink, Link};
|
||||
|
||||
use super::general::{EditMode, Filter, Operation, Ordering};
|
||||
|
||||
/// Request a list of users respecting the filter and ordering.
|
||||
#[derive(Clone, Deserialize, Serialize, Debug)]
|
||||
pub struct LinkRequestForm {
|
||||
pub filter: EnumMap<LinkOverviewColumns, Filter>,
|
||||
pub order: Option<Operation<LinkOverviewColumns, Ordering>>,
|
||||
pub offset: usize,
|
||||
pub amount: usize,
|
||||
}
|
||||
|
||||
impl Default for LinkRequestForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
filter: EnumMap::default(),
|
||||
order: None,
|
||||
offset: 0,
|
||||
amount: 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Struct that is responsible for creating and editing links.
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LinkDelta {
|
||||
pub edit: EditMode,
|
||||
pub id: Option<i64>,
|
||||
pub title: String,
|
||||
pub target: String,
|
||||
pub code: String,
|
||||
pub author: i64,
|
||||
pub created_at: Option<chrono::NaiveDateTime>,
|
||||
}
|
||||
|
||||
impl From<Link> for LinkDelta {
|
||||
/// Automatically create a `LinkDelta` from a Link.
|
||||
fn from(l: Link) -> Self {
|
||||
Self {
|
||||
edit: EditMode::Edit,
|
||||
id: Some(l.id),
|
||||
title: l.title,
|
||||
target: l.target,
|
||||
code: l.code,
|
||||
author: l.author,
|
||||
created_at: Some(l.created_at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FullLink> for LinkDelta {
|
||||
/// Automatically create a `LinkDelta` from a `FullLink`.
|
||||
fn from(l: FullLink) -> Self {
|
||||
Self {
|
||||
edit: EditMode::Edit,
|
||||
id: Some(l.link.id),
|
||||
title: l.link.title,
|
||||
target: l.link.target,
|
||||
code: l.link.code,
|
||||
author: l.link.author,
|
||||
created_at: Some(l.link.created_at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An enumeration of the filterable columns
|
||||
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Enum)]
|
||||
pub enum LinkOverviewColumns {
|
||||
Code,
|
||||
Description,
|
||||
Target,
|
||||
Author,
|
||||
Statistics,
|
||||
}
|
||||
|
||||
/// A struct to request a qr-code from the server
|
||||
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq)]
|
||||
pub struct QrCodeRequest {
|
||||
pub link_id: String,
|
||||
pub format: QrCodeFormat,
|
||||
}
|
||||
|
||||
/// The response to a qr-request
|
||||
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq)]
|
||||
pub struct SvgQrCodeResponse {
|
||||
pub svg: String,
|
||||
}
|
||||
|
||||
/// Available formats of qr-codes
|
||||
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq)]
|
||||
pub enum QrCodeFormat {
|
||||
Svg,
|
||||
Png,
|
||||
}
|
4
shared/src/apirequests/mod.rs
Normal file
4
shared/src/apirequests/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
//! This module contains the structs for api requests.
|
||||
pub mod general;
|
||||
pub mod links;
|
||||
pub mod users;
|
106
shared/src/apirequests/users.rs
Normal file
106
shared/src/apirequests/users.rs
Normal file
@ -0,0 +1,106 @@
|
||||
//! Types for user requesting and saving
|
||||
use enum_map::{Enum, EnumMap};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::datatypes::User;
|
||||
|
||||
use super::general::{EditMode, Filter, Operation, Ordering};
|
||||
|
||||
/// Request an ordered and filtered list of users from the server.
|
||||
#[derive(Clone, Deserialize, Serialize, Debug)]
|
||||
pub struct UserRequestForm {
|
||||
// The filters up to one for each column
|
||||
pub filter: EnumMap<UserOverviewColumns, Filter>,
|
||||
// Order According to this column
|
||||
pub order: Option<Operation<UserOverviewColumns, Ordering>>,
|
||||
// Return a maximum of `amount` results
|
||||
pub amount: usize,
|
||||
}
|
||||
|
||||
impl Default for UserRequestForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
filter: EnumMap::default(),
|
||||
order: None,
|
||||
amount: 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data to login.
|
||||
#[derive(Debug, Deserialize, Default, Serialize, Clone)]
|
||||
pub struct LoginUser {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// The Struct that is responsible for creating and editing users.
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserDelta {
|
||||
pub edit: EditMode,
|
||||
pub id: Option<i64>,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: Option<String>,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
impl From<User> for UserDelta {
|
||||
/// Automatically create a `UserDelta` from a User.
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
edit: EditMode::Edit,
|
||||
id: Some(u.id),
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
password: None,
|
||||
role: u.role,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The columns in the user view table. The table can be ordered according to these.
|
||||
#[allow(clippy::use_self)]
|
||||
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Enum)]
|
||||
pub enum UserOverviewColumns {
|
||||
Id,
|
||||
Email,
|
||||
Username,
|
||||
}
|
||||
|
||||
/// The possible roles a user could have. They are stored as i64 in the database
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Copy)]
|
||||
pub enum Role {
|
||||
NotAuthenticated,
|
||||
Disabled,
|
||||
Regular,
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
#[must_use]
|
||||
pub const fn convert(i: i64) -> Self {
|
||||
match i {
|
||||
0 => Self::Disabled,
|
||||
1 => Self::Regular,
|
||||
2 => Self::Admin,
|
||||
_ => Self::NotAuthenticated,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn to_i64(self) -> i64 {
|
||||
match self {
|
||||
Role::NotAuthenticated => 3,
|
||||
Role::Disabled => 0,
|
||||
Role::Regular => 1,
|
||||
Role::Admin => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Role {
|
||||
fn default() -> Self {
|
||||
Self::Regular
|
||||
}
|
||||
}
|
141
shared/src/datatypes.rs
Normal file
141
shared/src/datatypes.rs
Normal file
@ -0,0 +1,141 @@
|
||||
//! The more generic datatypes used in pslink
|
||||
use std::ops::Deref;
|
||||
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use strum_macros::{AsRefStr, EnumIter, EnumString, ToString};
|
||||
|
||||
use crate::apirequests::users::Role;
|
||||
/// A generic list returntype containing the User and a Vec containing e.g. Links or Users
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct ListWithOwner<T> {
|
||||
pub user: User,
|
||||
pub list: Vec<T>,
|
||||
}
|
||||
|
||||
/// A link together with its author and its click-count.
|
||||
#[derive(Clone, Deserialize, Serialize, Debug)]
|
||||
pub struct FullLink {
|
||||
pub link: Link,
|
||||
pub user: User,
|
||||
pub clicks: Count,
|
||||
}
|
||||
|
||||
/// A User of the pslink service
|
||||
#[derive(PartialEq, Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: Secret,
|
||||
pub role: Role,
|
||||
pub language: Lang,
|
||||
}
|
||||
|
||||
/// A short url of the link service
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Link {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub target: String,
|
||||
pub code: String,
|
||||
pub author: i64,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
/// When statistics are counted
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Count {
|
||||
pub number: i32,
|
||||
}
|
||||
|
||||
/// Everytime a shor url is clicked record it for statistical evaluation.
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Click {
|
||||
pub id: i64,
|
||||
pub link: i64,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
/// The Password: Display, Debug and serialize do not include the Password to prevent leaks of sensible information in logs or similar.
|
||||
#[derive(PartialEq, Clone, Deserialize)]
|
||||
#[serde(from = "String")]
|
||||
pub struct Secret {
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
impl From<String> for Secret {
|
||||
fn from(_: String) -> Self {
|
||||
Self { secret: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Secret {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str("*****SECRET*****")
|
||||
}
|
||||
}
|
||||
|
||||
impl Secret {
|
||||
#[must_use]
|
||||
pub const fn new(secret: String) -> Self {
|
||||
Self {
|
||||
secret: Some(secret),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("*****SECRET*****")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Secret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("*****SECRET*****")
|
||||
}
|
||||
}
|
||||
|
||||
/// Loadable is a type that has not been loaded but will be in future. It can be used to indicate the loading process.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum Loadable<T> {
|
||||
Data(Option<T>),
|
||||
Loading,
|
||||
}
|
||||
|
||||
impl<T> Deref for Loadable<T> {
|
||||
type Target = Option<T>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
match self {
|
||||
Loadable::Data(t) => t,
|
||||
Loadable::Loading => &None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An `enum` containing the available languages.
|
||||
/// To add an additional language add it to this enum aswell as an appropriate file into the locales folder.
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(
|
||||
Debug,
|
||||
Copy,
|
||||
Clone,
|
||||
EnumIter,
|
||||
EnumString,
|
||||
ToString,
|
||||
AsRefStr,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
)]
|
||||
pub enum Lang {
|
||||
#[strum(serialize = "en-US", serialize = "en", serialize = "enUS")]
|
||||
EnUS,
|
||||
#[strum(serialize = "de-DE", serialize = "de", serialize = "deDE")]
|
||||
DeDE,
|
||||
}
|
2
shared/src/lib.rs
Normal file
2
shared/src/lib.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod apirequests;
|
||||
pub mod datatypes;
|
@ -1,70 +0,0 @@
|
||||
extern crate sqlx;
|
||||
|
||||
mod cli;
|
||||
|
||||
use pslink::ServerConfig;
|
||||
|
||||
use tracing::instrument;
|
||||
use tracing::{subscriber::set_global_default, Subscriber};
|
||||
use tracing_opentelemetry::OpenTelemetryLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry};
|
||||
|
||||
/// Compose multiple layers into a `tracing`'s subscriber.
|
||||
#[must_use]
|
||||
pub fn get_subscriber(name: &str, env_filter: &str) -> impl Subscriber + Send + Sync {
|
||||
let env_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter));
|
||||
// Create a jaeger exporter pipeline for a `trace_demo` service.
|
||||
let tracer = opentelemetry_jaeger::new_pipeline()
|
||||
.with_service_name(name)
|
||||
.install_simple()
|
||||
.expect("Error initializing Jaeger exporter");
|
||||
let formatting_layer = tracing_subscriber::fmt::layer().with_target(false);
|
||||
|
||||
// Create a layer with the configured tracer
|
||||
let otel_layer = OpenTelemetryLayer::new(tracer);
|
||||
|
||||
// Use the tracing subscriber `Registry`, or any other subscriber
|
||||
// that impls `LookupSpan`
|
||||
Registry::default()
|
||||
.with(otel_layer)
|
||||
.with(env_filter)
|
||||
.with(formatting_layer)
|
||||
}
|
||||
|
||||
/// Register a subscriber as global default to process span data.
|
||||
///
|
||||
/// It should only be called once!
|
||||
pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
|
||||
set_global_default(subscriber).expect("Failed to set subscriber");
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::result::Result<(), std::io::Error> {
|
||||
let subscriber = get_subscriber("fhs.li", "info");
|
||||
init_subscriber(subscriber);
|
||||
|
||||
match cli::setup().await {
|
||||
Ok(Some(server_config)) => {
|
||||
pslink::webservice(server_config)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
println!("{:?}", e);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
std::process::exit(0);
|
||||
})
|
||||
.expect("Failed to launch the service")
|
||||
.await
|
||||
}
|
||||
Ok(None) => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("\nError: {}", e);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct LinkForm {
|
||||
pub title: String,
|
||||
pub target: String,
|
||||
pub code: String,
|
||||
}
|
383
src/lib.rs
383
src/lib.rs
@ -1,383 +0,0 @@
|
||||
extern crate sqlx;
|
||||
|
||||
pub mod forms;
|
||||
pub mod models;
|
||||
pub mod queries;
|
||||
mod views;
|
||||
|
||||
use actix_identity::{CookieIdentityPolicy, IdentityService};
|
||||
use actix_web::HttpResponse;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use fluent_templates::{static_loader, FluentLoader};
|
||||
use qrcode::types::QrError;
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use std::{fmt::Display, path::PathBuf, str::FromStr};
|
||||
use tera::Tera;
|
||||
use thiserror::Error;
|
||||
use tracing::instrument;
|
||||
use tracing::{error, info, trace};
|
||||
use tracing_actix_web::TracingLogger;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ServerError {
|
||||
#[error("Failed to encrypt the password {0} - aborting!")]
|
||||
Argonautica(argonautica::Error),
|
||||
#[error("The database could not be used: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("The database could not be migrated: {0}")]
|
||||
DatabaseMigration(#[from] sqlx::migrate::MigrateError),
|
||||
#[error("The environment file could not be read")]
|
||||
Environment(#[from] std::env::VarError),
|
||||
#[error("The templates could not be rendered correctly: {0}")]
|
||||
Template(#[from] tera::Error),
|
||||
#[error("The qr-code could not be generated: {0}")]
|
||||
Qr(#[from] QrError),
|
||||
#[error("Some error happened during input and output: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Error: {0}")]
|
||||
User(String),
|
||||
}
|
||||
|
||||
impl From<argonautica::Error> for ServerError {
|
||||
fn from(e: argonautica::Error) -> Self {
|
||||
Self::Argonautica(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerError {
|
||||
fn render_error(title: &str, content: &str) -> String {
|
||||
format!(
|
||||
"<!DOCTYPE html>
|
||||
<html lang=\"en\">
|
||||
<head>
|
||||
<meta charset=\"utf-8\">
|
||||
<title>{0}</title>
|
||||
<meta name=\"author\" content=\"Franz Dietrich\">
|
||||
<meta http-equiv=\"robots\" content=\"[noindex|nofollow]\">
|
||||
<link rel=\"stylesheet\" href=\"/static/style.css\">
|
||||
</head>
|
||||
<body>
|
||||
<section class=\"centered\">
|
||||
<h1>{0}</h1>
|
||||
<div class=\"container\">
|
||||
{1}
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>",
|
||||
title, content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl actix_web::error::ResponseError for ServerError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
match self {
|
||||
Self::Argonautica(e) => {
|
||||
eprintln!("Argonautica Error happened: {:?}", e);
|
||||
HttpResponse::InternalServerError()
|
||||
.body("Failed to encrypt the password - Aborting!")
|
||||
}
|
||||
Self::Database(e) => {
|
||||
eprintln!("Database Error happened: {:?}", e);
|
||||
HttpResponse::InternalServerError().body(&Self::render_error(
|
||||
"Server Error",
|
||||
"Database could not be accessed! - It could be that this value already was in the database! If you are the admin look into the logs for a more detailed error.",
|
||||
))
|
||||
}
|
||||
Self::DatabaseMigration(e) => {
|
||||
eprintln!("Migration Error happened: {:?}", e);
|
||||
unimplemented!("A migration error should never be rendered")
|
||||
}
|
||||
Self::Environment(e) => {
|
||||
eprintln!("Environment Error happened: {:?}", e);
|
||||
HttpResponse::InternalServerError().body(&Self::render_error(
|
||||
"Server Error",
|
||||
"This Server is not properly configured, if you are the admin look into the installation- or update instructions!",
|
||||
))
|
||||
}
|
||||
Self::Template(e) => {
|
||||
eprintln!("Template Error happened: {:?}", e);
|
||||
HttpResponse::InternalServerError().body(&Self::render_error(
|
||||
"Server Error",
|
||||
"The templates could not be rendered.",
|
||||
))
|
||||
}
|
||||
Self::Qr(e) => {
|
||||
eprintln!("QR Error happened: {:?}", e);
|
||||
HttpResponse::InternalServerError().body(&Self::render_error(
|
||||
"Server Error",
|
||||
"Could not generate the QR-code!",
|
||||
))
|
||||
}
|
||||
Self::Io(e) => {
|
||||
eprintln!("Io Error happened: {:?}", e);
|
||||
HttpResponse::InternalServerError().body(&Self::render_error(
|
||||
"Server Error",
|
||||
"Some Files could not be read or written. If you are the admin look into the logfiles for more details.",
|
||||
))
|
||||
}
|
||||
Self::User(data) => {
|
||||
eprintln!("User Error happened: {:?}", data);
|
||||
HttpResponse::InternalServerError().body(&Self::render_error(
|
||||
"Server Error",
|
||||
&format!("An error happened: {}", data),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Protocol {
|
||||
Http,
|
||||
Https,
|
||||
}
|
||||
|
||||
impl Display for Protocol {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Http => f.write_str("http"),
|
||||
Self::Https => f.write_str("https"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Protocol {
|
||||
type Err = ServerError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"http" => Ok(Self::Http),
|
||||
"https" => Ok(Self::Https),
|
||||
_ => Err(ServerError::User("Failed to parse Protocol".to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Secret {
|
||||
secret: String,
|
||||
}
|
||||
|
||||
impl Secret {
|
||||
#[must_use]
|
||||
pub const fn new(secret: String) -> Self {
|
||||
Self { secret }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("*****SECRET*****")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Secret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("*****SECRET*****")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub secret: Secret,
|
||||
pub db: PathBuf,
|
||||
pub db_pool: Pool<Sqlite>,
|
||||
pub public_url: String,
|
||||
pub internal_ip: String,
|
||||
pub port: u32,
|
||||
pub protocol: Protocol,
|
||||
pub empty_forward_url: String,
|
||||
pub brand_name: String,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
#[must_use]
|
||||
pub fn to_env_strings(&self) -> Vec<String> {
|
||||
vec![
|
||||
format!("PSLINK_DATABASE=\"{}\"\n", self.db.display()),
|
||||
format!("PSLINK_PORT={}\n", self.port),
|
||||
format!("PSLINK_PUBLIC_URL=\"{}\"\n", self.public_url),
|
||||
format!("PSLINK_EMPTY_FORWARD_URL=\"{}\"\n", self.empty_forward_url),
|
||||
format!("PSLINK_BRAND_NAME=\"{}\"\n", self.brand_name),
|
||||
format!("PSLINK_IP=\"{}\"\n", self.internal_ip),
|
||||
format!("PSLINK_PROTOCOL=\"{}\"\n", self.protocol),
|
||||
concat!(
|
||||
"# The SECRET_KEY variable is used for password encryption.\n",
|
||||
"# If it is changed all existing passwords are invalid.\n"
|
||||
)
|
||||
.to_owned(),
|
||||
format!("PSLINK_SECRET=\"{}\"\n", self.secret.secret),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
|
||||
|
||||
static_loader! {
|
||||
static LOCALES = {
|
||||
locales: "./locales",
|
||||
fallback_language: "en",
|
||||
};
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
fn build_tera() -> Result<Tera, ServerError> {
|
||||
let mut tera = Tera::default();
|
||||
|
||||
// Add translation support
|
||||
tera.register_function("fluent", FluentLoader::new(&*LOCALES));
|
||||
|
||||
tera.add_raw_templates(vec![
|
||||
("admin.html", include_str!("../templates/admin.html")),
|
||||
("base.html", include_str!("../templates/base.html")),
|
||||
(
|
||||
"edit_link.html",
|
||||
include_str!("../templates/edit_link.html"),
|
||||
),
|
||||
(
|
||||
"edit_profile.html",
|
||||
include_str!("../templates/edit_profile.html"),
|
||||
),
|
||||
(
|
||||
"index_users.html",
|
||||
include_str!("../templates/index_users.html"),
|
||||
),
|
||||
("index.html", include_str!("../templates/index.html")),
|
||||
("login.html", include_str!("../templates/login.html")),
|
||||
(
|
||||
"not_found.html",
|
||||
include_str!("../templates/not_found.html"),
|
||||
),
|
||||
("signup.html", include_str!("../templates/signup.html")),
|
||||
(
|
||||
"submission.html",
|
||||
include_str!("../templates/submission.html"),
|
||||
),
|
||||
(
|
||||
"view_link.html",
|
||||
include_str!("../templates/view_link.html"),
|
||||
),
|
||||
(
|
||||
"view_profile.html",
|
||||
include_str!("../templates/view_profile.html"),
|
||||
),
|
||||
])?;
|
||||
Ok(tera)
|
||||
}
|
||||
|
||||
/// Launch the pslink-webservice
|
||||
///
|
||||
/// # Errors
|
||||
/// This produces a [`ServerError`] if:
|
||||
/// * Tera failed to build its templates
|
||||
/// * The server failed to bind to the designated port.
|
||||
#[allow(clippy::future_not_send, clippy::too_many_lines)]
|
||||
pub async fn webservice(
|
||||
server_config: ServerConfig,
|
||||
) -> Result<actix_web::dev::Server, std::io::Error> {
|
||||
let host_port = format!("{}:{}", &server_config.internal_ip, &server_config.port);
|
||||
info!(
|
||||
"Running on: {}://{}/admin/login/",
|
||||
&server_config.protocol, host_port
|
||||
);
|
||||
info!(
|
||||
"If the public url is set up correctly it should be accessible via: {}://{}/admin/login/",
|
||||
&server_config.protocol, &server_config.public_url
|
||||
);
|
||||
let tera = build_tera().expect("Failed to build Templates");
|
||||
trace!("The tera templates are ready");
|
||||
|
||||
let server = HttpServer::new(move || {
|
||||
let generated = generate();
|
||||
App::new()
|
||||
.data(server_config.clone())
|
||||
.wrap(TracingLogger)
|
||||
.wrap(IdentityService::new(
|
||||
CookieIdentityPolicy::new(&[0; 32])
|
||||
.name("auth-cookie")
|
||||
.secure(false),
|
||||
))
|
||||
.data(tera.clone())
|
||||
.service(actix_web_static_files::ResourceFiles::new(
|
||||
"/static", generated,
|
||||
))
|
||||
// directly go to the main page set the target with the environment variable.
|
||||
.route("/", web::get().to(views::redirect_empty))
|
||||
// admin block
|
||||
.service(
|
||||
web::scope("/admin")
|
||||
// list all links
|
||||
.route("/index/", web::get().to(views::index))
|
||||
// invite users
|
||||
.route("/signup/", web::get().to(views::signup))
|
||||
.route("/signup/", web::post().to(views::process_signup))
|
||||
// logout
|
||||
.route("/logout/", web::to(views::logout))
|
||||
// submit a new url for shortening
|
||||
.route("/submit/", web::get().to(views::create_link))
|
||||
.route("/submit/", web::post().to(views::process_link_creation))
|
||||
// view an existing url
|
||||
.service(
|
||||
web::scope("/view")
|
||||
.service(
|
||||
web::scope("/link")
|
||||
.route("/{redirect_id}", web::get().to(views::view_link))
|
||||
.route("/", web::get().to(views::view_link_empty)),
|
||||
)
|
||||
.service(
|
||||
web::scope("/profile")
|
||||
.route("/{user_id}", web::get().to(views::view_profile)),
|
||||
)
|
||||
.route("/users/", web::get().to(views::index_users)),
|
||||
)
|
||||
.service(
|
||||
web::scope("/edit")
|
||||
.service(
|
||||
web::scope("/link")
|
||||
.route("/{redirect_id}", web::get().to(views::edit_link))
|
||||
.route(
|
||||
"/{redirect_id}",
|
||||
web::post().to(views::process_link_edit),
|
||||
),
|
||||
)
|
||||
.service(
|
||||
web::scope("/profile")
|
||||
.route("/{user_id}", web::get().to(views::edit_profile))
|
||||
.route(
|
||||
"/{user_id}",
|
||||
web::post().to(views::process_edit_profile),
|
||||
),
|
||||
)
|
||||
.route("/set_admin/{user_id}", web::get().to(views::toggle_admin))
|
||||
.route(
|
||||
"/set_language/{language}",
|
||||
web::get().to(views::set_language),
|
||||
),
|
||||
)
|
||||
.service(
|
||||
web::scope("/delete").service(
|
||||
web::scope("/link")
|
||||
.route("/{redirect_id}", web::get().to(views::process_link_delete)),
|
||||
),
|
||||
)
|
||||
.service(
|
||||
web::scope("/download")
|
||||
.route("/png/{redirect_id}", web::get().to(views::download_png)),
|
||||
)
|
||||
// login to the admin area
|
||||
.route("/login/", web::get().to(views::login))
|
||||
.route("/login/", web::post().to(views::process_login)),
|
||||
)
|
||||
// redirect to the url hidden behind the code
|
||||
.route("/{redirect_id}", web::get().to(views::redirect))
|
||||
})
|
||||
.bind(host_port)
|
||||
.map_err(|e| {
|
||||
error!("Failed to bind to port!");
|
||||
e
|
||||
})?
|
||||
.run();
|
||||
Ok(server)
|
||||
}
|
319
src/models.rs
319
src/models.rs
@ -1,319 +0,0 @@
|
||||
use crate::{forms::LinkForm, ServerConfig, ServerError};
|
||||
|
||||
use argonautica::Hasher;
|
||||
use dotenv::dotenv;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(PartialEq, Serialize, Clone, Debug)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub role: i64,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub(crate) async fn get_user(
|
||||
id: i64,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Self, ServerError> {
|
||||
let user = sqlx::query_as!(Self, "Select * from users where id = ? ", id)
|
||||
.fetch_one(&server_config.db_pool)
|
||||
.await;
|
||||
user.map_err(ServerError::Database)
|
||||
}
|
||||
|
||||
/// get a user by its username
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the user does not exist or the database cannot be acessed.
|
||||
pub async fn get_user_by_name(
|
||||
name: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Self, ServerError> {
|
||||
let user = sqlx::query_as!(Self, "Select * from users where username = ? ", name)
|
||||
.fetch_one(&server_config.db_pool)
|
||||
.await;
|
||||
user.map_err(ServerError::Database)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_all_users(
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Vec<Self>, ServerError> {
|
||||
let user = sqlx::query_as!(Self, "Select * from users")
|
||||
.fetch_all(&server_config.db_pool)
|
||||
.await;
|
||||
user.map_err(ServerError::Database)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_user(
|
||||
&self,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<(), ServerError> {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET
|
||||
username = ?,
|
||||
email = ?,
|
||||
password = ?,
|
||||
role = ? where id = ?",
|
||||
self.username,
|
||||
self.email,
|
||||
self.password,
|
||||
self.role,
|
||||
self.id
|
||||
)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
/// Change an admin user to normal user and a normal user to admin
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed. (the user should exist)
|
||||
pub async fn toggle_admin(self, server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
let new_role = 2 - (self.role + 1) % 2;
|
||||
sqlx::query!("UPDATE users SET role = ? where id = ?", new_role, self.id)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_language(
|
||||
self,
|
||||
server_config: &ServerConfig,
|
||||
new_language: &str,
|
||||
) -> Result<(), ServerError> {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET language = ? where id = ?",
|
||||
new_language,
|
||||
self.id
|
||||
)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Count the admin accounts
|
||||
///
|
||||
/// this is usefull for determining if any admins exist at all.
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed.
|
||||
pub async fn count_admins(server_config: &ServerConfig) -> Result<Count, ServerError> {
|
||||
let num = sqlx::query_as!(Count, "select count(*) as number from users where role = 2")
|
||||
.fetch_one(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(num)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NewUser {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl NewUser {
|
||||
/// Create a new user that can then be inserted in the database
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the password could not be encrypted.
|
||||
pub fn new(
|
||||
username: String,
|
||||
email: String,
|
||||
password: &str,
|
||||
config: &ServerConfig,
|
||||
) -> Result<Self, ServerError> {
|
||||
let hash = Self::hash_password(password, config)?;
|
||||
|
||||
Ok(Self {
|
||||
username,
|
||||
email,
|
||||
password: hash,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn hash_password(
|
||||
password: &str,
|
||||
config: &ServerConfig,
|
||||
) -> Result<String, ServerError> {
|
||||
dotenv().ok();
|
||||
|
||||
let secret = &config.secret;
|
||||
|
||||
let hash = Hasher::default()
|
||||
.with_password(password)
|
||||
.with_secret_key(&secret.secret)
|
||||
.hash()?;
|
||||
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Insert this user into the database
|
||||
///
|
||||
/// # Errors
|
||||
/// fails with [`ServerError`] if the database cannot be acessed.
|
||||
pub async fn insert_user(&self, server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
sqlx::query!(
|
||||
"Insert into users (
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
role) VALUES (?,?,?,1)",
|
||||
self.username,
|
||||
self.email,
|
||||
self.password,
|
||||
)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginUser {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Link {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub target: String,
|
||||
pub code: String,
|
||||
pub author: i64,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
pub(crate) async fn get_link_by_code(
|
||||
code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Self, ServerError> {
|
||||
let link = sqlx::query_as!(Self, "Select * from links where code = ? ", code)
|
||||
.fetch_one(&server_config.db_pool)
|
||||
.await;
|
||||
tracing::info!("Found link: {:?}", &link);
|
||||
link.map_err(ServerError::Database)
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_link_by_code(
|
||||
code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<(), ServerError> {
|
||||
sqlx::query!("DELETE from links where code = ? ", code)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) async fn update_link(
|
||||
&self,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<(), ServerError> {
|
||||
sqlx::query!(
|
||||
"UPDATE links SET
|
||||
title = ?,
|
||||
target = ?,
|
||||
code = ?,
|
||||
author = ?,
|
||||
created_at = ? where id = ?",
|
||||
self.title,
|
||||
self.target,
|
||||
self.code,
|
||||
self.author,
|
||||
self.created_at,
|
||||
self.id
|
||||
)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct NewLink {
|
||||
pub title: String,
|
||||
pub target: String,
|
||||
pub code: String,
|
||||
pub author: i64,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
impl NewLink {
|
||||
pub(crate) fn from_link_form(form: LinkForm, uid: i64) -> Self {
|
||||
Self {
|
||||
title: form.title,
|
||||
target: form.target,
|
||||
code: form.code,
|
||||
author: uid,
|
||||
created_at: chrono::Local::now().naive_utc(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn insert(self, server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
sqlx::query!(
|
||||
"Insert into links (
|
||||
title,
|
||||
target,
|
||||
code,
|
||||
author,
|
||||
created_at) VALUES (?,?,?,?,?)",
|
||||
self.title,
|
||||
self.target,
|
||||
self.code,
|
||||
self.author,
|
||||
self.created_at,
|
||||
)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Click {
|
||||
pub id: i64,
|
||||
pub link: i64,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NewClick {
|
||||
pub link: i64,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
impl NewClick {
|
||||
#[must_use]
|
||||
pub fn new(link_id: i64) -> Self {
|
||||
Self {
|
||||
link: link_id,
|
||||
created_at: chrono::Local::now().naive_utc(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn insert_click(
|
||||
self,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<(), ServerError> {
|
||||
sqlx::query!(
|
||||
"Insert into clicks (
|
||||
link,
|
||||
created_at) VALUES (?,?)",
|
||||
self.link,
|
||||
self.created_at,
|
||||
)
|
||||
.execute(&server_config.db_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Count {
|
||||
pub number: i32,
|
||||
}
|
482
src/queries.rs
482
src/queries.rs
@ -1,482 +0,0 @@
|
||||
use actix_identity::Identity;
|
||||
use actix_web::web;
|
||||
use serde::Serialize;
|
||||
use tracing::info;
|
||||
|
||||
use super::models::{Count, Link, NewUser, User};
|
||||
use crate::{
|
||||
forms::LinkForm,
|
||||
models::{NewClick, NewLink},
|
||||
ServerConfig, ServerError,
|
||||
};
|
||||
|
||||
/// The possible roles a user could have.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Role {
|
||||
NotAuthenticated,
|
||||
Disabled,
|
||||
Regular { user: User },
|
||||
Admin { user: User },
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// Determin if the user is admin or the given user id is his own. This is used for things where users can edit or view their own entries, whereas admins can do so for all entries.
|
||||
const fn admin_or_self(&self, id: i64) -> bool {
|
||||
match self {
|
||||
Self::Admin { .. } => true,
|
||||
Self::Regular { user } => user.id == id,
|
||||
Self::NotAuthenticated | Self::Disabled => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// queries the user matching the given [`actix_identity::Identity`] and determins its authentication and permission level. Returns a [`Role`] containing the user if it is authenticated.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails only if there are issues using the database.
|
||||
pub async fn authenticate(
|
||||
id: &Identity,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Role, ServerError> {
|
||||
if let Some(username) = id.identity() {
|
||||
let user = User::get_user_by_name(&username, server_config).await?;
|
||||
|
||||
return Ok(match user.role {
|
||||
0 => Role::Disabled,
|
||||
1 => Role::Regular { user },
|
||||
2 => Role::Admin { user },
|
||||
_ => Role::NotAuthenticated,
|
||||
});
|
||||
}
|
||||
Ok(Role::NotAuthenticated)
|
||||
}
|
||||
|
||||
/// A generic list returntype containing the User and a Vec containing e.g. Links or Users
|
||||
pub struct List<T> {
|
||||
pub user: User,
|
||||
pub list: Vec<T>,
|
||||
}
|
||||
|
||||
/// A link together with its author and its click-count.
|
||||
#[derive(Serialize)]
|
||||
pub struct FullLink {
|
||||
link: Link,
|
||||
user: User,
|
||||
clicks: Count,
|
||||
}
|
||||
|
||||
/// Returns a List of `FullLink` meaning `Links` enriched by their author and statistics. This returns all links if the user is either Admin or Regular user.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails.
|
||||
pub async fn list_all_allowed(
|
||||
id: &Identity,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<List<FullLink>, ServerError> {
|
||||
use crate::sqlx::Row;
|
||||
match authenticate(id, server_config).await? {
|
||||
Role::Admin { user } | Role::Regular { user } => {
|
||||
let links = sqlx::query(
|
||||
"select
|
||||
links.id as lid,
|
||||
links.title as ltitle,
|
||||
links.target as ltarget,
|
||||
links.code as lcode,
|
||||
links.author as lauthor,
|
||||
links.created_at as ldate,
|
||||
users.id as usid,
|
||||
users.username as usern,
|
||||
users.email as uemail,
|
||||
users.role as urole,
|
||||
users.language as ulang,
|
||||
count(clicks.id) as counter
|
||||
from
|
||||
links
|
||||
join users on links.author = users.id
|
||||
left join clicks on links.id = clicks.link
|
||||
group by
|
||||
links.id",
|
||||
)
|
||||
.fetch_all(&server_config.db_pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|v| FullLink {
|
||||
link: Link {
|
||||
id: v.get("lid"),
|
||||
title: v.get("ltitle"),
|
||||
target: v.get("ltarget"),
|
||||
code: v.get("lcode"),
|
||||
author: v.get("lauthor"),
|
||||
created_at: v.get("ldate"),
|
||||
},
|
||||
user: User {
|
||||
id: v.get("usid"),
|
||||
username: v.get("usern"),
|
||||
email: v.get("uemail"),
|
||||
password: "invalid".to_owned(),
|
||||
role: v.get("urole"),
|
||||
language: v.get("ulang"),
|
||||
},
|
||||
clicks: Count {
|
||||
number: v.get("counter"), /* count is never None */
|
||||
},
|
||||
});
|
||||
// show all links
|
||||
let all_links: Vec<FullLink> = links.collect();
|
||||
Ok(List {
|
||||
user,
|
||||
list: all_links,
|
||||
})
|
||||
}
|
||||
Role::Disabled | Role::NotAuthenticated => Err(ServerError::User("Not allowed".to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Only admins can list all users
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
pub async fn list_users(
|
||||
id: &Identity,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<List<User>, ServerError> {
|
||||
match authenticate(id, server_config).await? {
|
||||
Role::Admin { user } => {
|
||||
let all_users: Vec<User> = User::get_all_users(server_config).await?;
|
||||
Ok(List {
|
||||
user,
|
||||
list: all_users,
|
||||
})
|
||||
}
|
||||
_ => Err(ServerError::User(
|
||||
"Administrator permissions required".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic returntype containing the User and a single item
|
||||
pub struct Item<T> {
|
||||
pub user: User,
|
||||
pub item: T,
|
||||
}
|
||||
|
||||
/// Get a user if permissions are accordingly
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
#[allow(clippy::clippy::missing_panics_doc)]
|
||||
pub async fn get_user(
|
||||
id: &Identity,
|
||||
user_id: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<User>, ServerError> {
|
||||
if let Ok(uid) = user_id.parse::<i64>() {
|
||||
info!("Getting user {}", uid);
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
if auth.admin_or_self(uid) {
|
||||
match auth {
|
||||
Role::Admin { user } | Role::Regular { user } => {
|
||||
let viewed_user = User::get_user(uid as i64, server_config).await?;
|
||||
Ok(Item {
|
||||
user,
|
||||
item: viewed_user,
|
||||
})
|
||||
}
|
||||
Role::Disabled | Role::NotAuthenticated => {
|
||||
unreachable!("should already be unreachable because of `admin_or_self`")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(ServerError::User("Permission Denied".to_owned()))
|
||||
}
|
||||
} else {
|
||||
Err(ServerError::User("Permission Denied".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a user **without permission checks** (needed for login)
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails.
|
||||
pub async fn get_user_by_name(
|
||||
username: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<User, ServerError> {
|
||||
let user = User::get_user_by_name(username, server_config).await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Create a new user and save it to the database
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the user already exists.
|
||||
pub async fn create_user(
|
||||
id: &Identity,
|
||||
data: &web::Form<NewUser>,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<User>, ServerError> {
|
||||
info!("Creating a User: {:?}", &data);
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
match auth {
|
||||
Role::Admin { user } => {
|
||||
let new_user = NewUser::new(
|
||||
data.username.clone(),
|
||||
data.email.clone(),
|
||||
&data.password,
|
||||
server_config,
|
||||
)?;
|
||||
|
||||
new_user.insert_user(server_config).await?;
|
||||
|
||||
// querry the new user
|
||||
let new_user = get_user_by_name(&data.username, server_config).await?;
|
||||
Ok(Item {
|
||||
user,
|
||||
item: new_user,
|
||||
})
|
||||
}
|
||||
Role::Regular { .. } | Role::Disabled | Role::NotAuthenticated => {
|
||||
Err(ServerError::User("Permission denied!".to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Take a [`actix_web::web::Form<NewUser>`] and update the corresponding entry in the database.
|
||||
/// The password is only updated if a new password of at least 4 characters is provided.
|
||||
/// The `user_id` is never changed.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions, or the given data is malformed.
|
||||
#[allow(clippy::clippy::missing_panics_doc)]
|
||||
pub async fn update_user(
|
||||
id: &Identity,
|
||||
user_id: &str,
|
||||
server_config: &ServerConfig,
|
||||
data: &web::Form<NewUser>,
|
||||
) -> Result<Item<User>, ServerError> {
|
||||
if let Ok(uid) = user_id.parse::<i64>() {
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
let unmodified_user = User::get_user(uid, server_config).await?;
|
||||
if auth.admin_or_self(uid) {
|
||||
match auth {
|
||||
Role::Admin { .. } | Role::Regular { .. } => {
|
||||
info!("Updating userinfo: ");
|
||||
let password = if data.password.len() > 3 {
|
||||
NewUser::hash_password(&data.password, server_config)?
|
||||
} else {
|
||||
unmodified_user.password
|
||||
};
|
||||
let new_user = User {
|
||||
id: uid,
|
||||
username: data.username.clone(),
|
||||
email: data.email.clone(),
|
||||
password,
|
||||
role: unmodified_user.role,
|
||||
language: unmodified_user.language,
|
||||
};
|
||||
new_user.update_user(server_config).await?;
|
||||
let changed_user = User::get_user(uid, server_config).await?;
|
||||
Ok(Item {
|
||||
user: changed_user.clone(),
|
||||
item: changed_user,
|
||||
})
|
||||
}
|
||||
Role::NotAuthenticated | Role::Disabled => {
|
||||
unreachable!("Should be unreachable because of the `admin_or_self`")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(ServerError::User("Not a valid UID".to_owned()))
|
||||
}
|
||||
} else {
|
||||
Err(ServerError::User("Permission denied".to_owned()))
|
||||
}
|
||||
}
|
||||
/// Demote an admin user to a normal user or promote a normal user to admin privileges.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the user does not exist.
|
||||
pub async fn toggle_admin(
|
||||
id: &Identity,
|
||||
user_id: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<User>, ServerError> {
|
||||
if let Ok(uid) = user_id.parse::<i64>() {
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
match auth {
|
||||
Role::Admin { .. } => {
|
||||
info!("Changing administrator priviledges: ");
|
||||
|
||||
let unchanged_user = User::get_user(uid, server_config).await?;
|
||||
|
||||
let old = unchanged_user.role;
|
||||
unchanged_user.toggle_admin(server_config).await?;
|
||||
|
||||
info!("Toggling role: old was {}", old);
|
||||
|
||||
let changed_user = User::get_user(uid, server_config).await?;
|
||||
info!("Toggled role: new is {}", changed_user.role);
|
||||
Ok(Item {
|
||||
user: changed_user.clone(),
|
||||
item: changed_user,
|
||||
})
|
||||
}
|
||||
Role::Regular { .. } | Role::NotAuthenticated | Role::Disabled => {
|
||||
Err(ServerError::User("Permission denied".to_owned()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(ServerError::User("Permission denied".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the language of a given user
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the language given is invalid.
|
||||
pub async fn set_language(
|
||||
id: &Identity,
|
||||
lang_code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<(), ServerError> {
|
||||
match lang_code {
|
||||
"de" | "en" => match authenticate(id, server_config).await? {
|
||||
Role::Admin { user } | Role::Regular { user } => {
|
||||
user.set_language(server_config, lang_code).await
|
||||
}
|
||||
Role::Disabled | Role::NotAuthenticated => {
|
||||
Err(ServerError::User("Not Allowed".to_owned()))
|
||||
}
|
||||
},
|
||||
_ => Err(ServerError::User(
|
||||
"This language is not supported!".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get one link if permissions are accordingly.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
pub async fn get_link(
|
||||
id: &Identity,
|
||||
link_code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<Link>, ServerError> {
|
||||
match authenticate(id, server_config).await? {
|
||||
Role::Admin { user } | Role::Regular { user } => {
|
||||
let link = Link::get_link_by_code(link_code, server_config).await?;
|
||||
Ok(Item { user, item: link })
|
||||
}
|
||||
Role::Disabled | Role::NotAuthenticated => Err(ServerError::User("Not Allowed".to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get link **without authentication**
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails.
|
||||
pub async fn get_link_simple(
|
||||
link_code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Link, ServerError> {
|
||||
info!("Getting link for {:?}", link_code);
|
||||
|
||||
let link = Link::get_link_by_code(link_code, server_config).await?;
|
||||
info!("Foun d link for {:?}", link);
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Click on a link
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails.
|
||||
pub async fn click_link(link_id: i64, server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
info!("Clicking on {:?}", link_id);
|
||||
let new_click = NewClick::new(link_id);
|
||||
new_click.insert_click(server_config).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a link
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
pub async fn delete_link(
|
||||
id: &Identity,
|
||||
link_code: &str,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<(), ServerError> {
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
let link = get_link_simple(link_code, server_config).await?;
|
||||
if auth.admin_or_self(link.author) {
|
||||
Link::delete_link_by_code(link_code, server_config).await?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ServerError::User("Permission denied!".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a link if the user is admin or it is its own link.
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
pub async fn update_link(
|
||||
id: &Identity,
|
||||
link_code: &str,
|
||||
data: web::Form<LinkForm>,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<Link>, ServerError> {
|
||||
info!("Changing link to: {:?} {:?}", &data, &link_code);
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
match auth {
|
||||
Role::Admin { .. } | Role::Regular { .. } => {
|
||||
let query = get_link(id, link_code, server_config).await?;
|
||||
if auth.admin_or_self(query.item.author) {
|
||||
let mut link = query.item;
|
||||
let LinkForm {
|
||||
title,
|
||||
target,
|
||||
code,
|
||||
} = data.into_inner();
|
||||
link.code = code.clone();
|
||||
link.target = target;
|
||||
link.title = title;
|
||||
link.update_link(server_config).await?;
|
||||
get_link(id, &code, server_config).await
|
||||
} else {
|
||||
Err(ServerError::User("Not Allowed".to_owned()))
|
||||
}
|
||||
}
|
||||
Role::Disabled | Role::NotAuthenticated => Err(ServerError::User("Not Allowed".to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new link
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
pub async fn create_link(
|
||||
id: &Identity,
|
||||
data: web::Form<LinkForm>,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Item<Link>, ServerError> {
|
||||
let auth = authenticate(id, server_config).await?;
|
||||
match auth {
|
||||
Role::Admin { user } | Role::Regular { user } => {
|
||||
let code = data.code.clone();
|
||||
info!("Creating link for: {}", &code);
|
||||
let new_link = NewLink::from_link_form(data.into_inner(), user.id);
|
||||
info!("Creating link for: {:?}", &new_link);
|
||||
|
||||
new_link.insert(server_config).await?;
|
||||
let new_link = get_link_simple(&code, server_config).await?;
|
||||
Ok(Item {
|
||||
user,
|
||||
item: new_link,
|
||||
})
|
||||
}
|
||||
Role::Disabled | Role::NotAuthenticated => {
|
||||
Err(ServerError::User("Permission denied!".to_owned()))
|
||||
}
|
||||
}
|
||||
}
|
496
src/views.rs
496
src/views.rs
@ -1,496 +0,0 @@
|
||||
use std::time::SystemTime;
|
||||
|
||||
use actix_identity::Identity;
|
||||
use actix_web::{
|
||||
http::header::{CacheControl, CacheDirective, ContentType, Expires},
|
||||
web, HttpRequest, HttpResponse,
|
||||
};
|
||||
use argonautica::Verifier;
|
||||
use fluent_langneg::{
|
||||
convert_vec_str_to_langids_lossy, negotiate_languages, parse_accepted_languages,
|
||||
NegotiationStrategy,
|
||||
};
|
||||
use fluent_templates::LanguageIdentifier;
|
||||
use image::{DynamicImage, ImageOutputFormat, Luma};
|
||||
use qrcode::{render::svg, QrCode};
|
||||
use queries::{authenticate, Role};
|
||||
use tera::{Context, Tera};
|
||||
use tracing::{info, instrument, trace, warn};
|
||||
|
||||
use crate::forms::LinkForm;
|
||||
use crate::models::{LoginUser, NewUser};
|
||||
use crate::queries;
|
||||
use crate::ServerError;
|
||||
|
||||
#[instrument]
|
||||
fn redirect_builder(target: &str) -> HttpResponse {
|
||||
HttpResponse::SeeOther()
|
||||
.set(CacheControl(vec![
|
||||
CacheDirective::NoCache,
|
||||
CacheDirective::NoStore,
|
||||
CacheDirective::MustRevalidate,
|
||||
]))
|
||||
.set(Expires(SystemTime::now().into()))
|
||||
.set_header(actix_web::http::header::LOCATION, target)
|
||||
.body(format!("Redirect to {}", target))
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
fn detect_language(request: &HttpRequest) -> Result<String, ServerError> {
|
||||
let requested = parse_accepted_languages(
|
||||
request
|
||||
.headers()
|
||||
.get(actix_web::http::header::ACCEPT_LANGUAGE)
|
||||
.ok_or_else(|| ServerError::User("Failed to get Accept_Language".to_owned()))?
|
||||
.to_str()
|
||||
.map_err(|_| {
|
||||
ServerError::User("Failed to convert Accept_language to str".to_owned())
|
||||
})?,
|
||||
);
|
||||
let available = convert_vec_str_to_langids_lossy(&["de", "en"]);
|
||||
let default: LanguageIdentifier = "en"
|
||||
.parse()
|
||||
.map_err(|_| ServerError::User("Failed to parse a langid.".to_owned()))?;
|
||||
|
||||
let supported = negotiate_languages(
|
||||
&requested,
|
||||
&available,
|
||||
Some(&default),
|
||||
NegotiationStrategy::Filtering,
|
||||
);
|
||||
let languagecode = supported
|
||||
.get(0)
|
||||
.map_or("en".to_string(), std::string::ToString::to_string);
|
||||
Ok(languagecode)
|
||||
}
|
||||
|
||||
/// Show the list of all available links if a user is authenticated
|
||||
|
||||
#[instrument(skip(id, tera))]
|
||||
pub async fn index(
|
||||
tera: web::Data<Tera>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
if let Ok(links) = queries::list_all_allowed(&id, &config).await {
|
||||
let mut data = Context::new();
|
||||
data.insert("user", &links.user);
|
||||
data.insert("title", &format!("Links der {}", &config.brand_name,));
|
||||
data.insert("links_per_users", &links.list);
|
||||
let rendered = tera.render("index.html", &data)?;
|
||||
Ok(HttpResponse::Ok().body(rendered))
|
||||
} else {
|
||||
Ok(redirect_builder("/admin/login/"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the list of all available links if a user is authenticated
|
||||
#[instrument(skip(id, tera))]
|
||||
pub async fn index_users(
|
||||
tera: web::Data<Tera>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
if let Ok(users) = queries::list_users(&id, &config).await {
|
||||
let mut data = Context::new();
|
||||
data.insert("user", &users.user);
|
||||
data.insert("title", &format!("Benutzer der {}", &config.brand_name,));
|
||||
data.insert("users", &users.list);
|
||||
|
||||
let rendered = tera.render("index_users.html", &data)?;
|
||||
Ok(HttpResponse::Ok().body(rendered))
|
||||
} else {
|
||||
Ok(redirect_builder("/admin/login"))
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id, tera))]
|
||||
pub async fn view_link_empty(
|
||||
tera: web::Data<Tera>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
view_link(tera, config, id, web::Path::from("".to_owned())).await
|
||||
}
|
||||
|
||||
#[instrument(skip(id, tera))]
|
||||
pub async fn view_link(
|
||||
tera: web::Data<Tera>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
link_id: web::Path<String>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
if let Ok(link) = queries::get_link(&id, &link_id.0, &config).await {
|
||||
let host = config.public_url.to_string();
|
||||
let protocol = config.protocol.to_string();
|
||||
let qr = QrCode::with_error_correction_level(
|
||||
&format!("http://{}/{}", &host, &link.item.code),
|
||||
qrcode::EcLevel::L,
|
||||
)?;
|
||||
|
||||
let svg = qr
|
||||
.render()
|
||||
.min_dimensions(100, 100)
|
||||
.dark_color(svg::Color("#000000"))
|
||||
.light_color(svg::Color("#ffffff"))
|
||||
.build();
|
||||
|
||||
let mut data = Context::new();
|
||||
data.insert("user", &link.user);
|
||||
data.insert(
|
||||
"title",
|
||||
&format!("Links {} der {}", &link.item.code, &config.brand_name,),
|
||||
);
|
||||
data.insert("link", &link.item);
|
||||
data.insert("qr", &svg);
|
||||
data.insert("host", &host);
|
||||
data.insert("protocol", &protocol);
|
||||
|
||||
let rendered = tera.render("view_link.html", &data)?;
|
||||
Ok(HttpResponse::Ok().body(rendered))
|
||||
} else {
|
||||
Ok(redirect_builder("/admin/login/"))
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id, tera))]
|
||||
pub async fn view_profile(
|
||||
tera: web::Data<Tera>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
user_id: web::Path<String>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
info!("Viewing Profile!");
|
||||
if let Ok(query) = queries::get_user(&id, &user_id.0, &config).await {
|
||||
let mut data = Context::new();
|
||||
data.insert("user", &query.user);
|
||||
data.insert(
|
||||
"title",
|
||||
&format!(
|
||||
"Benutzer {} der {}",
|
||||
&query.item.username, &config.brand_name,
|
||||
),
|
||||
);
|
||||
data.insert("viewed_user", &query.item);
|
||||
|
||||
let rendered = tera.render("view_profile.html", &data)?;
|
||||
Ok(HttpResponse::Ok().body(rendered))
|
||||
} else {
|
||||
// Parsing error -- do something else
|
||||
Ok(redirect_builder("/admin/login/"))
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id, tera))]
|
||||
pub async fn edit_profile(
|
||||
tera: web::Data<Tera>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
user_id: web::Path<String>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
info!("Editing Profile!");
|
||||
if let Ok(query) = queries::get_user(&id, &user_id.0, &config).await {
|
||||
let mut data = Context::new();
|
||||
data.insert("user", &query.user);
|
||||
data.insert(
|
||||
"title",
|
||||
&format!(
|
||||
"Benutzer {} der {}",
|
||||
&query.user.username, &config.brand_name,
|
||||
),
|
||||
);
|
||||
data.insert("user", &query.user);
|
||||
|
||||
let rendered = tera.render("edit_profile.html", &data)?;
|
||||
Ok(HttpResponse::Ok().body(rendered))
|
||||
} else {
|
||||
Ok(redirect_builder("/admin/login/"))
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn process_edit_profile(
|
||||
data: web::Form<NewUser>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
user_id: web::Path<String>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
let query = queries::update_user(&id, &user_id.0, &config, &data).await?;
|
||||
Ok(redirect_builder(&format!(
|
||||
"admin/view/profile/{}",
|
||||
query.user.username
|
||||
)))
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn download_png(
|
||||
id: Identity,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
link_code: web::Path<String>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
match queries::get_link(&id, &link_code.0, &config).await {
|
||||
Ok(query) => {
|
||||
let qr = QrCode::with_error_correction_level(
|
||||
&format!("http://{}/{}", config.public_url, &query.item.code),
|
||||
qrcode::EcLevel::L,
|
||||
)
|
||||
.unwrap();
|
||||
let png = qr.render::<Luma<u8>>().quiet_zone(false).build();
|
||||
let mut temporary_data = std::io::Cursor::new(Vec::new());
|
||||
DynamicImage::ImageLuma8(png)
|
||||
.write_to(&mut temporary_data, ImageOutputFormat::Png)
|
||||
.unwrap();
|
||||
let image_data = temporary_data.into_inner();
|
||||
Ok(HttpResponse::Ok().set(ContentType::png()).body(image_data))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id, tera))]
|
||||
pub async fn signup(
|
||||
tera: web::Data<Tera>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
match queries::authenticate(&id, &config).await? {
|
||||
queries::Role::Admin { user } => {
|
||||
let mut data = Context::new();
|
||||
data.insert("title", "Ein Benutzerkonto erstellen");
|
||||
data.insert("user", &user);
|
||||
|
||||
let rendered = tera.render("signup.html", &data)?;
|
||||
Ok(HttpResponse::Ok().body(rendered))
|
||||
}
|
||||
queries::Role::Regular { .. }
|
||||
| queries::Role::NotAuthenticated
|
||||
| queries::Role::Disabled => Ok(redirect_builder("/admin/login/")),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn process_signup(
|
||||
data: web::Form<NewUser>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
info!("Creating a User: {:?}", &data);
|
||||
match queries::create_user(&id, &data, &config).await {
|
||||
Ok(item) => {
|
||||
Ok(HttpResponse::Ok().body(format!("Successfully saved user: {}", item.item.username)))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn toggle_admin(
|
||||
data: web::Path<String>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
let update = queries::toggle_admin(&id, &data.0, &config).await?;
|
||||
Ok(redirect_builder(&format!(
|
||||
"/admin/view/profile/{}",
|
||||
update.item.id
|
||||
)))
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn set_language(
|
||||
data: web::Path<String>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
queries::set_language(&id, &data.0, &config).await?;
|
||||
Ok(redirect_builder("/admin/index/"))
|
||||
}
|
||||
|
||||
#[instrument(skip(tera, id))]
|
||||
pub async fn login(
|
||||
tera: web::Data<Tera>,
|
||||
id: Identity,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
let language_code = detect_language(&req).unwrap_or_else(|_| "en".to_string());
|
||||
info!("Detected languagecode: {}", &language_code);
|
||||
let mut data = Context::new();
|
||||
data.insert("title", "Login");
|
||||
data.insert("language", &language_code);
|
||||
|
||||
if id.identity().is_some() {
|
||||
if let Ok(r) = authenticate(&id, &config).await {
|
||||
match r {
|
||||
Role::Admin { user } | Role::Regular { user } => {
|
||||
trace!(
|
||||
"This user ({}) is already logged in redirecting to /admin/index/",
|
||||
user.username
|
||||
);
|
||||
return Ok(redirect_builder("/admin/index/"));
|
||||
}
|
||||
Role::Disabled | Role::NotAuthenticated => (),
|
||||
}
|
||||
}
|
||||
warn!("Invalid user session. The user might be deleted or something tampered with the cookies.");
|
||||
id.forget();
|
||||
}
|
||||
|
||||
let rendered = tera.render("login.html", &data)?;
|
||||
Ok(HttpResponse::Ok().body(rendered))
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn process_login(
|
||||
data: web::Form<LoginUser>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
let user = queries::get_user_by_name(&data.username, &config).await;
|
||||
|
||||
match user {
|
||||
Ok(u) => {
|
||||
let secret = &config.secret;
|
||||
let valid = Verifier::default()
|
||||
.with_hash(&u.password)
|
||||
.with_password(&data.password)
|
||||
.with_secret_key(&secret.secret)
|
||||
.verify()?;
|
||||
|
||||
if valid {
|
||||
info!("Log-in of user: {}", &u.username);
|
||||
let session_token = u.username;
|
||||
id.remember(session_token);
|
||||
Ok(redirect_builder("/admin/index/"))
|
||||
} else {
|
||||
Ok(redirect_builder("/admin/login/"))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Failed to login: {}", e);
|
||||
Ok(redirect_builder("/admin/login/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn logout(id: Identity) -> Result<HttpResponse, ServerError> {
|
||||
info!("Logging out the user");
|
||||
id.forget();
|
||||
Ok(redirect_builder("/admin/login/"))
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
pub async fn redirect(
|
||||
tera: web::Data<Tera>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
data: web::Path<String>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
info!("Redirecting to {:?}", data);
|
||||
let link = queries::get_link_simple(&data.0, &config).await;
|
||||
info!("link: {:?}", link);
|
||||
match link {
|
||||
Ok(link) => {
|
||||
queries::click_link(link.id, &config).await?;
|
||||
Ok(redirect_builder(&link.target))
|
||||
}
|
||||
Err(ServerError::Database(e)) => {
|
||||
info!(
|
||||
"Link was not found: http://{}/{} \n {}",
|
||||
&config.public_url, &data.0, e
|
||||
);
|
||||
let mut data = Context::new();
|
||||
data.insert("title", "Wurde gel\u{f6}scht");
|
||||
let language = detect_language(&req).unwrap_or_else(|_| "en".to_string());
|
||||
data.insert("language", &language);
|
||||
let rendered = tera.render("not_found.html", &data)?;
|
||||
Ok(HttpResponse::NotFound().body(rendered))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
pub async fn redirect_empty(
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
Ok(redirect_builder(&config.empty_forward_url))
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn create_link(
|
||||
tera: web::Data<Tera>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
match queries::authenticate(&id, &config).await? {
|
||||
queries::Role::Admin { user } | queries::Role::Regular { user } => {
|
||||
let mut data = Context::new();
|
||||
data.insert("title", "Einen Kurzlink erstellen");
|
||||
|
||||
data.insert("user", &user);
|
||||
let rendered = tera.render("submission.html", &data)?;
|
||||
Ok(HttpResponse::Ok().body(rendered))
|
||||
}
|
||||
queries::Role::NotAuthenticated | queries::Role::Disabled => {
|
||||
Ok(redirect_builder("/admin/login/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn process_link_creation(
|
||||
data: web::Form<LinkForm>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
let new_link = queries::create_link(&id, data, &config).await?;
|
||||
Ok(redirect_builder(&format!(
|
||||
"/admin/view/link/{}",
|
||||
new_link.item.code
|
||||
)))
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn edit_link(
|
||||
tera: web::Data<Tera>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
link_id: web::Path<String>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
if let Ok(query) = queries::get_link(&id, &link_id.0, &config).await {
|
||||
let mut data = Context::new();
|
||||
data.insert("title", "Submit a Post");
|
||||
data.insert("link", &query.item);
|
||||
|
||||
data.insert("user", &query.user);
|
||||
let rendered = tera.render("edit_link.html", &data)?;
|
||||
return Ok(HttpResponse::Ok().body(rendered));
|
||||
}
|
||||
Ok(redirect_builder("/admin/login/"))
|
||||
}
|
||||
pub async fn process_link_edit(
|
||||
data: web::Form<LinkForm>,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
id: Identity,
|
||||
link_code: web::Path<String>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
match queries::update_link(&id, &link_code.0, data, &config).await {
|
||||
Ok(query) => Ok(redirect_builder(&format!(
|
||||
"/admin/view/link/{}",
|
||||
&query.item.code
|
||||
))),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(id))]
|
||||
pub async fn process_link_delete(
|
||||
id: Identity,
|
||||
config: web::Data<crate::ServerConfig>,
|
||||
link_code: web::Path<String>,
|
||||
) -> Result<HttpResponse, ServerError> {
|
||||
queries::delete_link(&id, &link_code.0, &config).await?;
|
||||
Ok(redirect_builder("/admin/login/"))
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
}
|
||||
.center {
|
||||
width: 800px;
|
||||
height: 600px;
|
||||
margin-left: -400px;
|
||||
margin-top: -300px;
|
||||
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: center;
|
||||
border: 1px solid #ccc;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: #eee;
|
||||
}
|
||||
table tr:nth-child(odd) {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
nav ol {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: #333;
|
||||
width:100%;
|
||||
}
|
||||
|
||||
nav li a, nav li div.willkommen {
|
||||
display: block;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 14px 16px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav li {
|
||||
float: left;
|
||||
}
|
||||
nav li a:hover {
|
||||
background-color: #111;
|
||||
}
|
||||
|
||||
nav li {
|
||||
border-right: 1px solid #bbb;
|
||||
}
|
||||
|
||||
nav li:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
svg {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
div.actions {
|
||||
margin-left:5px;
|
||||
display: flex;
|
||||
width:100%;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
color: #333;
|
||||
flex-flow: row wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
div.danger {
|
||||
background-color: rgb(235, 127, 77);
|
||||
font-size: smaller;
|
||||
border: 2px solid crimson;
|
||||
}
|
||||
|
||||
div.danger h3 {
|
||||
width:100%;
|
||||
}
|
||||
|
||||
a.button, div.actions input {
|
||||
width: 250px;
|
||||
display:block;
|
||||
padding: 15px;
|
||||
margin-left: 15px;
|
||||
text-align: center;
|
||||
border-radius: 1px;
|
||||
border: 1px solid rgb(90, 90, 90);
|
||||
font-family: inherit;
|
||||
background-color: #eae9ea;
|
||||
}
|
@ -1,495 +0,0 @@
|
||||
/*
|
||||
SortTable
|
||||
version 2
|
||||
7th April 2007
|
||||
Stuart Langridge, http://www.kryogenix.org/code/browser/sorttable/
|
||||
|
||||
Instructions:
|
||||
Download this file
|
||||
Add <script src="sorttable.js"></script> to your HTML
|
||||
Add class="sortable" to any table you'd like to make sortable
|
||||
Click on the headers to sort
|
||||
|
||||
Thanks to many, many people for contributions and suggestions.
|
||||
Licenced as X11: http://www.kryogenix.org/code/browser/licence.html
|
||||
This basically means: do what you want with it.
|
||||
*/
|
||||
|
||||
|
||||
var stIsIE = /*@cc_on!@*/false;
|
||||
|
||||
sorttable = {
|
||||
init: function () {
|
||||
// quit if this function has already been called
|
||||
if (arguments.callee.done) return;
|
||||
// flag this function so we don't do the same thing twice
|
||||
arguments.callee.done = true;
|
||||
// kill the timer
|
||||
if (_timer) clearInterval(_timer);
|
||||
|
||||
if (!document.createElement || !document.getElementsByTagName) return;
|
||||
|
||||
sorttable.DATE_RE = /^(\d\d?)[\/\.-](\d\d?)[\/\.-]((\d\d)?\d\d)$/;
|
||||
|
||||
forEach(document.getElementsByTagName('table'), function (table) {
|
||||
if (table.className.search(/\bsortable\b/) != -1) {
|
||||
sorttable.makeSortable(table);
|
||||
}
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
makeSortable: function (table) {
|
||||
if (table.getElementsByTagName('thead').length == 0) {
|
||||
// table doesn't have a tHead. Since it should have, create one and
|
||||
// put the first table row in it.
|
||||
the = document.createElement('thead');
|
||||
the.appendChild(table.rows[0]);
|
||||
table.insertBefore(the, table.firstChild);
|
||||
}
|
||||
// Safari doesn't support table.tHead, sigh
|
||||
if (table.tHead == null) table.tHead = table.getElementsByTagName('thead')[0];
|
||||
|
||||
if (table.tHead.rows.length != 1) return; // can't cope with two header rows
|
||||
|
||||
// Sorttable v1 put rows with a class of "sortbottom" at the bottom (as
|
||||
// "total" rows, for example). This is B&R, since what you're supposed
|
||||
// to do is put them in a tfoot. So, if there are sortbottom rows,
|
||||
// for backwards compatibility, move them to tfoot (creating it if needed).
|
||||
sortbottomrows = [];
|
||||
for (var i = 0; i < table.rows.length; i++) {
|
||||
if (table.rows[i].className.search(/\bsortbottom\b/) != -1) {
|
||||
sortbottomrows[sortbottomrows.length] = table.rows[i];
|
||||
}
|
||||
}
|
||||
if (sortbottomrows) {
|
||||
if (table.tFoot == null) {
|
||||
// table doesn't have a tfoot. Create one.
|
||||
tfo = document.createElement('tfoot');
|
||||
table.appendChild(tfo);
|
||||
}
|
||||
for (var i = 0; i < sortbottomrows.length; i++) {
|
||||
tfo.appendChild(sortbottomrows[i]);
|
||||
}
|
||||
delete sortbottomrows;
|
||||
}
|
||||
|
||||
// work through each column and calculate its type
|
||||
headrow = table.tHead.rows[0].cells;
|
||||
for (var i = 0; i < headrow.length; i++) {
|
||||
// manually override the type with a sorttable_type attribute
|
||||
if (!headrow[i].className.match(/\bsorttable_nosort\b/)) { // skip this col
|
||||
mtch = headrow[i].className.match(/\bsorttable_([a-z0-9]+)\b/);
|
||||
if (mtch) { override = mtch[1]; }
|
||||
if (mtch && typeof sorttable["sort_" + override] == 'function') {
|
||||
headrow[i].sorttable_sortfunction = sorttable["sort_" + override];
|
||||
} else {
|
||||
headrow[i].sorttable_sortfunction = sorttable.guessType(table, i);
|
||||
}
|
||||
// make it clickable to sort
|
||||
headrow[i].sorttable_columnindex = i;
|
||||
headrow[i].sorttable_tbody = table.tBodies[0];
|
||||
dean_addEvent(headrow[i], "click", sorttable.innerSortFunction = function (e) {
|
||||
|
||||
if (this.className.search(/\bsorttable_sorted\b/) != -1) {
|
||||
// if we're already sorted by this column, just
|
||||
// reverse the table, which is quicker
|
||||
sorttable.reverse(this.sorttable_tbody);
|
||||
this.className = this.className.replace('sorttable_sorted',
|
||||
'sorttable_sorted_reverse');
|
||||
this.removeChild(document.getElementById('sorttable_sortfwdind'));
|
||||
sortrevind = document.createElement('span');
|
||||
sortrevind.id = "sorttable_sortrevind";
|
||||
sortrevind.innerHTML = stIsIE ? ' <font face="webdings">5</font>' : ' ▴';
|
||||
this.appendChild(sortrevind);
|
||||
return;
|
||||
}
|
||||
if (this.className.search(/\bsorttable_sorted_reverse\b/) != -1) {
|
||||
// if we're already sorted by this column in reverse, just
|
||||
// re-reverse the table, which is quicker
|
||||
sorttable.reverse(this.sorttable_tbody);
|
||||
this.className = this.className.replace('sorttable_sorted_reverse',
|
||||
'sorttable_sorted');
|
||||
this.removeChild(document.getElementById('sorttable_sortrevind'));
|
||||
sortfwdind = document.createElement('span');
|
||||
sortfwdind.id = "sorttable_sortfwdind";
|
||||
sortfwdind.innerHTML = stIsIE ? ' <font face="webdings">6</font>' : ' ▾';
|
||||
this.appendChild(sortfwdind);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove sorttable_sorted classes
|
||||
theadrow = this.parentNode;
|
||||
forEach(theadrow.childNodes, function (cell) {
|
||||
if (cell.nodeType == 1) { // an element
|
||||
cell.className = cell.className.replace('sorttable_sorted_reverse', '');
|
||||
cell.className = cell.className.replace('sorttable_sorted', '');
|
||||
}
|
||||
});
|
||||
sortfwdind = document.getElementById('sorttable_sortfwdind');
|
||||
if (sortfwdind) { sortfwdind.parentNode.removeChild(sortfwdind); }
|
||||
sortrevind = document.getElementById('sorttable_sortrevind');
|
||||
if (sortrevind) { sortrevind.parentNode.removeChild(sortrevind); }
|
||||
|
||||
this.className += ' sorttable_sorted';
|
||||
sortfwdind = document.createElement('span');
|
||||
sortfwdind.id = "sorttable_sortfwdind";
|
||||
sortfwdind.innerHTML = stIsIE ? ' <font face="webdings">6</font>' : ' ▾';
|
||||
this.appendChild(sortfwdind);
|
||||
|
||||
// build an array to sort. This is a Schwartzian transform thing,
|
||||
// i.e., we "decorate" each row with the actual sort key,
|
||||
// sort based on the sort keys, and then put the rows back in order
|
||||
// which is a lot faster because you only do getInnerText once per row
|
||||
row_array = [];
|
||||
col = this.sorttable_columnindex;
|
||||
rows = this.sorttable_tbody.rows;
|
||||
for (var j = 0; j < rows.length; j++) {
|
||||
row_array[row_array.length] = [sorttable.getInnerText(rows[j].cells[col]), rows[j]];
|
||||
}
|
||||
/* If you want a stable sort, uncomment the following line */
|
||||
//sorttable.shaker_sort(row_array, this.sorttable_sortfunction);
|
||||
/* and comment out this one */
|
||||
row_array.sort(this.sorttable_sortfunction);
|
||||
|
||||
tb = this.sorttable_tbody;
|
||||
for (var j = 0; j < row_array.length; j++) {
|
||||
tb.appendChild(row_array[j][1]);
|
||||
}
|
||||
|
||||
delete row_array;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
guessType: function (table, column) {
|
||||
// guess the type of a column based on its first non-blank row
|
||||
sortfn = sorttable.sort_alpha;
|
||||
for (var i = 0; i < table.tBodies[0].rows.length; i++) {
|
||||
text = sorttable.getInnerText(table.tBodies[0].rows[i].cells[column]);
|
||||
if (text != '') {
|
||||
if (text.match(/^-?[Ł$¤]?[\d,.]+%?$/)) {
|
||||
return sorttable.sort_numeric;
|
||||
}
|
||||
// check for a date: dd/mm/yyyy or dd/mm/yy
|
||||
// can have / or . or - as separator
|
||||
// can be mm/dd as well
|
||||
possdate = text.match(sorttable.DATE_RE)
|
||||
if (possdate) {
|
||||
// looks like a date
|
||||
first = parseInt(possdate[1]);
|
||||
second = parseInt(possdate[2]);
|
||||
if (first > 12) {
|
||||
// definitely dd/mm
|
||||
return sorttable.sort_ddmm;
|
||||
} else if (second > 12) {
|
||||
return sorttable.sort_mmdd;
|
||||
} else {
|
||||
// looks like a date, but we can't tell which, so assume
|
||||
// that it's dd/mm (English imperialism!) and keep looking
|
||||
sortfn = sorttable.sort_ddmm;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortfn;
|
||||
},
|
||||
|
||||
getInnerText: function (node) {
|
||||
// gets the text we want to use for sorting for a cell.
|
||||
// strips leading and trailing whitespace.
|
||||
// this is *not* a generic getInnerText function; it's special to sorttable.
|
||||
// for example, you can override the cell text with a customkey attribute.
|
||||
// it also gets .value for <input> fields.
|
||||
|
||||
if (!node) return "";
|
||||
|
||||
hasInputs = (typeof node.getElementsByTagName == 'function') &&
|
||||
node.getElementsByTagName('input').length;
|
||||
|
||||
if (node.getAttribute("sorttable_customkey") != null) {
|
||||
return node.getAttribute("sorttable_customkey");
|
||||
}
|
||||
else if (typeof node.textContent != 'undefined' && !hasInputs) {
|
||||
return node.textContent.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
else if (typeof node.innerText != 'undefined' && !hasInputs) {
|
||||
return node.innerText.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
else if (typeof node.text != 'undefined' && !hasInputs) {
|
||||
return node.text.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
else {
|
||||
switch (node.nodeType) {
|
||||
case 3:
|
||||
if (node.nodeName.toLowerCase() == 'input') {
|
||||
return node.value.replace(/^\s+|\s+$/g, '');
|
||||
}
|
||||
case 4:
|
||||
return node.nodeValue.replace(/^\s+|\s+$/g, '');
|
||||
break;
|
||||
case 1:
|
||||
case 11:
|
||||
var innerText = '';
|
||||
for (var i = 0; i < node.childNodes.length; i++) {
|
||||
innerText += sorttable.getInnerText(node.childNodes[i]);
|
||||
}
|
||||
return innerText.replace(/^\s+|\s+$/g, '');
|
||||
break;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
reverse: function (tbody) {
|
||||
// reverse the rows in a tbody
|
||||
newrows = [];
|
||||
for (var i = 0; i < tbody.rows.length; i++) {
|
||||
newrows[newrows.length] = tbody.rows[i];
|
||||
}
|
||||
for (var i = newrows.length - 1; i >= 0; i--) {
|
||||
tbody.appendChild(newrows[i]);
|
||||
}
|
||||
delete newrows;
|
||||
},
|
||||
|
||||
/* sort functions
|
||||
each sort function takes two parameters, a and b
|
||||
you are comparing a[0] and b[0] */
|
||||
sort_numeric: function (a, b) {
|
||||
aa = parseFloat(a[0].replace(/[^0-9.-]/g, ''));
|
||||
if (isNaN(aa)) aa = 0;
|
||||
bb = parseFloat(b[0].replace(/[^0-9.-]/g, ''));
|
||||
if (isNaN(bb)) bb = 0;
|
||||
return aa - bb;
|
||||
},
|
||||
sort_alpha: function (a, b) {
|
||||
if (a[0] == b[0]) return 0;
|
||||
if (a[0] < b[0]) return -1;
|
||||
return 1;
|
||||
},
|
||||
sort_ddmm: function (a, b) {
|
||||
mtch = a[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; m = mtch[2]; d = mtch[1];
|
||||
if (m.length == 1) m = '0' + m;
|
||||
if (d.length == 1) d = '0' + d;
|
||||
dt1 = y + m + d;
|
||||
mtch = b[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; m = mtch[2]; d = mtch[1];
|
||||
if (m.length == 1) m = '0' + m;
|
||||
if (d.length == 1) d = '0' + d;
|
||||
dt2 = y + m + d;
|
||||
if (dt1 == dt2) return 0;
|
||||
if (dt1 < dt2) return -1;
|
||||
return 1;
|
||||
},
|
||||
sort_mmdd: function (a, b) {
|
||||
mtch = a[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; d = mtch[2]; m = mtch[1];
|
||||
if (m.length == 1) m = '0' + m;
|
||||
if (d.length == 1) d = '0' + d;
|
||||
dt1 = y + m + d;
|
||||
mtch = b[0].match(sorttable.DATE_RE);
|
||||
y = mtch[3]; d = mtch[2]; m = mtch[1];
|
||||
if (m.length == 1) m = '0' + m;
|
||||
if (d.length == 1) d = '0' + d;
|
||||
dt2 = y + m + d;
|
||||
if (dt1 == dt2) return 0;
|
||||
if (dt1 < dt2) return -1;
|
||||
return 1;
|
||||
},
|
||||
|
||||
shaker_sort: function (list, comp_func) {
|
||||
// A stable sort function to allow multi-level sorting of data
|
||||
// see: http://en.wikipedia.org/wiki/Cocktail_sort
|
||||
// thanks to Joseph Nahmias
|
||||
var b = 0;
|
||||
var t = list.length - 1;
|
||||
var swap = true;
|
||||
|
||||
while (swap) {
|
||||
swap = false;
|
||||
for (var i = b; i < t; ++i) {
|
||||
if (comp_func(list[i], list[i + 1]) > 0) {
|
||||
var q = list[i]; list[i] = list[i + 1]; list[i + 1] = q;
|
||||
swap = true;
|
||||
}
|
||||
} // for
|
||||
t--;
|
||||
|
||||
if (!swap) break;
|
||||
|
||||
for (var i = t; i > b; --i) {
|
||||
if (comp_func(list[i], list[i - 1]) < 0) {
|
||||
var q = list[i]; list[i] = list[i - 1]; list[i - 1] = q;
|
||||
swap = true;
|
||||
}
|
||||
} // for
|
||||
b++;
|
||||
|
||||
} // while(swap)
|
||||
}
|
||||
}
|
||||
|
||||
/* ******************************************************************
|
||||
Supporting functions: bundled here to avoid depending on a library
|
||||
****************************************************************** */
|
||||
|
||||
// Dean Edwards/Matthias Miller/John Resig
|
||||
|
||||
/* for Mozilla/Opera9 */
|
||||
if (document.addEventListener) {
|
||||
document.addEventListener("DOMContentLoaded", sorttable.init, false);
|
||||
}
|
||||
|
||||
/* for Internet Explorer */
|
||||
/*@cc_on @*/
|
||||
/*@if (@_win32)
|
||||
document.write("<script id=__ie_onload defer src=javascript:void(0)><\/script>");
|
||||
var script = document.getElementById("__ie_onload");
|
||||
script.onreadystatechange = function() {
|
||||
if (this.readyState == "complete") {
|
||||
sorttable.init(); // call the onload handler
|
||||
}
|
||||
};
|
||||
/*@end @*/
|
||||
|
||||
/* for Safari */
|
||||
if (/WebKit/i.test(navigator.userAgent)) { // sniff
|
||||
var _timer = setInterval(function () {
|
||||
if (/loaded|complete/.test(document.readyState)) {
|
||||
sorttable.init(); // call the onload handler
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/* for other browsers */
|
||||
window.onload = sorttable.init;
|
||||
|
||||
// written by Dean Edwards, 2005
|
||||
// with input from Tino Zijdel, Matthias Miller, Diego Perini
|
||||
|
||||
// http://dean.edwards.name/weblog/2005/10/add-event/
|
||||
|
||||
function dean_addEvent(element, type, handler) {
|
||||
if (element.addEventListener) {
|
||||
element.addEventListener(type, handler, false);
|
||||
} else {
|
||||
// assign each event handler a unique ID
|
||||
if (!handler.$$guid) handler.$$guid = dean_addEvent.guid++;
|
||||
// create a hash table of event types for the element
|
||||
if (!element.events) element.events = {};
|
||||
// create a hash table of event handlers for each element/event pair
|
||||
var handlers = element.events[type];
|
||||
if (!handlers) {
|
||||
handlers = element.events[type] = {};
|
||||
// store the existing event handler (if there is one)
|
||||
if (element["on" + type]) {
|
||||
handlers[0] = element["on" + type];
|
||||
}
|
||||
}
|
||||
// store the event handler in the hash table
|
||||
handlers[handler.$$guid] = handler;
|
||||
// assign a global event handler to do all the work
|
||||
element["on" + type] = handleEvent;
|
||||
}
|
||||
};
|
||||
// a counter used to create unique IDs
|
||||
dean_addEvent.guid = 1;
|
||||
|
||||
function removeEvent(element, type, handler) {
|
||||
if (element.removeEventListener) {
|
||||
element.removeEventListener(type, handler, false);
|
||||
} else {
|
||||
// delete the event handler from the hash table
|
||||
if (element.events && element.events[type]) {
|
||||
delete element.events[type][handler.$$guid];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function handleEvent(event) {
|
||||
var returnValue = true;
|
||||
// grab the event object (IE uses a global event object)
|
||||
event = event || fixEvent(((this.ownerDocument || this.document || this).parentWindow || window).event);
|
||||
// get a reference to the hash table of event handlers
|
||||
var handlers = this.events[event.type];
|
||||
// execute each event handler
|
||||
for (var i in handlers) {
|
||||
this.$$handleEvent = handlers[i];
|
||||
if (this.$$handleEvent(event) === false) {
|
||||
returnValue = false;
|
||||
}
|
||||
}
|
||||
return returnValue;
|
||||
};
|
||||
|
||||
function fixEvent(event) {
|
||||
// add W3C standard event methods
|
||||
event.preventDefault = fixEvent.preventDefault;
|
||||
event.stopPropagation = fixEvent.stopPropagation;
|
||||
return event;
|
||||
};
|
||||
fixEvent.preventDefault = function () {
|
||||
this.returnValue = false;
|
||||
};
|
||||
fixEvent.stopPropagation = function () {
|
||||
this.cancelBubble = true;
|
||||
}
|
||||
|
||||
// Dean's forEach: http://dean.edwards.name/base/forEach.js
|
||||
/*
|
||||
forEach, version 1.0
|
||||
Copyright 2006, Dean Edwards
|
||||
License: http://www.opensource.org/licenses/mit-license.php
|
||||
*/
|
||||
|
||||
// array-like enumeration
|
||||
if (!Array.forEach) { // mozilla already supports this
|
||||
Array.forEach = function (array, block, context) {
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
block.call(context, array[i], i, array);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// generic enumeration
|
||||
Function.prototype.forEach = function (object, block, context) {
|
||||
for (var key in object) {
|
||||
if (typeof this.prototype[key] == "undefined") {
|
||||
block.call(context, object[key], key, object);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// character enumeration
|
||||
String.forEach = function (string, block, context) {
|
||||
Array.forEach(string.split(""), function (chr, index) {
|
||||
block.call(context, chr, index, string);
|
||||
});
|
||||
};
|
||||
|
||||
// globally resolve forEach enumeration
|
||||
var forEach = function (object, block, context) {
|
||||
if (object) {
|
||||
var resolve = Object; // default
|
||||
if (object instanceof Function) {
|
||||
// functions have a "length" property
|
||||
resolve = Function;
|
||||
} else if (object.forEach instanceof Function) {
|
||||
// the object implements a custom forEach method so use that
|
||||
object.forEach(block, context);
|
||||
return;
|
||||
} else if (typeof object == "string") {
|
||||
// the object is a string
|
||||
resolve = String;
|
||||
} else if (typeof object.length == "number") {
|
||||
// the object is array-like
|
||||
resolve = Array;
|
||||
}
|
||||
resolve.forEach(object, block, context);
|
||||
}
|
||||
};
|
||||
|
@ -1,34 +0,0 @@
|
||||
|
||||
*, *:before, *:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin:0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -200px;
|
||||
margin-top: -200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 30px;
|
||||
color: #333;
|
||||
}
|
||||
.center input {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
margin: 5px;
|
||||
border-radius: 1px;
|
||||
border: 1px solid rgb(90, 90, 90);
|
||||
font-family: inherit;
|
||||
background-color: #eae9ea;
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
{% block head2 %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="admin">
|
||||
<nav>
|
||||
<ol>
|
||||
<li><a href="/admin/index/">{{ fluent(key="list-links", lang=user.language) }}</a>
|
||||
</li>
|
||||
<li><a href="/admin/submit/">{{ fluent(key="add-link", lang=user.language) }}</a></li>
|
||||
{% if user.role == 2 %}<li><a href="/admin/signup/">{{ fluent(key="invite-user", lang=user.language) }}</a>
|
||||
</li>
|
||||
<li><a href="/admin/view/users/">{{ fluent(key="list-users", lang=user.language) }}</a></li>{% endif %}
|
||||
<li style="float:right"><a href="/admin/logout/">{{ fluent(key="logout", lang=user.language) }}</a></li>
|
||||
<li style="float:right">
|
||||
<div class="willkommen">{{ fluent(key="welcome-user", lang=user.language, username=user.username) }}
|
||||
</div>
|
||||
</li>
|
||||
<li style="float:right"><a href="/admin/edit/set_language/en">en</a></li>
|
||||
<li style="float:right"><a href="/admin/edit/set_language/de">de</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% block admin %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{title}}</title>
|
||||
<meta name="author" content="Franz Dietrich">
|
||||
<meta http-equiv="robots" content="[noindex|nofollow]">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="content">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
@ -1,28 +0,0 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
{% block admin %}
|
||||
<div class="center">
|
||||
<h1>{{ fluent(key="edit-link-headline", lang=user.language, linktitle=link.title) }}</h1>
|
||||
<form action="" method="POST">
|
||||
<div>
|
||||
<label for="title">{{ fluent(key="link-description", lang=user.language) }}:</label>
|
||||
<input type="text" name="title" value="{{ link.title }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="target">{{ fluent(key="link-target", lang=user.language) }}:</label>
|
||||
<input type="text" name="target" value="{{link.target}}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="code">{{ fluent(key="link-code", lang=user.language) }}:</label>
|
||||
<input type="text" name="code" value="{{link.code}}">
|
||||
</div>
|
||||
<div class="actions danger">
|
||||
<h2>{{ fluent(key="danger-zone", lang=user.language) }}</h2>
|
||||
<h3>{{ fluent(key="danger-zone-text", lang=user.language) }}</h3>
|
||||
<input type="submit" value='{{ fluent(key="save-edits", lang=user.language) }}'>
|
||||
<a class="button" href="/admin/delete/link/{{link.code}}">{{ fluent(key="delete-link", lang=user.language)
|
||||
}}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,29 +0,0 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
{% block admin %}
|
||||
<div class="center">
|
||||
<h1>{{ fluent(key="edit-user-headline", lang=user.language, username=user.username) }}
|
||||
</h1>
|
||||
<form action="" method="POST">
|
||||
<div>
|
||||
<label for="username">{{ fluent(key="username", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="text" name="username" value="{{ user.username }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email">{{ fluent(key="email", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="email" name="email" value="{{ user.email }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">{{ fluent(key="password", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="password" name="password" placeholder='{{ fluent(key="password-placeholder", lang=user.language)
|
||||
}}'>
|
||||
</div>
|
||||
<input type="submit" value='{{ fluent(key="save-user", lang=user.language)
|
||||
}}'>
|
||||
</form>
|
||||
<h2> </h2>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,58 +0,0 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
|
||||
{% block head2 %}
|
||||
<script src="/static/sorttable.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin %}
|
||||
<div class="scrollable">
|
||||
<table class="sortable">
|
||||
|
||||
<tr>
|
||||
<th>
|
||||
{{ fluent(key="link-code", lang=user.language)
|
||||
}}
|
||||
</th>
|
||||
<th>
|
||||
{{ fluent(key="link-target", lang=user.language)
|
||||
}}
|
||||
</th>
|
||||
<th>
|
||||
{{ fluent(key="username", lang=user.language)
|
||||
}}
|
||||
</th>
|
||||
<th>
|
||||
{{ fluent(key="statistics", lang=user.language)
|
||||
}}
|
||||
</th>
|
||||
</tr>
|
||||
{% for links_user in links_per_users %}
|
||||
{% set l = links_user.link %}
|
||||
{% set u = links_user.user %}
|
||||
{% set c = links_user.clicks %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/admin/view/link/{{l.code}}"><span>{{l.code}}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/view/link/{{l.code}}">{{ l.target }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if user.role == 2 or user.id == u.id %}
|
||||
<a href="/admin/view/profile/{{u.id}}"><small>{{ u.username }}</small>
|
||||
</a>
|
||||
{% else %}
|
||||
<small>{{ u.username }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ c.number }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,45 +0,0 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
|
||||
{% block head2 %}
|
||||
<script src="/static/sorttable.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin %}
|
||||
<div class="scrollable">
|
||||
<table class="sortable">
|
||||
|
||||
<tr>
|
||||
<th>
|
||||
{{ fluent(key="userid", lang=user.language)
|
||||
}}
|
||||
</th>
|
||||
<th>
|
||||
{{ fluent(key="email", lang=user.language)
|
||||
}}
|
||||
</th>
|
||||
<th>
|
||||
{{ fluent(key="username", lang=user.language)
|
||||
}}
|
||||
</th>
|
||||
</tr>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/admin/view/profile/{{user.id}}"><span>{{user.id}}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/view/profile/{{user.id}}">{{ user.email }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
<a href="/admin/view/profile/{{user.id}}"><small>{{ user.username }}</small>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,20 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="center">
|
||||
<form action="" method="POST">
|
||||
<div>
|
||||
<label for="username">{{ fluent(key="username", lang=language)
|
||||
}}:</label>
|
||||
<input type="text" name="username">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">{{ fluent(key="password", lang=language)
|
||||
}}:</label>
|
||||
<input type="password" name="password">
|
||||
</div>
|
||||
<input type="submit" value='{{ fluent(key="login", lang=language) }}'>
|
||||
</form>
|
||||
<h2> </h2>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,8 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="center">
|
||||
<h3>{{ fluent(key="not-found", lang=language) }}</h3>
|
||||
<h2> </h2>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,26 +0,0 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
{% block admin %}
|
||||
<div class="center">
|
||||
<form action="" method="POST">
|
||||
<div>
|
||||
<label for="username">{{ fluent(key="username", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="text" name="username">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email">{{ fluent(key="email", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="email" name="email">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">{{ fluent(key="password", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="password" name="password">
|
||||
</div>
|
||||
<input type="submit" value='{{ fluent(key="invite-user", lang=user.language)
|
||||
}}'>
|
||||
</form>
|
||||
<h2> </h2>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,24 +0,0 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
{% block admin %}
|
||||
<div class="center">
|
||||
<form action="" method="POST">
|
||||
<div>
|
||||
<label for="title">{{ fluent(key="link-description", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="text" name="title">
|
||||
</div>
|
||||
<div>
|
||||
<label for="target">{{ fluent(key="link-target", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="text" name="target">
|
||||
</div>
|
||||
<div>
|
||||
<label for="code">{{ fluent(key="link-code", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="text" name="code">
|
||||
</div>
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,46 +0,0 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
{% block admin %}
|
||||
<div class="center">
|
||||
<h1>{{ link.title }}</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<td>{{ fluent(key="link-description", lang=user.language)
|
||||
}}:</td>
|
||||
<td>{{ link.title }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ fluent(key="link-code", lang=user.language)
|
||||
}}:</td>
|
||||
<td>{{ link.code }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ fluent(key="shortlink", lang=user.language)
|
||||
}}:</td>
|
||||
<td><a href="{{ protocol }}://{{ host }}/{{ link.code }}">{{ protocol }}://{{ host }}/{{ link.code }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ fluent(key="link-target", lang=user.language)
|
||||
}}:</td>
|
||||
<td>{{ link.target }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ fluent(key="qr-code", lang=user.language)
|
||||
}}</td>
|
||||
<td><a href="/admin/download/png/{{ link.code }}" download="{{ link.title | slugify }}.png">
|
||||
{{ qr | trim_start_matches(pat=
|
||||
'.*?>')
|
||||
| safe }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% if user.role == 2 or user.id == link.author %}
|
||||
<div class="actions">
|
||||
<a class="button" href="/admin/edit/link/{{ link.code }}">{{ fluent(key="edit-link", lang=user.language)
|
||||
}}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,43 +0,0 @@
|
||||
{% extends "admin.html" %}
|
||||
|
||||
{% block admin %}
|
||||
<div class="center">
|
||||
<h1>{{ fluent(key="user-headline", lang=user.language, username=user.username) }}</h1>
|
||||
<form action="" method="POST">
|
||||
<div>
|
||||
<label for="username">{{ fluent(key="username", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="text" name="username" value="{{ viewed_user.username }}" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email">{{ fluent(key="email", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="email" name="email" value="{{ viewed_user.email }}" readonly>
|
||||
</div>
|
||||
{% if user.role == 2 or user.id == viewed_user.id %}
|
||||
<div>
|
||||
<label for="password">{{ fluent(key="password", lang=user.language)
|
||||
}}:</label>
|
||||
<input type="password" name="password" value="verschlüsselt" readonly>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if user.role == 2 or user.id == viewed_user.id %}
|
||||
<div class="actions">
|
||||
<a class="button" href="/admin/edit/profile/{{ viewed_user.id }}">{{ fluent(key="edit-user", lang=user.language)
|
||||
}}</a>
|
||||
{% if user.role == 2 and viewed_user.role == 1 %}
|
||||
<a class="button" href="/admin/edit/set_admin/{{ viewed_user.id }}">{{ fluent(key="make-user-admin",
|
||||
lang=user.language)
|
||||
}}</a>
|
||||
{% endif %}
|
||||
{% if user.role == 2 and viewed_user.role == 2 and not user.id == viewed_user.id %}
|
||||
<a class="button" href="/admin/edit/set_admin/{{ viewed_user.id }}">{{ fluent(key="make-user-regular",
|
||||
lang=user.language)
|
||||
}}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<h2> </h2>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,349 +0,0 @@
|
||||
#[test]
|
||||
fn test_help_of_command_for_breaking_changes() {
|
||||
let output = test_bin::get_test_bin("pslink")
|
||||
.output()
|
||||
.expect("Failed to start pslink");
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("USAGE"));
|
||||
|
||||
let output = test_bin::get_test_bin("pslink")
|
||||
.args(&["--help"])
|
||||
.output()
|
||||
.expect("Failed to start pslink");
|
||||
let outstring = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
let args = &[
|
||||
"USAGE",
|
||||
"-h",
|
||||
"--help",
|
||||
"-b",
|
||||
"-e",
|
||||
"-i",
|
||||
"-p",
|
||||
"-t",
|
||||
"-u",
|
||||
"runserver",
|
||||
"create-admin",
|
||||
"generate-env",
|
||||
"migrate-database",
|
||||
"help",
|
||||
];
|
||||
|
||||
for s in args {
|
||||
assert!(
|
||||
outstring.contains(s),
|
||||
"{} was not found in the help - this is a breaking change",
|
||||
s
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_env() {
|
||||
use std::io::BufRead;
|
||||
let tmp_dir = tempdir::TempDir::new("pslink_test_env").expect("create temp dir");
|
||||
let output = test_bin::get_test_bin("pslink")
|
||||
.args(&["generate-env", "--secret", "abcdefghijklmnopqrstuvw"])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed to start pslink");
|
||||
let envfile = tmp_dir.path().join(".env");
|
||||
let dbfile = tmp_dir.path().join("links.db");
|
||||
println!("{}", envfile.display());
|
||||
println!("{}", dbfile.display());
|
||||
println!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
assert!(envfile.exists(), "No .env-file was created!");
|
||||
assert!(dbfile.exists(), "No database-file was created!");
|
||||
|
||||
let envfile = std::fs::File::open(envfile).unwrap();
|
||||
let envcontent: Vec<Result<String, _>> = std::io::BufReader::new(envfile).lines().collect();
|
||||
assert!(
|
||||
envcontent
|
||||
.iter()
|
||||
.any(|s| s.as_ref().unwrap().starts_with("PSLINK_PORT=")),
|
||||
"Failed to find PSLINK_PORT in the generated .env file."
|
||||
);
|
||||
assert!(
|
||||
envcontent
|
||||
.iter()
|
||||
.any(|s| s.as_ref().unwrap().starts_with("PSLINK_SECRET=")),
|
||||
"Failed to find PSLINK_SECRET in the generated .env file."
|
||||
);
|
||||
assert!(
|
||||
!envcontent.iter().any(|s| {
|
||||
let r = s.as_ref().unwrap().contains("***SECRET***");
|
||||
r
|
||||
}),
|
||||
"It seems that a censored secret was used in the .env file."
|
||||
);
|
||||
assert!(
|
||||
envcontent.iter().any(|s| {
|
||||
let r = s.as_ref().unwrap().contains("abcdefghijklmnopqrstuvw");
|
||||
r
|
||||
}),
|
||||
"The secret has not made it into the .env file!"
|
||||
);
|
||||
let output = test_bin::get_test_bin("pslink")
|
||||
.args(&["generate-env"])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed to start pslink");
|
||||
let second_out = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(!second_out.contains("secret"));
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_migrate_database() {
|
||||
use std::io::Write;
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub struct Count {
|
||||
pub number: i32,
|
||||
}
|
||||
|
||||
let tmp_dir = tempdir::TempDir::new("pslink_test_env").expect("create temp dir");
|
||||
// generate .env file
|
||||
let _output = test_bin::get_test_bin("pslink")
|
||||
.args(&["generate-env"])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed generate .env");
|
||||
|
||||
// migrate the database
|
||||
let output = test_bin::get_test_bin("pslink")
|
||||
.args(&["migrate-database"])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed to migrate the database");
|
||||
println!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
|
||||
// check if the users table exists by counting the number of admins.
|
||||
let db_pool = sqlx::pool::Pool::<sqlx::sqlite::Sqlite>::connect(
|
||||
&tmp_dir.path().join("links.db").display().to_string(),
|
||||
)
|
||||
.await
|
||||
.expect("Error: Failed to connect to database!");
|
||||
let num = sqlx::query_as!(Count, "select count(*) as number from users where role = 2")
|
||||
.fetch_one(&db_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// initially no admin is present
|
||||
assert_eq!(num.number, 0, "Failed to create the database!");
|
||||
|
||||
// create a new admin
|
||||
let mut input = test_bin::get_test_bin("pslink")
|
||||
.args(&["create-admin"])
|
||||
.current_dir(&tmp_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to migrate the database");
|
||||
let mut procin = input.stdin.take().unwrap();
|
||||
|
||||
procin.write_all(b"test\n").unwrap();
|
||||
procin.write_all(b"test@mail.test\n").unwrap();
|
||||
procin.write_all(b"testpw\n").unwrap();
|
||||
|
||||
let r = input.wait().unwrap();
|
||||
println!("Exitstatus is: {}", r);
|
||||
|
||||
println!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
let num = sqlx::query_as!(Count, "select count(*) as number from users where role = 2")
|
||||
.fetch_one(&db_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// now 1 admin is there
|
||||
assert_eq!(num.number, 1, "Failed to create an admin!");
|
||||
}
|
||||
|
||||
async fn run_server() {
|
||||
use std::io::Write;
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub struct Count {
|
||||
pub number: i32,
|
||||
}
|
||||
let tmp_dir = tempdir::TempDir::new("pslink_test_env").expect("create temp dir");
|
||||
// generate .env file
|
||||
let _output = test_bin::get_test_bin("pslink")
|
||||
.args(&["generate-env", "--secret", "abcdefghijklmnopqrstuvw"])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed generate .env");
|
||||
// migrate the database
|
||||
let output = test_bin::get_test_bin("pslink")
|
||||
.args(&["migrate-database"])
|
||||
.current_dir(&tmp_dir)
|
||||
.output()
|
||||
.expect("Failed to migrate the database");
|
||||
|
||||
// create a database connection.
|
||||
let db_pool = sqlx::pool::Pool::<sqlx::sqlite::Sqlite>::connect(
|
||||
&tmp_dir.path().join("links.db").display().to_string(),
|
||||
)
|
||||
.await
|
||||
.expect("Error: Failed to connect to database!"); // create a new admin
|
||||
let mut input = test_bin::get_test_bin("pslink")
|
||||
.args(&["create-admin"])
|
||||
.current_dir(&tmp_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to migrate the database");
|
||||
let mut procin = input.stdin.take().unwrap();
|
||||
|
||||
procin.write_all(b"test\n").unwrap();
|
||||
procin.write_all(b"test@mail.test\n").unwrap();
|
||||
procin.write_all(b"testpw\n").unwrap();
|
||||
|
||||
let r = input.wait().unwrap();
|
||||
println!("Exitstatus is: {}", r);
|
||||
|
||||
println!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
let num = sqlx::query_as!(Count, "select count(*) as number from users where role = 2")
|
||||
.fetch_one(&db_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// now 1 admin is there
|
||||
assert_eq!(
|
||||
num.number, 1,
|
||||
"Failed to create an admin! See previous tests!"
|
||||
);
|
||||
|
||||
let server_config = pslink::ServerConfig {
|
||||
secret: pslink::Secret::new("abcdefghijklmnopqrstuvw".to_string()),
|
||||
db: std::path::PathBuf::from("links.db"),
|
||||
db_pool,
|
||||
public_url: "localhost:8080".to_string(),
|
||||
internal_ip: "localhost".to_string(),
|
||||
port: 8080,
|
||||
protocol: pslink::Protocol::Http,
|
||||
empty_forward_url: "https://github.com/enaut/pslink".to_string(),
|
||||
brand_name: "Pslink".to_string(),
|
||||
};
|
||||
|
||||
let server = pslink::webservice(server_config);
|
||||
|
||||
let _neveruse = tokio::spawn(server);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_web_paths() {
|
||||
run_server().await;
|
||||
|
||||
// We need to bring in `reqwest`
|
||||
// to perform HTTP requests against our application.
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
// Act
|
||||
let response = client
|
||||
.get("http://localhost:8080/")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// The basic redirection is working!
|
||||
assert!(response.status().is_redirection());
|
||||
let location = response.headers().get("location").unwrap();
|
||||
assert!(location.to_str().unwrap().contains("github"));
|
||||
|
||||
// Act
|
||||
let response = client
|
||||
.get("http://localhost:8080/admin/login/")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// The Loginpage is reachable and contains a password field!
|
||||
assert!(response.status().is_success());
|
||||
let content = response.text().await.unwrap();
|
||||
assert!(
|
||||
content.contains(r#"<input type="password"#),
|
||||
"No password field was found!"
|
||||
);
|
||||
|
||||
// Act
|
||||
let formdata = &[("username", "test"), ("password", "testpw")];
|
||||
let response = client
|
||||
.post("http://localhost:8080/admin/login/")
|
||||
.form(formdata)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// It is possible to login
|
||||
assert!(response.status().is_redirection());
|
||||
let location = response.headers().get("location").unwrap();
|
||||
assert_eq!("/admin/index/", location.to_str().unwrap());
|
||||
assert!(
|
||||
response.headers().get("set-cookie").is_some(),
|
||||
"A auth cookie is not set even though authentication succeeds"
|
||||
);
|
||||
|
||||
// After login this should return a redirect
|
||||
let response = client
|
||||
.get("http://localhost:8080/admin/login/")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// The Loginpage redirects to link index when logged in
|
||||
assert!(
|
||||
response.status().is_redirection(),
|
||||
"/admin/login/ is not redirecting correctly when logged in!"
|
||||
);
|
||||
let location = response.headers().get("location").unwrap();
|
||||
assert_eq!("/admin/index/", location.to_str().unwrap());
|
||||
|
||||
// After login this should return a redirect
|
||||
let response = client
|
||||
.get("http://localhost:8080/admin/index/")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// The Loginpage redirects to link index when logged in
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"Could not access /admin/index/"
|
||||
);
|
||||
let content = response.text().await.unwrap();
|
||||
assert!(
|
||||
content.contains(r#"<a href="/admin/logout/">"#),
|
||||
"No Logout Button was found on /admin/index/!"
|
||||
);
|
||||
|
||||
// Act title=haupt&target=http%3A%2F%2Fdas.geht%2Fjetzt%2F&code=tpuah
|
||||
let formdata = &[
|
||||
("title", "haupt"),
|
||||
("target", "https://das.geht/jetzt/"),
|
||||
("code", "tpuah"),
|
||||
];
|
||||
let response = client
|
||||
.post("http://localhost:8080/admin/submit/")
|
||||
.form(formdata)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// It is possible to login
|
||||
assert!(response.status().is_redirection());
|
||||
let location = response.headers().get("location").unwrap();
|
||||
assert_eq!("/admin/view/link/tpuah", location.to_str().unwrap());
|
||||
|
||||
// Act
|
||||
let response = client
|
||||
.get("http://localhost:8080/tpuah")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// The basic redirection is working!
|
||||
assert!(response.status().is_redirection());
|
||||
let location = response.headers().get("location").unwrap();
|
||||
assert!(location
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("https://das.geht/jetzt/"));
|
||||
}
|
Loading…
Reference in New Issue
Block a user