Working user and link list, (big)restructure
This is now a workspace consisting of: * the pslink app (a wasm frontend for the admin interface) * the pslink binary * shared - modules for communication between the two above.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
[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"
|
||||
actix-files = "0.5.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"
|
||||
async-trait = "0.1"
|
||||
enum-map = {version="1", features = ["serde"]}
|
||||
|
||||
shared = { path = "../shared" }
|
||||
|
||||
[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"
|
||||
|
||||
[profile]
|
||||
[profile.release]
|
||||
lto = true
|
||||
#codegen-units = 1
|
||||
@@ -0,0 +1,116 @@
|
||||
# Pslink a "Private Short Link page"
|
||||
|
||||
The target audience of this tool are small entities that need a url shortener. The shortened urls can be publicly resolved but only registered users can create short urls. Every registered user can see all shorted urls but ownly modify its own. Admin users can invite other accounts and edit everything that can be edited (also urls created by other accounts).
|
||||
|
||||
So in general this is more a shared short url bookmark webpage than a shorturl service.
|
||||
|
||||

|
||||
|
||||
The Page comes with a basic commandline interface to setup the environment.
|
||||
|
||||
## 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:
|
||||
|
||||
```bash
|
||||
# mkdir -p /opt/pslink
|
||||
# wget -o /opt/pslink/pslink https://github.com/enaut/pslink/releases/latest/download/pslink.linux.64bit
|
||||
# chmod +x /opt/pslink/pslink
|
||||
```
|
||||
|
||||
You could now adjust your `PATH` or setup an alias or just call the binary with the full path `/opt/pslink/pslink`
|
||||
|
||||
### 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.
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
SQLX_OFFLINE=1 cargo run
|
||||
# or
|
||||
$ export SQLX_OFFLINE=1
|
||||
$ cargo run
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Setup
|
||||
|
||||
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.
|
||||
4. `pslink runserver`
|
||||
|
||||
If everything is set up correctly this command will start the service.
|
||||
|
||||
### Run the service
|
||||
|
||||
If everything is correctly set up just do `pslink runserver`.
|
||||
|
||||
### 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
|
||||
3. run `pslink migrate-database`
|
||||
4. run the server again `pslink runserver`
|
||||
|
||||
### Help
|
||||
|
||||
For a list of options use `pslink help`. If the help does not provide enough clues please file an issue at: https://github.com/enaut/pslink/issues/new
|
||||
|
||||
### Systemd service file
|
||||
|
||||
If you want to automatically start this with systemd you can adjust the following template unit to your system. In this case a dedicated `pslink` user and group is used with the users home directory at `/var/pslink`. Some additional settings are in place to protect the system a little should anything go wrong.
|
||||
|
||||
```systemd
|
||||
# /etc/systemd/system/pslink.service
|
||||
[Unit]
|
||||
Description=Pslink the Urlshortener
|
||||
Documentation=https://github.com/enaut/Pslink
|
||||
Wants=network.target
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=pslink
|
||||
Group=pslink
|
||||
EnvironmentFile=-/var/pslink/.env
|
||||
|
||||
ProtectHome=true
|
||||
ProtectSystem=full
|
||||
PrivateDevices=true
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
InaccessibleDirectories=/root /sys /srv -/opt /media -/lost+found
|
||||
ReadWriteDirectories=/var/pslink
|
||||
WorkingDirectory=/var/pslink
|
||||
ExecStart=/var/pslink/pslink runserver
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
Guide to release:
|
||||
|
||||
- [ ] Verify everything is committed
|
||||
- [ ] update the sqlx cache: cargo sqlx prepare
|
||||
- [ ] commit the update
|
||||
- [ ] push to github and teilgedanken
|
||||
- [ ] create release draft tag: https://github.com/enaut/pslink/releases
|
||||
- [ ] check `git log --pretty=format:'* %s' --abbrev-commit` for changes and selectively include into changelist.
|
||||
- [ ] verify everything is ready for publishing using:
|
||||
|
||||
```
|
||||
SQLX_OFFLINE=1 cargo publish --dry-run
|
||||
```
|
||||
|
||||
- [ ] make draft a release
|
||||
|
||||
- [ ] publish
|
||||
|
||||
```SQLX_OFFLINE=1 cargo publish```
|
||||
@@ -0,0 +1,5 @@
|
||||
use actix_web_static_files::resource_dir;
|
||||
|
||||
fn main() {
|
||||
resource_dir("./static/").build().unwrap();
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,36 @@
|
||||
list-links = Link Liste
|
||||
add-link = Link hinzufügen
|
||||
invite-user = Benutzer einladen
|
||||
list-users = Liste der Benutzer
|
||||
welcome-user = Herzlich willkommen {$username}
|
||||
logout = Abmelden
|
||||
login = Login
|
||||
|
||||
not-found = Dieser Link existiert nicht, oder wurde gelöscht.
|
||||
|
||||
edit-link-headline = Zu editierender Link: {$linktitle}
|
||||
edit-link = Link Editieren
|
||||
link-description = Beschreibung
|
||||
link-target = Link Ziel
|
||||
link-code = Link Code
|
||||
shortlink = Shortlink
|
||||
qr-code = QR-code
|
||||
|
||||
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.
|
||||
|
||||
save-edits = Speichere die Veränderungen
|
||||
delete-link = Diesen Link löschen
|
||||
|
||||
edit-user-headline = Benutzereinstellungen von: {$username}
|
||||
username = Benutzername
|
||||
email = Email
|
||||
password = Passwort
|
||||
password-placeholder = Leer lassen um das Passwort nicht zu ändern
|
||||
save-user = Benutzer speichern
|
||||
edit-user = Benutzer editieren
|
||||
make-user-admin = Zum Administrator befördern
|
||||
make-user-regular = Zurückstufen zum normalen Nutzer
|
||||
|
||||
userid = Benutzernummer
|
||||
statistics = Statistik
|
||||
@@ -0,0 +1,36 @@
|
||||
list-links = List of existing links
|
||||
add-link = Add a new link
|
||||
invite-user = Invite a new user
|
||||
list-users = List of existing users
|
||||
welcome-user = Welcome {$username}
|
||||
logout = Logout
|
||||
login = Login
|
||||
|
||||
not-found = This Link has not been found or has been deleted
|
||||
|
||||
edit-link-headline = Edit link: {$linktitle}
|
||||
edit-link = Edit link
|
||||
link-description = Description
|
||||
link-target = Link target
|
||||
link-code = Link code
|
||||
shortlink = Shortlink
|
||||
|
||||
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.
|
||||
|
||||
save-edits = Save edits
|
||||
delete-link = Delete this link
|
||||
|
||||
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
|
||||
save-user = Save this user
|
||||
edit-user = Edit this user
|
||||
make-user-admin = Promote to admin
|
||||
make-user-regular = Demote to regular
|
||||
|
||||
userid = User ID
|
||||
statistics = Statistics
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Add up migration script here
|
||||
DROP TABLE IF EXISTS __diesel_schema_migrations;
|
||||
|
||||
-- Add migration script here
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
username VARCHAR NOT NULL,
|
||||
email VARCHAR NOT NULL,
|
||||
password VARCHAR NOT NULL,
|
||||
role INTEGER DEFAULT 1 NOT NULL,
|
||||
UNIQUE(username),
|
||||
UNIQUE(email)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
title VARCHAR NOT NULL,
|
||||
target VARCHAR NOT NULL,
|
||||
code VARCHAR NOT NULL,
|
||||
author INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (author) REFERENCES users (id),
|
||||
UNIQUE (code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clicks (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
link INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (link) REFERENCES links (id)
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add migration script here
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN language Text NOT NULL DEFAULT "en";
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Add migration script here
|
||||
PRAGMA foreign_keys = off;
|
||||
|
||||
|
||||
CREATE TABLE new_clicks (
|
||||
id INTEGER PRIMARY KEY NOT NULL,
|
||||
link INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (link) REFERENCES links (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
new_clicks
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
clicks;
|
||||
|
||||
|
||||
DROP TABLE clicks;
|
||||
ALTER TABLE
|
||||
new_clicks RENAME TO clicks;
|
||||
|
||||
PRAGMA foreign_keys = on;
|
||||
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"db": "SQLite",
|
||||
"01e68928ea67ef301d8ea72a320fe747dafbfaa398a22731effb93d23ae16a77": {
|
||||
"query": "UPDATE links SET\n title = ?,\n target = ?,\n code = ?,\n author = ?,\n created_at = ? where id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 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": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"109ddc9fa55a36541ed1a866d362ff666bb39c3672f72000e786f86b514dc239": {
|
||||
"query": "Select * from users where id = ? ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"ordinal": 4,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"193ebdfd8bdb96da45f5054f83a6a5e23eaa311e3e5c4139095a3455f4764c64": {
|
||||
"query": "Select * from users",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"ordinal": 4,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"3ad5584fcb0c2685339e471320e8d0c091b684ffe86912a1f2540eee1444889d": {
|
||||
"query": "Insert into clicks (\n link,\n created_at) VALUES (?,?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"5919b8824209c31a76178f30b3d52f385931ee0f3aa17e65f8647ad15a3595d8": {
|
||||
"query": "Insert into links (\n title,\n target,\n code,\n author,\n created_at) VALUES (?,?,?,?,?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"5d892d21ed6b4ccc3ddedb3a7469385f001f6ff9f41b19faa67b754ad8f7fc4b": {
|
||||
"query": "Insert into users (\n username,\n email,\n password,\n role) VALUES (?,?,?,1)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"77a21769284cb3df457d806d6e04088ae4f99d92c535fd15c79828e46ee3ae6f": {
|
||||
"query": "UPDATE users SET role = ? where id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"7a52eae6cb72e4daac95a99d15113ab09571329733ea121fecc55d18dfdb1c45": {
|
||||
"query": "DELETE from links where code = ? ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"8670a2dacd013de68831d0a45d927ea9f473d86100387fc85c9b9802668c3de4": {
|
||||
"query": "Select * from users where username = ? ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "password",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"ordinal": 4,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"9cb2f491eab760ba60ede5b3e730b84d061fc09627579ff05bc63d3e27fe8fb7": {
|
||||
"query": "select count(*) as number from users where role = 2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "number",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"ba18635aa20b30d92172fa60fa22c6dba7e5cb2f57106e9d13cdab556af80fd3": {
|
||||
"query": "UPDATE users SET language = ? where id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
use clap::{
|
||||
app_from_crate, crate_authors, crate_description, crate_name, crate_version, App, Arg,
|
||||
ArgMatches, SubCommand,
|
||||
};
|
||||
use dotenv::dotenv;
|
||||
use shared::datatypes::User;
|
||||
use sqlx::{migrate::Migrator, Pool, Sqlite};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, BufRead, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use pslink::{
|
||||
models::{NewUser, UserDbOperations},
|
||||
ServerConfig, ServerError,
|
||||
};
|
||||
|
||||
use tracing::{error, info, trace, warn};
|
||||
|
||||
static MIGRATOR: Migrator = sqlx::migrate!();
|
||||
|
||||
#[allow(clippy::clippy::too_many_lines)]
|
||||
fn generate_cli() -> App<'static, 'static> {
|
||||
app_from_crate!()
|
||||
.arg(
|
||||
Arg::with_name("database")
|
||||
.long("db")
|
||||
.help("The path of the sqlite database")
|
||||
.env("PSLINK_DATABASE")
|
||||
.default_value("links.db")
|
||||
.global(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("port")
|
||||
.long("port")
|
||||
.short("p")
|
||||
.help("The port the pslink service will run on")
|
||||
.env("PSLINK_PORT")
|
||||
.default_value("8080")
|
||||
.global(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("public_url")
|
||||
.long("public-url")
|
||||
.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")
|
||||
.global(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("empty_forward_url")
|
||||
.long("empty-url")
|
||||
.short("e")
|
||||
.help("The the url that / will redirect to. Usually your homepage.")
|
||||
.env("PSLINK_EMPTY_FORWARD_URL")
|
||||
.default_value("https://github.com/enaut/pslink")
|
||||
.global(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("brand_name")
|
||||
.long("brand-name")
|
||||
.short("b")
|
||||
.help("The Brandname that will apper in various places.")
|
||||
.env("PSLINK_BRAND_NAME")
|
||||
.default_value("Pslink")
|
||||
.global(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("internal_ip")
|
||||
.long("hostip")
|
||||
.short("i")
|
||||
.help("The host (ip) that will run the pslink service")
|
||||
.env("PSLINK_IP")
|
||||
.default_value("localhost")
|
||||
.global(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("protocol")
|
||||
.long("protocol")
|
||||
.short("t")
|
||||
.help(concat!(
|
||||
"The protocol that is used in the qr-codes",
|
||||
" (http results in slightly smaller codes in some cases)"
|
||||
))
|
||||
.env("PSLINK_PROTOCOL")
|
||||
.default_value("http")
|
||||
.possible_values(&["http", "https"])
|
||||
.global(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("secret")
|
||||
.long("secret")
|
||||
.help(concat!(
|
||||
"The secret that is used to encrypt the",
|
||||
" password database keep this as inacessable as possible.",
|
||||
" As commandlineparameters are visible",
|
||||
" to all users",
|
||||
" it is not wise to use this as",
|
||||
" a commandline parameter but rather as an environment variable.",
|
||||
))
|
||||
.env("PSLINK_SECRET")
|
||||
.default_value("")
|
||||
.global(true),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("runserver")
|
||||
.about("Run the server")
|
||||
.display_order(1),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("migrate-database")
|
||||
.about("Apply any pending migrations and exit")
|
||||
.display_order(2),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("generate-env")
|
||||
.about("Generate an .env file template using default settings and exit")
|
||||
.display_order(2),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("create-admin")
|
||||
.about("Create an admin user.")
|
||||
.display_order(2),
|
||||
)
|
||||
}
|
||||
|
||||
async fn parse_args_to_config(config: ArgMatches<'_>) -> ServerConfig {
|
||||
let secret = config
|
||||
.value_of("secret")
|
||||
.expect("Failed to read the secret")
|
||||
.to_owned();
|
||||
let secret = if secret.len() < 5 {
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{thread_rng, Rng};
|
||||
|
||||
if secret.is_empty() {
|
||||
warn!("No secret was found! Use the environment variable PSLINK_SECRET to set one.");
|
||||
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.")
|
||||
}
|
||||
|
||||
thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(30)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
} else {
|
||||
secret
|
||||
};
|
||||
let secret = pslink::Secret::new(secret);
|
||||
let db = config
|
||||
.value_of("database")
|
||||
.expect(concat!(
|
||||
"Neither the DATABASE_URL environment variable",
|
||||
" nor the commandline parameters",
|
||||
" contain a valid database location."
|
||||
))
|
||||
.parse::<PathBuf>()
|
||||
.expect("Failed to parse Database path.");
|
||||
let db_pool = Pool::<Sqlite>::connect(&db.display().to_string())
|
||||
.await
|
||||
.expect("Error: Failed to connect to database!");
|
||||
let public_url = config
|
||||
.value_of("public_url")
|
||||
.expect("Failed to read the host value")
|
||||
.to_owned();
|
||||
let empty_forward_url = config
|
||||
.value_of("empty_forward_url")
|
||||
.expect("Failed to read the empty_forward_url value")
|
||||
.to_owned();
|
||||
let brand_name = config
|
||||
.value_of("brand_name")
|
||||
.expect("Failed to read the brand_name value")
|
||||
.to_owned();
|
||||
let internal_ip = config
|
||||
.value_of("internal_ip")
|
||||
.expect("Failed to read the host value")
|
||||
.to_owned();
|
||||
let port = config
|
||||
.value_of("port")
|
||||
.expect("Failed to read the port value")
|
||||
.parse::<u32>()
|
||||
.expect("Failed to parse the portnumber");
|
||||
let protocol = config
|
||||
.value_of("protocol")
|
||||
.expect("Failed to read the protocol value")
|
||||
.parse::<pslink::Protocol>()
|
||||
.expect("Failed to parse the protocol");
|
||||
|
||||
crate::ServerConfig {
|
||||
secret,
|
||||
db,
|
||||
db_pool,
|
||||
public_url,
|
||||
internal_ip,
|
||||
port,
|
||||
protocol,
|
||||
empty_forward_url,
|
||||
brand_name,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn setup() -> Result<Option<crate::ServerConfig>, ServerError> {
|
||||
dotenv().ok();
|
||||
|
||||
// Print launch info
|
||||
info!("Launching Pslink a 'Private short link generator'");
|
||||
trace!("logging initialized");
|
||||
|
||||
let app = generate_cli();
|
||||
|
||||
let config = app.get_matches();
|
||||
|
||||
let db = config
|
||||
.value_of("database")
|
||||
.expect(concat!(
|
||||
"Neither the DATABASE_URL environment variable",
|
||||
" nor the commandline parameters",
|
||||
" contain a valid database location."
|
||||
))
|
||||
.parse::<PathBuf>()
|
||||
.expect("Failed to parse Database path.");
|
||||
|
||||
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())
|
||||
{
|
||||
let msg = format!(
|
||||
concat!(
|
||||
"Database not found at {}!",
|
||||
" Create a new database with: `pslink migrate-database`",
|
||||
"or adjust the databasepath."
|
||||
),
|
||||
db.display()
|
||||
);
|
||||
error!("{}", msg);
|
||||
eprintln!("{}", msg);
|
||||
return Ok(None);
|
||||
}
|
||||
trace!("Creating database: {}", db.display());
|
||||
|
||||
// create an empty database file. The if above makes sure that this file does not exist.
|
||||
File::create(db)?;
|
||||
};
|
||||
let server_config: crate::ServerConfig = parse_args_to_config(config.clone()).await;
|
||||
|
||||
if let Some(_migrate_config) = config.subcommand_matches("generate-env") {
|
||||
return match generate_env_file(&server_config) {
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
}
|
||||
if let Some(_migrate_config) = config.subcommand_matches("migrate-database") {
|
||||
return match apply_migrations(&server_config).await {
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
}
|
||||
if let Some(_create_config) = config.subcommand_matches("create-admin") {
|
||||
return match create_admin(&server_config).await {
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(_runserver_config) = config.subcommand_matches("runserver") {
|
||||
let num_users = User::count_admins(&server_config).await?;
|
||||
|
||||
if num_users.number < 1 {
|
||||
warn!(concat!(
|
||||
"No admin user created you will not be",
|
||||
" able to do anything as the service is invite only.",
|
||||
" Create a user with `pslink create-admin`"
|
||||
));
|
||||
} else {
|
||||
trace!("At least one admin user is found.");
|
||||
}
|
||||
trace!("Initialization finished starting the service.");
|
||||
Ok(Some(server_config))
|
||||
} else {
|
||||
println!("{}", config.usage());
|
||||
Err(ServerError::User("Print usage.".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Interactively create a new admin user.
|
||||
async fn create_admin(config: &ServerConfig) -> Result<(), ServerError> {
|
||||
info!("Creating an admin user.");
|
||||
let sin = io::stdin();
|
||||
|
||||
// wait for logging:
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
print!("Please enter the Username of the admin: ");
|
||||
io::stdout().flush().unwrap();
|
||||
let new_username = sin.lock().lines().next().unwrap().unwrap();
|
||||
|
||||
print!("Please enter the emailadress for {}: ", new_username);
|
||||
io::stdout().flush().unwrap();
|
||||
let new_email = sin.lock().lines().next().unwrap().unwrap();
|
||||
|
||||
print!("Please enter the password for {}: ", new_username);
|
||||
io::stdout().flush().unwrap();
|
||||
let password = rpassword::read_password().unwrap();
|
||||
info!(
|
||||
"Creating {} ({}) with given password ",
|
||||
&new_username, &new_email
|
||||
);
|
||||
|
||||
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?;
|
||||
created_user.toggle_admin(config).await?;
|
||||
|
||||
info!("Admin user created: {}", new_username);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_migrations(config: &ServerConfig) -> Result<(), ServerError> {
|
||||
info!(
|
||||
"Creating a database file and running the migrations in the file {}:",
|
||||
&config.db.display()
|
||||
);
|
||||
MIGRATOR.run(&config.db_pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_env_file(server_config: &ServerConfig) -> Result<(), ServerError> {
|
||||
if std::path::Path::new(".env").exists() {
|
||||
return Err(ServerError::User(
|
||||
"ERROR: There already is a .env file - ABORT!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
r#"Creating a .env file with default options
|
||||
The SECRET_KEY variable is used for password encryption.
|
||||
If it is changed all existing passwords are invalid."#
|
||||
);
|
||||
|
||||
let mut file = std::fs::File::create(".env")?;
|
||||
let conf_file_content = server_config.to_env_strings();
|
||||
|
||||
for line in &conf_file_content {
|
||||
file.write_all(line.as_bytes())
|
||||
.expect("failed to write .env file")
|
||||
}
|
||||
info!("Successfully created the env file!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use serde::Deserialize;
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct LinkForm {
|
||||
pub title: String,
|
||||
pub target: String,
|
||||
pub code: String,
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
extern crate sqlx;
|
||||
|
||||
pub mod forms;
|
||||
pub mod models;
|
||||
pub mod queries;
|
||||
mod views;
|
||||
|
||||
use actix_files::Files;
|
||||
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)),
|
||||
)
|
||||
.service(
|
||||
web::scope("/json")
|
||||
.route("/list_links/", web::post().to(views::index_json))
|
||||
.route("/list_users/", web::post().to(views::index_users_json)),
|
||||
)
|
||||
// login to the admin area
|
||||
.route("/login/", web::get().to(views::login))
|
||||
.route("/login/", web::post().to(views::process_login)),
|
||||
)
|
||||
.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)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use crate::{forms::LinkForm, Secret, ServerConfig, ServerError};
|
||||
|
||||
use argonautica::Hasher;
|
||||
use async_trait::async_trait;
|
||||
use dotenv::dotenv;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use shared::datatypes::{Count, Link, User};
|
||||
|
||||
#[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: &str,
|
||||
) -> Result<(), ServerError>;
|
||||
async fn count_admins(server_config: &ServerConfig) -> Result<Count, ServerError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserDbOperations<User> for User {
|
||||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
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(())
|
||||
}
|
||||
|
||||
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.
|
||||
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,
|
||||
secret: &Secret,
|
||||
) -> Result<Self, ServerError> {
|
||||
let hash = Self::hash_password(password, secret)?;
|
||||
|
||||
Ok(Self {
|
||||
username,
|
||||
email,
|
||||
password: hash,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
.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,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait LinkDbOperations<T> {
|
||||
async fn get_link_by_code(code: &str, 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<Link> for Link {
|
||||
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)
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
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)]
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
use actix_identity::Identity;
|
||||
use actix_web::web;
|
||||
use enum_map::EnumMap;
|
||||
use serde::Serialize;
|
||||
use shared::{
|
||||
apirequests::{
|
||||
general::{Filter, Operation, Ordering},
|
||||
links::{LinkOverviewColumns, LinkRequestForm},
|
||||
users::{UserOverviewColumns, UserRequestForm},
|
||||
},
|
||||
datatypes::{Count, FullLink, Link, User},
|
||||
};
|
||||
use sqlx::Row;
|
||||
use tracing::{info, instrument, warn};
|
||||
|
||||
use super::models::NewUser;
|
||||
use crate::{
|
||||
forms::LinkForm,
|
||||
models::{LinkDbOperations, NewClick, NewLink, UserDbOperations},
|
||||
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.
|
||||
#[instrument(skip(id))]
|
||||
pub async fn authenticate(
|
||||
id: &Identity,
|
||||
server_config: &ServerConfig,
|
||||
) -> Result<Role, 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(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
|
||||
#[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.
|
||||
///
|
||||
/// # 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 crate::sqlx::Row;
|
||||
match authenticate(id, server_config).await? {
|
||||
Role::Admin { user } | Role::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));
|
||||
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: "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(ListWithOwner {
|
||||
user,
|
||||
list: all_links,
|
||||
})
|
||||
}
|
||||
Role::Disabled | Role::NotAuthenticated => Err(ServerError::User("Not allowed".to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
macro_rules! ts {
|
||||
($ordering:expr) => {
|
||||
match $ordering {
|
||||
Ordering::Ascending => "ASC",
|
||||
Ordering::Descending => "DESC",
|
||||
};
|
||||
};
|
||||
}
|
||||
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
|
||||
///
|
||||
/// # Errors
|
||||
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
|
||||
#[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? {
|
||||
Role::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: "invalid".to_owned(),
|
||||
role: v.get("role"),
|
||||
language: v.get("language"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ListWithOwner { user, list: users })
|
||||
}
|
||||
_ => Err(ServerError::User(
|
||||
"Administrator permissions required".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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::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 {
|
||||
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.
|
||||
#[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: &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.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,
|
||||
})
|
||||
}
|
||||
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)]
|
||||
#[instrument(skip(id))]
|
||||
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.secret)?
|
||||
} 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.
|
||||
#[instrument(skip(id))]
|
||||
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.
|
||||
#[instrument(skip(id))]
|
||||
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()))
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
warn!("An invalid language was selected!");
|
||||
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.
|
||||
#[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? {
|
||||
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 => {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(id))]
|
||||
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: Item<Link> = 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.
|
||||
#[instrument(skip(id))]
|
||||
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: 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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
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 shared::apirequests::{links::LinkRequestForm, users::UserRequestForm};
|
||||
use tera::{Context, Tera};
|
||||
use tracing::{error, info, instrument, 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)
|
||||
}
|
||||
|
||||
#[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>Server integration example</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 '/app/pkg/package.js';
|
||||
init('/app/pkg/package_bg.wasm');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>"#,
|
||||
))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
match queries::list_all_allowed(&id, &config, LinkRequestForm::default()).await {
|
||||
Ok(links) => {
|
||||
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))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to get the links: {:?}", e);
|
||||
Ok(redirect_builder("/admin/login/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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(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, UserRequestForm::default()).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))]
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
#[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 } => {
|
||||
info!(
|
||||
"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(skip(tera))]
|
||||
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/"))
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 100%;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,28 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="center">
|
||||
<h3>{{ fluent(key="not-found", lang=language) }}</h3>
|
||||
<h2> </h2>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,349 @@
|
||||
#[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/"));
|
||||
}
|
||||
Reference in New Issue
Block a user