Compare commits

..

45 Commits

Author SHA1 Message Date
1611cfb9a2
Merge pull request #8 from enaut/tracing
Add tracing (eg. jaeger) and tests
2021-04-18 17:20:29 +02:00
680d28ed58
Merge branch 'master' into tracing 2021-04-18 16:40:55 +02:00
b33088057d
Add basic github actions 2021-04-18 16:18:23 +02:00
84625939de
Add more testcases 2021-04-18 16:11:43 +02:00
04170079d6
Add integration test for runserver 2021-04-18 11:38:07 +02:00
7690d301f1
Properly handle absense of a prefered language 2021-04-18 11:37:02 +02:00
322c867e94
Fix: real secret instead of censored in .env 2021-04-18 10:29:09 +02:00
ce315c429c
Add integration tests, do not show secret in logs
The code is restructured so that the library contains the actix-web
code and the binary only does commandline parsing and running of
the lib.
2021-04-18 09:41:17 +02:00
6fd36936a3
Enable jaeger + opentracing logging 2021-04-12 16:32:59 +02:00
ac172670be
simplifying apply_migrations 2021-04-12 16:30:18 +02:00
a3b757abad initial port to tracing 2021-04-11 13:14:11 +02:00
ea75e1e3ee
Show some errors to the user 2021-04-10 18:37:59 +02:00
5950fa7370
Better logging and error handling 2021-04-10 12:30:05 +02:00
76b1f53120
Use the anyhow crate for Errors in the cli 2021-04-02 17:41:27 +02:00
583e9908bf
move the error to thiserror 2021-04-01 15:40:19 +02:00
34b957332e
Sepparate library part and binary part
closes #5
2021-04-01 14:58:40 +02:00
0ed010b78f
fix errormessage 2021-04-01 10:02:12 +02:00
a6598ba5db clicks are deleted when connected links are
fixes #7
2021-04-01 10:00:16 +02:00
2fba9080d6 Revert "format migration"
as migrations store a hash it is not possible to change the
migrationfiles afterwards.

This reverts commit fdcc89ec1f.
2021-04-01 10:00:16 +02:00
053992e30c
Merge pull request #6 from enaut/WIP--translations
Make the page translatable
2021-03-31 16:50:13 +02:00
fdcc89ec1f
format migration 2021-03-31 16:40:25 +02:00
dd759923f0
Add a language selector for logged in users. 2021-03-30 21:45:46 +02:00
0b9afb1fa3
translate login and not_found to browser language 2021-03-30 20:23:27 +02:00
c6c56a5ea2
translate view_link, view_profile 2021-03-30 18:05:03 +02:00
aac653f6cb
translate signup and link submission 2021-03-30 17:47:18 +02:00
3cb6784448
translate linklist index 2021-03-30 17:43:35 +02:00
cada51f768
translate index_users 2021-03-30 17:39:55 +02:00
59ad7089df
translate edit_profile 2021-03-30 17:33:04 +02:00
20506d1bbe
translate edit_link 2021-03-30 17:16:44 +02:00
218840432f
initial translatability 2021-03-30 13:18:13 +02:00
7fd29d4903
add changes to release guide 2021-03-27 11:26:10 +01:00
77f362368c
bump version + add release guides 2021-03-27 11:23:02 +01:00
453cccde8b
Improve Readme 2021-03-27 11:09:41 +01:00
81c636de56
Add offline compilablility (sqlx) 2021-03-27 10:14:14 +01:00
c646dcf263 move url to github as it has a more official touch 2021-03-27 07:37:59 +01:00
5218efe551 password hashes should stop appearing in logs 2021-03-27 07:37:59 +01:00
c5c8f27ae6
Merge pull request #1 from enaut/move-to-sqlx
Move diesel to sqlx
2021-03-21 13:40:34 +01:00
a4d5982e3c
make initial migrations work (no db file exists). 2021-03-21 13:02:55 +01:00
e8ff696006
working sqlx-port 2021-03-21 08:31:47 +01:00
e8f955220a
remove old dependency diesel_codegen 2021-03-15 15:40:31 +01:00
6a87a0b88c
Bump Version 2021-03-15 14:42:01 +01:00
5402f43432
Fix categories 2021-03-15 14:38:34 +01:00
ccb13ce4ad
Use unpatched version of actix-web-static-files 2021-03-15 14:33:45 +01:00
d5a4541b7a
bump version before publishing 2021-03-15 14:17:18 +01:00
7d35357531
update categories and keywords 2021-03-15 14:06:28 +01:00
38 changed files with 3539 additions and 1399 deletions

22
.github/workflows/rust.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: Rust
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose

3
.gitignore vendored
View File

@ -1,6 +1,7 @@
/target /target
.env .env
links.db links.db*
launch.json launch.json
settings.json settings.json
links.session.sql links.session.sql
sqltemplates

2169
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,46 +1,73 @@
[package] [package]
name = "pslink"
version = "0.1.0"
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."
authors = ["Dietrich <dietrich@teilgedanken.de>"] authors = ["Dietrich <dietrich@teilgedanken.de>"]
edition = "2018"
license = "MIT OR Apache-2.0"
keywords = ["url", "link", "webpage", "actix"]
categories = ["os", "os::linux-apis", "parser-implementations", "command-line-utilities"]
readme = "README.md"
repository = "https://git.teilgedanken.de/dietrich/Pslink"
build = "build.rs" 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."
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 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] [dependencies]
actix-web = "3"
actix-web-static-files = { git = "https://github.com/enaut/actix-web-static-files.git", branch = "enaut-must_use" }
actix-slog = "0.2"
tera = "1.6"
serde = "1.0"
diesel = { version = "1.4", features = ["sqlite", "chrono"] }
diesel_codegen = { version = "0.16.1", features = ["sqlite"] }
diesel_migrations = "1.4"
libsqlite3-sys = { version = "0.8", features = ["bundled"] }
dotenv = "0.10.1"
actix-identity = "0.3" actix-identity = "0.3"
chrono = { version = "0.4", features = ["serde"] } actix-rt = "1.1"
actix-slog = "0.2"
actix-web = "3"
actix-web-static-files = "3.0"
anyhow = "1.0"
argonautica = "0.2" argonautica = "0.2"
slog = "2"
slog-term = "2"
slog-async = "2"
qrcode = "0.12"
image = "0.23"
rand="0.8"
rpassword = "5.0"
clap = "2.33" 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"
[build-dependencies] [dependencies.chrono]
actix-web-static-files = { git = "https://github.com/enaut/actix-web-static-files.git", branch = "enaut-must_use" } features = ["serde"]
version = "0.4"
# optimize for size at cost of compilation speed. [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] [profile.release]
#lto = true lto = true
#codegen-units = 1 #codegen-units = 1

View File

@ -2,41 +2,91 @@
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). 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.
![Screenshot](./doc/img/pslinkscreenshot.png) ![Screenshot](./doc/img/pslinkscreenshot.png)
The Page comes with a basic commandline interface to setup the environment. If it 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. The Page comes with a basic commandline interface to setup the environment.
Templates and migrations are embedded in the binary. So it should run standalone without anything extra.
## usage ## Usage
### setup ### 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: 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. 1. `pslink generate-env`
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 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`. If everything is correctly set up just do `pslink runserver`.
### update ### Update
To update to a newer version execute the commands in the following order To update to a newer version execute the commands in the following order
1. stop the service 1. stop the service
2. run `pslink migrate-database` 2. download and install the new binary
3. run the server again `pslink runserver` 3. run `pslink migrate-database`
4. run the server again `pslink runserver`
### help ### Help
For a list of options use `pslink 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 ### 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 are 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. 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 ```systemd
# /etc/systemd/system/pslink.service # /etc/systemd/system/pslink.service

19
Release.md Normal file
View File

@ -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```

View File

@ -1,5 +0,0 @@
# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"

36
locales/de/main.ftl Normal file
View File

@ -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

36
locales/en/main.ftl Normal file
View File

@ -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

View File

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

View File

@ -1,29 +0,0 @@
-- Your SQL goes here
CREATE TABLE users
(
id INTEGER PRIMARY KEY NOT NULL,
username VARCHAR NOT NULL,
email VARCHAR NOT NULL,
password VARCHAR NOT NULL,
UNIQUE(username, email)
);
CREATE TABLE 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)
);

View File

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

View File

@ -1,13 +0,0 @@
-- Your SQL goes here
CREATE TABLE clicks
(
id INTEGER PRIMARY KEY NOT NULL,
link INT NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY
(link)
REFERENCES links
(id)
);

View File

@ -1,20 +0,0 @@
-- This file should undo anything in `up.sql`
CREATE TABLE usersold
(
id INTEGER PRIMARY KEY NOT NULL,
username VARCHAR NOT NULL,
email VARCHAR NOT NULL,
password VARCHAR NOT NULL,
UNIQUE(username, email)
);
INSERT INTO usersold
SELECT id,username,email,password
FROM users;
DROP TABLE users;
ALTER TABLE usersold
RENAME TO users;

View File

@ -1,6 +0,0 @@
-- Your SQL goes here
ALTER TABLE users ADD COLUMN role INTEGER DEFAULT 1 NOT NULL;
UPDATE users SET role=2 where id is 1;

View File

@ -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)
);

View File

@ -0,0 +1,4 @@
-- Add migration script here
ALTER TABLE users
ADD COLUMN language Text NOT NULL DEFAULT "en";

View File

@ -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;

293
sqlx-data.json Normal file
View File

@ -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": []
}
}
}

View File

@ -2,17 +2,19 @@ use clap::{
app_from_crate, crate_authors, crate_description, crate_name, crate_version, App, Arg, app_from_crate, crate_authors, crate_description, crate_name, crate_version, App, Arg,
ArgMatches, SubCommand, ArgMatches, SubCommand,
}; };
use diesel::prelude::*;
use dotenv::dotenv; use dotenv::dotenv;
use sqlx::{migrate::Migrator, Pool, Sqlite};
use std::{ use std::{
fs::File,
io::{self, BufRead, Write}, io::{self, BufRead, Write},
path::PathBuf, path::PathBuf,
}; };
use crate::{models::NewUser, ServerConfig, ServerError}; use pslink::{models::NewUser, models::User, ServerConfig, ServerError};
use crate::{queries, schema};
use slog::{Drain, Logger}; use tracing::{error, info, trace, warn};
static MIGRATOR: Migrator = sqlx::migrate!();
#[allow(clippy::clippy::too_many_lines)] #[allow(clippy::clippy::too_many_lines)]
fn generate_cli() -> App<'static, 'static> { fn generate_cli() -> App<'static, 'static> {
@ -120,7 +122,7 @@ fn generate_cli() -> App<'static, 'static> {
) )
} }
fn parse_args_to_config(config: &ArgMatches, log: &Logger) -> ServerConfig { async fn parse_args_to_config(config: ArgMatches<'_>) -> ServerConfig {
let secret = config let secret = config
.value_of("secret") .value_of("secret")
.expect("Failed to read the secret") .expect("Failed to read the secret")
@ -130,20 +132,11 @@ fn parse_args_to_config(config: &ArgMatches, log: &Logger) -> ServerConfig {
use rand::{thread_rng, Rng}; use rand::{thread_rng, Rng};
if secret.is_empty() { if secret.is_empty() {
slog_warn!( warn!("No secret was found! Use the environment variable PSLINK_SECRET to set one.");
log, warn!("If you change the secret all passwords will be invalid");
"No secret was found! Use the environment variable PSLINK_SECRET to set one." warn!("Using an auto generated one for this run.");
);
slog_warn!(
log,
"If you change the secret all passwords will be invalid"
);
slog_warn!(log, "Using an auto generated one for this run.");
} else { } else {
slog_warn!( warn!("The provided secret was too short. Using an autogenerated one.")
log,
"The provided secret was too short. Using an autogenerated one."
)
} }
thread_rng() thread_rng()
@ -154,6 +147,7 @@ fn parse_args_to_config(config: &ArgMatches, log: &Logger) -> ServerConfig {
} else { } else {
secret secret
}; };
let secret = pslink::Secret::new(secret);
let db = config let db = config
.value_of("database") .value_of("database")
.expect(concat!( .expect(concat!(
@ -163,6 +157,9 @@ fn parse_args_to_config(config: &ArgMatches, log: &Logger) -> ServerConfig {
)) ))
.parse::<PathBuf>() .parse::<PathBuf>()
.expect("Failed to parse Database path."); .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 let public_url = config
.value_of("public_url") .value_of("public_url")
.expect("Failed to read the host value") .expect("Failed to read the host value")
@ -187,40 +184,66 @@ fn parse_args_to_config(config: &ArgMatches, log: &Logger) -> ServerConfig {
let protocol = config let protocol = config
.value_of("protocol") .value_of("protocol")
.expect("Failed to read the protocol value") .expect("Failed to read the protocol value")
.parse::<crate::Protocol>() .parse::<pslink::Protocol>()
.expect("Failed to parse the protocol"); .expect("Failed to parse the protocol");
let log = log.new(slog_o!("host" => public_url.clone()));
crate::ServerConfig { crate::ServerConfig {
secret, secret,
db, db,
db_pool,
public_url, public_url,
internal_ip, internal_ip,
port, port,
protocol, protocol,
log,
empty_forward_url, empty_forward_url,
brand_name, brand_name,
} }
} }
pub(crate) fn setup() -> Result<Option<crate::ServerConfig>, ServerError> { pub(crate) async fn setup() -> Result<Option<crate::ServerConfig>, ServerError> {
dotenv().ok(); dotenv().ok();
let decorator = slog_term::TermDecorator::new().build(); // Print launch info
let drain = slog_term::FullFormat::new(decorator).build().fuse(); info!("Launching Pslink a 'Private short link generator'");
let drain = slog_async::Async::new(drain).build().fuse(); trace!("logging initialized");
let log = slog::Logger::root(drain, slog_o!("name" => "Pslink"));
slog_info!(log, "Launching Pslink a 'Private short link generator'");
slog_info!(log, ".env file setup, logging initialized");
let app = generate_cli(); let app = generate_cli();
let config = app.get_matches(); let config = app.get_matches();
let server_config: crate::ServerConfig = parse_args_to_config(&config, &log); 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") { if let Some(_migrate_config) = config.subcommand_matches("generate-env") {
return match generate_env_file(&server_config) { return match generate_env_file(&server_config) {
@ -229,54 +252,31 @@ pub(crate) fn setup() -> Result<Option<crate::ServerConfig>, ServerError> {
}; };
} }
if let Some(_migrate_config) = config.subcommand_matches("migrate-database") { if let Some(_migrate_config) = config.subcommand_matches("migrate-database") {
return match apply_migrations(&server_config) { return match apply_migrations(&server_config).await {
Ok(_) => Ok(None), Ok(_) => Ok(None),
Err(e) => Err(e), Err(e) => Err(e),
}; };
} }
if let Some(_create_config) = config.subcommand_matches("create-admin") { if let Some(_create_config) = config.subcommand_matches("create-admin") {
return match create_admin(&server_config) { return match create_admin(&server_config).await {
Ok(_) => Ok(None), Ok(_) => Ok(None),
Err(e) => Err(e), Err(e) => Err(e),
}; };
} }
if let Some(_runserver_config) = config.subcommand_matches("runserver") { if let Some(_runserver_config) = config.subcommand_matches("runserver") {
let connection = if server_config.db.exists() { let num_users = User::count_admins(&server_config).await?;
queries::establish_connection(&server_config.db)?
} else {
let msg = format!(
concat!(
"Database not found at {}!",
" Create a new database with: `pslink migrate-database`",
"or adjust the databasepath."
),
server_config.db.display()
);
slog_error!(&server_config.log, "{}", msg);
eprintln!("{}", msg);
return Ok(None);
};
let num_users: i64 = schema::users::dsl::users
.filter(schema::users::dsl::role.eq(2))
.select(diesel::dsl::count_star())
.first(&connection)
.expect("Failed to count the users");
if num_users < 1 { if num_users.number < 1 {
slog_warn!( warn!(concat!(
&server_config.log, "No admin user created you will not be",
concat!( " able to do anything as the service is invite only.",
"No user created you will not be", " Create a user with `pslink create-admin`"
" 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.");
);
} }
slog_info!( trace!("Initialization finished starting the service.");
&server_config.log,
"Initialization finished starting the service."
);
Ok(Some(server_config)) Ok(Some(server_config))
} else { } else {
println!("{}", config.usage()); println!("{}", config.usage());
@ -285,14 +285,10 @@ pub(crate) fn setup() -> Result<Option<crate::ServerConfig>, ServerError> {
} }
/// Interactively create a new admin user. /// Interactively create a new admin user.
fn create_admin(config: &ServerConfig) -> Result<(), ServerError> { async fn create_admin(config: &ServerConfig) -> Result<(), ServerError> {
use schema::users; info!("Creating an admin user.");
use schema::users::dsl::{email, role, username};
slog_info!(&config.log, "Creating an admin user.");
let sin = io::stdin(); let sin = io::stdin();
let connection = queries::establish_connection(&config.db)?;
// wait for logging: // wait for logging:
std::thread::sleep(std::time::Duration::from_millis(100)); std::thread::sleep(std::time::Duration::from_millis(100));
@ -307,69 +303,52 @@ fn create_admin(config: &ServerConfig) -> Result<(), ServerError> {
print!("Please enter the password for {}: ", new_username); print!("Please enter the password for {}: ", new_username);
io::stdout().flush().unwrap(); io::stdout().flush().unwrap();
let password = rpassword::read_password().unwrap(); let password = rpassword::read_password().unwrap();
slog_info!( info!(
&config.log,
"Creating {} ({}) with given password ", "Creating {} ({}) with given password ",
&new_username, &new_username, &new_email
&new_email
); );
let new_admin = NewUser::new(new_username.clone(), new_email.clone(), &password, config)?; let new_admin = NewUser::new(new_username.clone(), new_email.clone(), &password, config)?;
diesel::insert_into(users::table) new_admin.insert_user(config).await?;
.values(&new_admin) let created_user = User::get_user_by_name(&new_username, config).await?;
.execute(&connection)?; created_user.toggle_admin(config).await?;
let created_user = users::table info!("Admin user created: {}", new_username);
.filter(username.eq(new_username))
.filter(email.eq(new_email));
// Add admin rights to the user identified by (username, email) this should be unique according to sqlite constraints
diesel::update(created_user)
.set((role.eq(2),))
.execute(&connection)?;
slog_info!(&config.log, "Admin user created: {}", &new_admin.username);
Ok(()) Ok(())
} }
fn apply_migrations(config: &ServerConfig) -> Result<(), ServerError> { async fn apply_migrations(config: &ServerConfig) -> Result<(), ServerError> {
slog_info!( info!(
config.log,
"Creating a database file and running the migrations in the file {}:", "Creating a database file and running the migrations in the file {}:",
&config.db.display() &config.db.display()
); );
let connection = queries::establish_connection(&config.db)?; MIGRATOR.run(&config.db_pool).await?;
crate::embedded_migrations::run_with_output(&connection, &mut std::io::stdout())?;
Ok(()) Ok(())
} }
fn generate_env_file(server_config: &ServerConfig) -> Result<(), ServerError> { fn generate_env_file(server_config: &ServerConfig) -> Result<(), ServerError> {
if std::path::Path::new(".env").exists() { if std::path::Path::new(".env").exists() {
slog_error!( return Err(ServerError::User(
server_config.log, "ERROR: There already is a .env file - ABORT!".to_string(),
"ERROR: There already is a .env file - ABORT!" ));
)
} else {
slog_info!(
server_config.log,
"Creating a .env file with default options"
);
slog_info!(
server_config.log,
concat!(
"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();
conf_file_content.iter().for_each(|l| {
file.write_all(l.as_bytes())
.expect("failed to write .env file")
});
slog_info!(server_config.log, "Successfully created the env file!")
} }
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(()) Ok(())
} }

70
src/bin/pslink/main.rs Normal file
View File

@ -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);
}
}
}

View File

@ -1,134 +1,135 @@
#[macro_use] extern crate sqlx;
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
#[allow(unused_imports)]
#[macro_use(
slog_o,
slog_info,
slog_warn,
slog_error,
slog_log,
slog_record,
slog_record_static,
slog_b,
slog_kv
)]
extern crate slog;
extern crate slog_async;
mod cli; pub mod forms;
mod forms;
pub mod models; pub mod models;
mod queries; pub mod queries;
pub mod schema;
mod views; mod views;
use std::{fmt::Display, path::PathBuf, str::FromStr};
use actix_identity::{CookieIdentityPolicy, IdentityService}; use actix_identity::{CookieIdentityPolicy, IdentityService};
use actix_web::{web, App, HttpResponse, HttpServer}; use actix_web::HttpResponse;
use actix_web::{web, App, HttpServer};
use fluent_templates::{static_loader, FluentLoader};
use qrcode::types::QrError; use qrcode::types::QrError;
use sqlx::{Pool, Sqlite};
use std::{fmt::Display, path::PathBuf, str::FromStr};
use tera::Tera; use tera::Tera;
use thiserror::Error;
use tracing::instrument;
use tracing::{error, info, trace};
use tracing_actix_web::TracingLogger;
#[derive(Debug)] #[derive(Error, Debug)]
pub enum ServerError { pub enum ServerError {
Argonautic, #[error("Failed to encrypt the password {0} - aborting!")]
Diesel(diesel::result::Error), Argonautica(argonautica::Error),
Migration(diesel_migrations::RunMigrationsError), #[error("The database could not be used: {0}")]
Environment, Database(#[from] sqlx::Error),
Template(tera::Error), #[error("The database could not be migrated: {0}")]
Qr(QrError), DatabaseMigration(#[from] sqlx::migrate::MigrateError),
Io(std::io::Error), #[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), User(String),
} }
impl std::fmt::Display for ServerError { impl From<argonautica::Error> for ServerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn from(e: argonautica::Error) -> Self {
match self { Self::Argonautica(e)
Self::Argonautic => write!(f, "Argonautica Error"), }
Self::Diesel(e) => write!(f, "Diesel Error: {}", e), }
Self::Environment => write!(f, "Environment Error"),
Self::Template(e) => write!(f, "Template Error: {:?}", e), impl ServerError {
Self::Qr(e) => write!(f, "Qr Code Error: {:?}", e), fn render_error(title: &str, content: &str) -> String {
Self::Io(e) => write!(f, "IO Error: {:?}", e), format!(
Self::Migration(e) => write!(f, "Migration Error: {:?}", e), "<!DOCTYPE html>
Self::User(data) => write!(f, "{}", data), <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 { impl actix_web::error::ResponseError for ServerError {
fn error_response(&self) -> HttpResponse { fn error_response(&self) -> HttpResponse {
match self { match self {
Self::Argonautic => HttpResponse::InternalServerError().json("Argonautica Error"), Self::Argonautica(e) => {
Self::Diesel(e) => { eprintln!("Argonautica Error happened: {:?}", e);
HttpResponse::InternalServerError().json(format!("Diesel Error: {:?}", 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::Environment => HttpResponse::InternalServerError().json("Environment Error"),
Self::Template(e) => { Self::Template(e) => {
HttpResponse::InternalServerError().json(format!("Template Error: {:?}", e)) eprintln!("Template Error happened: {:?}", e);
HttpResponse::InternalServerError().body(&Self::render_error(
"Server Error",
"The templates could not be rendered.",
))
} }
Self::Qr(e) => { Self::Qr(e) => {
HttpResponse::InternalServerError().json(format!("Qr Code Error: {:?}", e)) eprintln!("QR Error happened: {:?}", e);
HttpResponse::InternalServerError().body(&Self::render_error(
"Server Error",
"Could not generate the QR-code!",
))
} }
Self::Io(e) => HttpResponse::InternalServerError().json(format!("IO Error: {:?}", e)), Self::Io(e) => {
Self::Migration(e) => { eprintln!("Io Error happened: {:?}", e);
HttpResponse::InternalServerError().json(format!("Migration Error: {:?}", 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),
))
} }
Self::User(data) => HttpResponse::InternalServerError().json(data),
} }
} }
} }
impl From<std::env::VarError> for ServerError {
fn from(e: std::env::VarError) -> Self {
eprintln!("Environment error {:?}", e);
Self::Environment
}
}
impl From<diesel_migrations::RunMigrationsError> for ServerError {
fn from(e: diesel_migrations::RunMigrationsError) -> Self {
Self::Migration(e)
}
}
impl From<diesel::result::Error> for ServerError {
fn from(err: diesel::result::Error) -> Self {
eprintln!("Database error {:?}", err);
Self::Diesel(err)
}
}
impl From<argonautica::Error> for ServerError {
fn from(e: argonautica::Error) -> Self {
eprintln!("Authentication error {:?}", e);
Self::Argonautic
}
}
impl From<tera::Error> for ServerError {
fn from(e: tera::Error) -> Self {
eprintln!("Template error {:?}", e);
Self::Template(e)
}
}
impl From<QrError> for ServerError {
fn from(e: QrError) -> Self {
eprintln!("Template error {:?}", e);
Self::Qr(e)
}
}
impl From<std::io::Error> for ServerError {
fn from(e: std::io::Error) -> Self {
eprintln!("IO error {:?}", e);
Self::Io(e)
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Protocol { pub enum Protocol {
Http, Http,
Https, Https,
} }
@ -154,20 +155,45 @@ impl FromStr for Protocol {
} }
} }
#[derive(Debug, Clone)] #[derive(Clone)]
pub(crate) struct ServerConfig { pub struct Secret {
secret: String, secret: String,
db: PathBuf, }
public_url: String,
internal_ip: String, impl Secret {
port: u32, #[must_use]
protocol: Protocol, pub const fn new(secret: String) -> Self {
log: slog::Logger, Self { secret }
empty_forward_url: String, }
brand_name: String, }
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 { impl ServerConfig {
#[must_use]
pub fn to_env_strings(&self) -> Vec<String> { pub fn to_env_strings(&self) -> Vec<String> {
vec![ vec![
format!("PSLINK_DATABASE=\"{}\"\n", self.db.display()), format!("PSLINK_DATABASE=\"{}\"\n", self.db.display()),
@ -182,17 +208,27 @@ impl ServerConfig {
"# If it is changed all existing passwords are invalid.\n" "# If it is changed all existing passwords are invalid.\n"
) )
.to_owned(), .to_owned(),
format!("PSLINK_SECRET=\"{}\"\n", self.secret), format!("PSLINK_SECRET=\"{}\"\n", self.secret.secret),
] ]
} }
} }
include!(concat!(env!("OUT_DIR"), "/generated.rs")); include!(concat!(env!("OUT_DIR"), "/generated.rs"));
embed_migrations!("migrations/");
fn build_tera() -> Tera { static_loader! {
static LOCALES = {
locales: "./locales",
fallback_language: "en",
};
}
#[instrument]
fn build_tera() -> Result<Tera, ServerError> {
let mut tera = Tera::default(); let mut tera = Tera::default();
// Add translation support
tera.register_function("fluent", FluentLoader::new(&*LOCALES));
tera.add_raw_templates(vec![ tera.add_raw_templates(vec![
("admin.html", include_str!("../templates/admin.html")), ("admin.html", include_str!("../templates/admin.html")),
("base.html", include_str!("../templates/base.html")), ("base.html", include_str!("../templates/base.html")),
@ -227,42 +263,43 @@ fn build_tera() -> Tera {
"view_profile.html", "view_profile.html",
include_str!("../templates/view_profile.html"), include_str!("../templates/view_profile.html"),
), ),
]) ])?;
.expect("failed to parse templates"); Ok(tera)
tera
} }
#[allow(clippy::future_not_send)] /// Launch the pslink-webservice
async fn webservice(server_config: ServerConfig) -> std::io::Result<()> { ///
/// # 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); let host_port = format!("{}:{}", &server_config.internal_ip, &server_config.port);
info!(
slog_info!(
server_config.log,
"Running on: {}://{}/admin/login/", "Running on: {}://{}/admin/login/",
&server_config.protocol, &server_config.protocol, host_port
host_port
); );
slog_info!( info!(
server_config.log,
"If the public url is set up correctly it should be accessible via: {}://{}/admin/login/", "If the public url is set up correctly it should be accessible via: {}://{}/admin/login/",
&server_config.protocol, &server_config.protocol, &server_config.public_url
&server_config.public_url
); );
let tera = build_tera().expect("Failed to build Templates");
trace!("The tera templates are ready");
HttpServer::new(move || { let server = HttpServer::new(move || {
let tera = build_tera(); //Tera::new("templates/**/*").expect("failed to initialize the templates");
let generated = generate(); let generated = generate();
App::new() App::new()
.data(server_config.clone()) .data(server_config.clone())
.wrap(actix_slog::StructuredLogger::new( .wrap(TracingLogger)
server_config.log.new(slog_o!("log_type" => "access")),
))
.wrap(IdentityService::new( .wrap(IdentityService::new(
CookieIdentityPolicy::new(&[0; 32]) CookieIdentityPolicy::new(&[0; 32])
.name("auth-cookie") .name("auth-cookie")
.secure(false), .secure(false),
)) ))
.data(tera) .data(tera.clone())
.service(actix_web_static_files::ResourceFiles::new( .service(actix_web_static_files::ResourceFiles::new(
"/static", generated, "/static", generated,
)) ))
@ -313,7 +350,11 @@ async fn webservice(server_config: ServerConfig) -> std::io::Result<()> {
web::post().to(views::process_edit_profile), web::post().to(views::process_edit_profile),
), ),
) )
.route("/set_admin/{user_id}", web::get().to(views::toggle_admin)), .route("/set_admin/{user_id}", web::get().to(views::toggle_admin))
.route(
"/set_language/{language}",
web::get().to(views::set_language),
),
) )
.service( .service(
web::scope("/delete").service( web::scope("/delete").service(
@ -332,23 +373,11 @@ async fn webservice(server_config: ServerConfig) -> std::io::Result<()> {
// redirect to the url hidden behind the code // redirect to the url hidden behind the code
.route("/{redirect_id}", web::get().to(views::redirect)) .route("/{redirect_id}", web::get().to(views::redirect))
}) })
.bind(host_port)? .bind(host_port)
.run() .map_err(|e| {
.await error!("Failed to bind to port!");
} e
})?
#[actix_web::main] .run();
async fn main() -> Result<(), std::io::Error> { Ok(server)
match cli::setup() {
Ok(Some(server_config)) => webservice(server_config).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);
}
}
} }

View File

@ -1,22 +1,115 @@
use crate::{forms::LinkForm, ServerConfig, ServerError}; use crate::{forms::LinkForm, ServerConfig, ServerError};
use super::schema::{clicks, links, users};
use argonautica::Hasher; use argonautica::Hasher;
use diesel::{Identifiable, Insertable, Queryable};
use dotenv::dotenv; use dotenv::dotenv;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Identifiable, Queryable, PartialEq, Serialize, Clone, Debug)] #[derive(PartialEq, Serialize, Clone, Debug)]
pub struct User { pub struct User {
pub id: i32, pub id: i64,
pub username: String, pub username: String,
pub email: String, pub email: String,
pub password: String, pub password: String,
pub role: i32, pub role: i64,
pub language: String,
} }
#[derive(Debug, Deserialize, Insertable)] impl User {
#[table_name = "users"] pub(crate) async fn get_user(
id: i64,
server_config: &ServerConfig,
) -> Result<Self, ServerError> {
let user = sqlx::query_as!(Self, "Select * from users where id = ? ", id)
.fetch_one(&server_config.db_pool)
.await;
user.map_err(ServerError::Database)
}
/// get a user by its username
///
/// # Errors
/// fails with [`ServerError`] if the user does not exist or the database cannot be acessed.
pub async fn get_user_by_name(
name: &str,
server_config: &ServerConfig,
) -> Result<Self, ServerError> {
let user = sqlx::query_as!(Self, "Select * from users where username = ? ", name)
.fetch_one(&server_config.db_pool)
.await;
user.map_err(ServerError::Database)
}
pub(crate) async fn get_all_users(
server_config: &ServerConfig,
) -> Result<Vec<Self>, ServerError> {
let user = sqlx::query_as!(Self, "Select * from users")
.fetch_all(&server_config.db_pool)
.await;
user.map_err(ServerError::Database)
}
pub(crate) async fn update_user(
&self,
server_config: &ServerConfig,
) -> Result<(), ServerError> {
sqlx::query!(
"UPDATE users SET
username = ?,
email = ?,
password = ?,
role = ? where id = ?",
self.username,
self.email,
self.password,
self.role,
self.id
)
.execute(&server_config.db_pool)
.await?;
Ok(())
}
/// Change an admin user to normal user and a normal user to admin
///
/// # Errors
/// fails with [`ServerError`] if the database cannot be acessed. (the user should exist)
pub async fn toggle_admin(self, server_config: &ServerConfig) -> Result<(), ServerError> {
let new_role = 2 - (self.role + 1) % 2;
sqlx::query!("UPDATE users SET role = ? where id = ?", new_role, self.id)
.execute(&server_config.db_pool)
.await?;
Ok(())
}
pub(crate) async fn set_language(
self,
server_config: &ServerConfig,
new_language: &str,
) -> Result<(), ServerError> {
sqlx::query!(
"UPDATE users SET language = ? where id = ?",
new_language,
self.id
)
.execute(&server_config.db_pool)
.await?;
Ok(())
}
/// Count the admin accounts
///
/// this is usefull for determining if any admins exist at all.
///
/// # Errors
/// fails with [`ServerError`] if the database cannot be acessed.
pub async fn count_admins(server_config: &ServerConfig) -> Result<Count, ServerError> {
let num = sqlx::query_as!(Count, "select count(*) as number from users where role = 2")
.fetch_one(&server_config.db_pool)
.await?;
Ok(num)
}
}
#[derive(Debug, Deserialize)]
pub struct NewUser { pub struct NewUser {
pub username: String, pub username: String,
pub email: String, pub email: String,
@ -24,7 +117,11 @@ pub struct NewUser {
} }
impl NewUser { impl NewUser {
pub(crate) fn new( /// 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, username: String,
email: String, email: String,
password: &str, password: &str,
@ -49,11 +146,31 @@ impl NewUser {
let hash = Hasher::default() let hash = Hasher::default()
.with_password(password) .with_password(password)
.with_secret_key(secret) .with_secret_key(&secret.secret)
.hash()?; .hash()?;
Ok(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)] #[derive(Debug, Deserialize)]
@ -62,28 +179,72 @@ pub struct LoginUser {
pub password: String, pub password: String,
} }
#[derive(Serialize, Debug, Queryable)] #[derive(Serialize, Debug)]
pub struct Link { pub struct Link {
pub id: i32, pub id: i64,
pub title: String, pub title: String,
pub target: String, pub target: String,
pub code: String, pub code: String,
pub author: i32, pub author: i64,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
} }
#[derive(Serialize, Insertable)] impl Link {
#[table_name = "links"] pub(crate) async fn get_link_by_code(
code: &str,
server_config: &ServerConfig,
) -> Result<Self, ServerError> {
let link = sqlx::query_as!(Self, "Select * from links where code = ? ", code)
.fetch_one(&server_config.db_pool)
.await;
tracing::info!("Found link: {:?}", &link);
link.map_err(ServerError::Database)
}
pub(crate) async fn delete_link_by_code(
code: &str,
server_config: &ServerConfig,
) -> Result<(), ServerError> {
sqlx::query!("DELETE from links where code = ? ", code)
.execute(&server_config.db_pool)
.await?;
Ok(())
}
pub(crate) async fn update_link(
&self,
server_config: &ServerConfig,
) -> Result<(), ServerError> {
sqlx::query!(
"UPDATE links SET
title = ?,
target = ?,
code = ?,
author = ?,
created_at = ? where id = ?",
self.title,
self.target,
self.code,
self.author,
self.created_at,
self.id
)
.execute(&server_config.db_pool)
.await?;
Ok(())
}
}
#[derive(Serialize, Debug)]
pub struct NewLink { pub struct NewLink {
pub title: String, pub title: String,
pub target: String, pub target: String,
pub code: String, pub code: String,
pub author: i32, pub author: i64,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
} }
impl NewLink { impl NewLink {
pub(crate) fn from_link_form(form: LinkForm, uid: i32) -> Self { pub(crate) fn from_link_form(form: LinkForm, uid: i64) -> Self {
Self { Self {
title: form.title, title: form.title,
target: form.target, target: form.target,
@ -92,33 +253,67 @@ impl NewLink {
created_at: chrono::Local::now().naive_utc(), created_at: chrono::Local::now().naive_utc(),
} }
} }
pub(crate) async fn insert(self, server_config: &ServerConfig) -> Result<(), ServerError> {
sqlx::query!(
"Insert into links (
title,
target,
code,
author,
created_at) VALUES (?,?,?,?,?)",
self.title,
self.target,
self.code,
self.author,
self.created_at,
)
.execute(&server_config.db_pool)
.await?;
Ok(())
}
} }
#[derive(Serialize, Debug, Queryable)] #[derive(Serialize, Debug)]
pub struct Click { pub struct Click {
pub id: i32, pub id: i64,
pub link: i32, pub link: i64,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
} }
#[derive(Serialize, Insertable)] #[derive(Serialize)]
#[table_name = "clicks"]
pub struct NewClick { pub struct NewClick {
pub link: i32, pub link: i64,
pub created_at: chrono::NaiveDateTime, pub created_at: chrono::NaiveDateTime,
} }
impl NewClick { impl NewClick {
#[must_use] #[must_use]
pub fn new(link_id: i32) -> Self { pub fn new(link_id: i64) -> Self {
Self { Self {
link: link_id, link: link_id,
created_at: chrono::Local::now().naive_utc(), created_at: chrono::Local::now().naive_utc(),
} }
} }
pub(crate) async fn insert_click(
self,
server_config: &ServerConfig,
) -> Result<(), ServerError> {
sqlx::query!(
"Insert into clicks (
link,
created_at) VALUES (?,?)",
self.link,
self.created_at,
)
.execute(&server_config.db_pool)
.await?;
Ok(())
}
} }
#[derive(Serialize, Debug, Queryable)] #[derive(Serialize, Debug)]
pub struct Count { pub struct Count {
count: i32, pub number: i32,
} }

View File

@ -1,9 +1,7 @@
use std::path::Path;
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::web; use actix_web::web;
use diesel::{prelude::*, sqlite::SqliteConnection};
use serde::Serialize; use serde::Serialize;
use tracing::info;
use super::models::{Count, Link, NewUser, User}; use super::models::{Count, Link, NewUser, User};
use crate::{ use crate::{
@ -12,23 +10,6 @@ use crate::{
ServerConfig, ServerError, ServerConfig, ServerError,
}; };
/// Create a connection to the database
pub(super) fn establish_connection(database_url: &Path) -> Result<SqliteConnection, ServerError> {
match SqliteConnection::establish(&database_url.display().to_string()) {
Ok(c) => Ok(c),
Err(e) => {
eprintln!(
"Error connecting to database: {}, {}",
database_url.display(),
e
);
Err(ServerError::User(
"Error connecting to Database".to_string(),
))
}
}
}
/// The possible roles a user could have. /// The possible roles a user could have.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Role { pub enum Role {
@ -40,7 +21,7 @@ pub enum Role {
impl Role { 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. /// 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: i32) -> bool { const fn admin_or_self(&self, id: i64) -> bool {
match self { match self {
Self::Admin { .. } => true, Self::Admin { .. } => true,
Self::Regular { user } => user.id == id, Self::Regular { user } => user.id == id,
@ -50,17 +31,15 @@ impl Role {
} }
/// 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. /// 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.
pub(crate) fn authenticate( ///
/// # Errors
/// Fails only if there are issues using the database.
pub async fn authenticate(
id: &Identity, id: &Identity,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<Role, ServerError> { ) -> Result<Role, ServerError> {
if let Some(username) = id.identity() { if let Some(username) = id.identity() {
use super::schema::users::dsl; let user = User::get_user_by_name(&username, server_config).await?;
let connection = establish_connection(&server_config.db)?;
let user = dsl::users
.filter(dsl::username.eq(&username))
.first::<User>(&connection)?;
return Ok(match user.role { return Ok(match user.role {
0 => Role::Disabled, 0 => Role::Disabled,
@ -87,52 +66,63 @@ pub struct FullLink {
} }
/// 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. /// 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.
pub(crate) fn list_all_allowed( ///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails.
pub async fn list_all_allowed(
id: &Identity, id: &Identity,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<List<FullLink>, ServerError> { ) -> Result<List<FullLink>, ServerError> {
use super::schema::clicks; use crate::sqlx::Row;
use super::schema::links; match authenticate(id, server_config).await? {
use super::schema::users;
// query to select all users could be const but typespecification is too complex. A filter can be added in the match below.
let query = links::dsl::links
.inner_join(users::dsl::users)
.left_join(clicks::dsl::clicks)
.group_by(links::id)
.select((
(
links::id,
links::title,
links::target,
links::code,
links::author,
links::created_at,
),
(
users::id,
users::username,
users::email,
users::password,
users::role,
),
(diesel::dsl::sql::<diesel::sql_types::Integer>(
"COUNT(clicks.id)",
),),
));
match authenticate(id, server_config)? {
Role::Admin { user } | Role::Regular { user } => { Role::Admin { user } | Role::Regular { user } => {
let links = sqlx::query(
"select
links.id as lid,
links.title as ltitle,
links.target as ltarget,
links.code as lcode,
links.author as lauthor,
links.created_at as ldate,
users.id as usid,
users.username as usern,
users.email as uemail,
users.role as urole,
users.language as ulang,
count(clicks.id) as counter
from
links
join users on links.author = users.id
left join clicks on links.id = clicks.link
group by
links.id",
)
.fetch_all(&server_config.db_pool)
.await?
.into_iter()
.map(|v| FullLink {
link: Link {
id: v.get("lid"),
title: v.get("ltitle"),
target: v.get("ltarget"),
code: v.get("lcode"),
author: v.get("lauthor"),
created_at: v.get("ldate"),
},
user: User {
id: v.get("usid"),
username: v.get("usern"),
email: v.get("uemail"),
password: "invalid".to_owned(),
role: v.get("urole"),
language: v.get("ulang"),
},
clicks: Count {
number: v.get("counter"), /* count is never None */
},
});
// show all links // show all links
let connection = establish_connection(&server_config.db)?; let all_links: Vec<FullLink> = links.collect();
let all_links: Vec<FullLink> = query
.load(&connection)?
.into_iter()
.map(|l: (Link, User, Count)| FullLink {
link: l.0,
user: l.1,
clicks: l.2,
})
.collect();
Ok(List { Ok(List {
user, user,
list: all_links, list: all_links,
@ -143,15 +133,16 @@ pub(crate) fn list_all_allowed(
} }
/// Only admins can list all users /// Only admins can list all users
pub(crate) fn list_users( ///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
pub async fn list_users(
id: &Identity, id: &Identity,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<List<User>, ServerError> { ) -> Result<List<User>, ServerError> {
use super::schema::users::dsl::users; match authenticate(id, server_config).await? {
match authenticate(id, server_config)? {
Role::Admin { user } => { Role::Admin { user } => {
let connection = establish_connection(&server_config.db)?; let all_users: Vec<User> = User::get_all_users(server_config).await?;
let all_users: Vec<User> = users.load(&connection)?;
Ok(List { Ok(List {
user, user,
list: all_users, list: all_users,
@ -170,23 +161,22 @@ pub struct Item<T> {
} }
/// Get a user if permissions are accordingly /// Get a user if permissions are accordingly
pub(crate) fn get_user( ///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
#[allow(clippy::clippy::missing_panics_doc)]
pub async fn get_user(
id: &Identity, id: &Identity,
user_id: &str, user_id: &str,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<Item<User>, ServerError> { ) -> Result<Item<User>, ServerError> {
use super::schema::users; if let Ok(uid) = user_id.parse::<i64>() {
if let Ok(uid) = user_id.parse::<i32>() { info!("Getting user {}", uid);
slog_info!(server_config.log, "Getting user {}", uid); let auth = authenticate(id, server_config).await?;
let auth = authenticate(id, server_config)?;
slog_info!(server_config.log, "{:?}", &auth);
if auth.admin_or_self(uid) { if auth.admin_or_self(uid) {
match auth { match auth {
Role::Admin { user } | Role::Regular { user } => { Role::Admin { user } | Role::Regular { user } => {
let connection = establish_connection(&server_config.db)?; let viewed_user = User::get_user(uid as i64, server_config).await?;
let viewed_user = users::dsl::users
.filter(users::dsl::id.eq(&uid))
.first::<User>(&connection)?;
Ok(Item { Ok(Item {
user, user,
item: viewed_user, item: viewed_user,
@ -205,31 +195,30 @@ pub(crate) fn get_user(
} }
/// Get a user **without permission checks** (needed for login) /// Get a user **without permission checks** (needed for login)
pub(crate) fn get_user_by_name( ///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails.
pub async fn get_user_by_name(
username: &str, username: &str,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<User, ServerError> { ) -> Result<User, ServerError> {
use super::schema::users; let user = User::get_user_by_name(username, server_config).await?;
let connection = establish_connection(&server_config.db)?;
let user = users::dsl::users
.filter(users::dsl::username.eq(username))
.first::<User>(&connection)?;
Ok(user) Ok(user)
} }
pub(crate) fn create_user( /// Create a new user and save it to the database
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the user already exists.
pub async fn create_user(
id: &Identity, id: &Identity,
data: &web::Form<NewUser>, data: &web::Form<NewUser>,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<Item<User>, ServerError> { ) -> Result<Item<User>, ServerError> {
slog_info!(server_config.log, "Creating a User: {:?}", &data); info!("Creating a User: {:?}", &data);
let auth = authenticate(id, server_config)?; let auth = authenticate(id, server_config).await?;
match auth { match auth {
Role::Admin { user } => { Role::Admin { user } => {
use super::schema::users;
let connection = establish_connection(&server_config.db)?;
let new_user = NewUser::new( let new_user = NewUser::new(
data.username.clone(), data.username.clone(),
data.email.clone(), data.email.clone(),
@ -237,11 +226,10 @@ pub(crate) fn create_user(
server_config, server_config,
)?; )?;
diesel::insert_into(users::table) new_user.insert_user(server_config).await?;
.values(&new_user)
.execute(&connection)?;
let new_user = get_user_by_name(&data.username, server_config)?; // querry the new user
let new_user = get_user_by_name(&data.username, server_config).await?;
Ok(Item { Ok(Item {
user, user,
item: new_user, item: new_user,
@ -255,37 +243,38 @@ pub(crate) fn create_user(
/// Take a [`actix_web::web::Form<NewUser>`] and update the corresponding entry in the database. /// 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 password is only updated if a new password of at least 4 characters is provided.
/// The `user_id` is never changed. /// The `user_id` is never changed.
pub(crate) fn update_user( ///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions, or the given data is malformed.
#[allow(clippy::clippy::missing_panics_doc)]
pub async fn update_user(
id: &Identity, id: &Identity,
user_id: &str, user_id: &str,
server_config: &ServerConfig, server_config: &ServerConfig,
data: &web::Form<NewUser>, data: &web::Form<NewUser>,
) -> Result<Item<User>, ServerError> { ) -> Result<Item<User>, ServerError> {
if let Ok(uid) = user_id.parse::<i32>() { if let Ok(uid) = user_id.parse::<i64>() {
let auth = authenticate(id, server_config)?; let auth = authenticate(id, server_config).await?;
let unmodified_user = User::get_user(uid, server_config).await?;
if auth.admin_or_self(uid) { if auth.admin_or_self(uid) {
match auth { match auth {
Role::Admin { .. } | Role::Regular { .. } => { Role::Admin { .. } | Role::Regular { .. } => {
use super::schema::users::dsl::{email, id, password, username, users}; info!("Updating userinfo: ");
let password = if data.password.len() > 3 {
slog_info!(server_config.log, "Updating userinfo: "); NewUser::hash_password(&data.password, server_config)?
let connection = establish_connection(&server_config.db)?; } else {
unmodified_user.password
// Update username and email - if they have not been changed their values will be replaced by the old ones. };
diesel::update(users.filter(id.eq(&uid))) let new_user = User {
.set(( id: uid,
username.eq(data.username.clone()), username: data.username.clone(),
email.eq(data.email.clone()), email: data.email.clone(),
)) password,
.execute(&connection)?; role: unmodified_user.role,
// Update the password only if the user entered something. language: unmodified_user.language,
if data.password.len() > 3 { };
let hash = NewUser::hash_password(&data.password, server_config)?; new_user.update_user(server_config).await?;
diesel::update(users.filter(id.eq(&uid))) let changed_user = User::get_user(uid, server_config).await?;
.set((password.eq(hash),))
.execute(&connection)?;
}
let changed_user = users.filter(id.eq(&uid)).first::<User>(&connection)?;
Ok(Item { Ok(Item {
user: changed_user.clone(), user: changed_user.clone(),
item: changed_user, item: changed_user,
@ -302,37 +291,30 @@ pub(crate) fn update_user(
Err(ServerError::User("Permission denied".to_owned())) Err(ServerError::User("Permission denied".to_owned()))
} }
} }
/// Demote an admin user to a normal user or promote a normal user to admin privileges.
pub(crate) fn toggle_admin( ///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the user does not exist.
pub async fn toggle_admin(
id: &Identity, id: &Identity,
user_id: &str, user_id: &str,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<Item<User>, ServerError> { ) -> Result<Item<User>, ServerError> {
if let Ok(uid) = user_id.parse::<i32>() { if let Ok(uid) = user_id.parse::<i64>() {
let auth = authenticate(id, server_config)?; let auth = authenticate(id, server_config).await?;
match auth { match auth {
Role::Admin { .. } => { Role::Admin { .. } => {
use super::schema::users::dsl::{id, role, users}; info!("Changing administrator priviledges: ");
slog_info!(server_config.log, "Changing administrator priviledges: "); let unchanged_user = User::get_user(uid, server_config).await?;
let connection = establish_connection(&server_config.db)?;
let unchanged_user = users.filter(id.eq(&uid)).first::<User>(&connection)?; let old = unchanged_user.role;
unchanged_user.toggle_admin(server_config).await?;
let new_role = 2 - (unchanged_user.role + 1) % 2; info!("Toggling role: old was {}", old);
slog_info!(
server_config.log,
"Assigning new role: {} - old was {}",
new_role,
unchanged_user.role
);
// Update the role eg. admin vs. normal vs. disabled let changed_user = User::get_user(uid, server_config).await?;
diesel::update(users.filter(id.eq(&uid))) info!("Toggled role: new is {}", changed_user.role);
.set((role.eq(new_role),))
.execute(&connection)?;
let changed_user = users.filter(id.eq(&uid)).first::<User>(&connection)?;
Ok(Item { Ok(Item {
user: changed_user.clone(), user: changed_user.clone(),
item: changed_user, item: changed_user,
@ -347,19 +329,42 @@ pub(crate) fn toggle_admin(
} }
} }
/// Set the language of a given user
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the language given is invalid.
pub async fn set_language(
id: &Identity,
lang_code: &str,
server_config: &ServerConfig,
) -> Result<(), ServerError> {
match lang_code {
"de" | "en" => match authenticate(id, server_config).await? {
Role::Admin { user } | Role::Regular { user } => {
user.set_language(server_config, lang_code).await
}
Role::Disabled | Role::NotAuthenticated => {
Err(ServerError::User("Not Allowed".to_owned()))
}
},
_ => Err(ServerError::User(
"This language is not supported!".to_owned(),
)),
}
}
/// Get one link if permissions are accordingly. /// Get one link if permissions are accordingly.
pub(crate) fn get_link( ///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
pub async fn get_link(
id: &Identity, id: &Identity,
link_code: &str, link_code: &str,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<Item<Link>, ServerError> { ) -> Result<Item<Link>, ServerError> {
use super::schema::links::dsl::{code, links}; match authenticate(id, server_config).await? {
match authenticate(id, server_config)? {
Role::Admin { user } | Role::Regular { user } => { Role::Admin { user } | Role::Regular { user } => {
let connection = establish_connection(&server_config.db)?; let link = Link::get_link_by_code(link_code, server_config).await?;
let link: Link = links
.filter(code.eq(&link_code))
.first::<Link>(&connection)?;
Ok(Item { user, item: link }) Ok(Item { user, item: link })
} }
Role::Disabled | Role::NotAuthenticated => Err(ServerError::User("Not Allowed".to_owned())), Role::Disabled | Role::NotAuthenticated => Err(ServerError::User("Not Allowed".to_owned())),
@ -367,75 +372,77 @@ pub(crate) fn get_link(
} }
/// Get link **without authentication** /// Get link **without authentication**
pub(crate) fn get_link_simple( ///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails.
pub async fn get_link_simple(
link_code: &str, link_code: &str,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<Link, ServerError> { ) -> Result<Link, ServerError> {
use super::schema::links::dsl::{code, links}; info!("Getting link for {:?}", link_code);
slog_info!(server_config.log, "Getting link for {:?}", link_code);
let connection = establish_connection(&server_config.db)?; let link = Link::get_link_by_code(link_code, server_config).await?;
let link: Link = links info!("Foun d link for {:?}", link);
.filter(code.eq(&link_code))
.first::<Link>(&connection)?;
Ok(link) Ok(link)
} }
/// Click on a link
pub(crate) fn click_link(link_id: i32, server_config: &ServerConfig) -> Result<(), ServerError> {
use super::schema::clicks;
let new_click = NewClick::new(link_id);
let connection = establish_connection(&server_config.db)?;
diesel::insert_into(clicks::table) /// Click on a link
.values(&new_click) ///
.execute(&connection)?; /// # Errors
/// Fails with [`ServerError`] if access to the database fails.
pub async fn click_link(link_id: i64, server_config: &ServerConfig) -> Result<(), ServerError> {
info!("Clicking on {:?}", link_id);
let new_click = NewClick::new(link_id);
new_click.insert_click(server_config).await?;
Ok(()) Ok(())
} }
/// Click on a link /// Delete a link
pub(crate) fn delete_link( ///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
pub async fn delete_link(
id: &Identity, id: &Identity,
link_code: &str, link_code: &str,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<(), ServerError> { ) -> Result<(), ServerError> {
use super::schema::links::dsl::{code, links}; let auth = authenticate(id, server_config).await?;
let connection = establish_connection(&server_config.db)?; let link = get_link_simple(link_code, server_config).await?;
let auth = authenticate(id, server_config)?;
let link = get_link_simple(link_code, server_config)?;
if auth.admin_or_self(link.author) { if auth.admin_or_self(link.author) {
diesel::delete(links.filter(code.eq(&link_code))).execute(&connection)?; Link::delete_link_by_code(link_code, server_config).await?;
Ok(()) Ok(())
} else { } else {
Err(ServerError::User("Permission denied!".to_owned())) Err(ServerError::User("Permission denied!".to_owned()))
} }
} }
/// Update a link if the user is admin or it is its own link. /// Update a link if the user is admin or it is its own link.
pub(crate) fn update_link( ///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
pub async fn update_link(
id: &Identity, id: &Identity,
link_code: &str, link_code: &str,
data: &web::Form<LinkForm>, data: web::Form<LinkForm>,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<Item<Link>, ServerError> { ) -> Result<Item<Link>, ServerError> {
use super::schema::links::dsl::{code, links, target, title}; info!("Changing link to: {:?} {:?}", &data, &link_code);
slog_info!( let auth = authenticate(id, server_config).await?;
server_config.log,
"Changing link to: {:?} {:?}",
&data,
&link_code
);
let auth = authenticate(id, server_config)?;
match auth { match auth {
Role::Admin { .. } | Role::Regular { .. } => { Role::Admin { .. } | Role::Regular { .. } => {
let query = get_link(id, link_code, server_config)?; let query = get_link(id, link_code, server_config).await?;
if auth.admin_or_self(query.item.author) { if auth.admin_or_self(query.item.author) {
let connection = establish_connection(&server_config.db)?; let mut link = query.item;
diesel::update(links.filter(code.eq(&query.item.code))) let LinkForm {
.set(( title,
code.eq(&data.code), target,
target.eq(&data.target), code,
title.eq(&data.title), } = data.into_inner();
)) link.code = code.clone();
.execute(&connection)?; link.target = target;
get_link(id, &data.code, server_config) link.title = title;
link.update_link(server_config).await?;
get_link(id, &code, server_config).await
} else { } else {
Err(ServerError::User("Not Allowed".to_owned())) Err(ServerError::User("Not Allowed".to_owned()))
} }
@ -444,23 +451,25 @@ pub(crate) fn update_link(
} }
} }
pub(crate) fn create_link( /// Create a new link
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
pub async fn create_link(
id: &Identity, id: &Identity,
data: web::Form<LinkForm>, data: web::Form<LinkForm>,
server_config: &ServerConfig, server_config: &ServerConfig,
) -> Result<Item<Link>, ServerError> { ) -> Result<Item<Link>, ServerError> {
let auth = authenticate(id, server_config)?; let auth = authenticate(id, server_config).await?;
match auth { match auth {
Role::Admin { user } | Role::Regular { user } => { Role::Admin { user } | Role::Regular { user } => {
use super::schema::links; let code = data.code.clone();
info!("Creating link for: {}", &code);
let connection = establish_connection(&server_config.db)?;
let new_link = NewLink::from_link_form(data.into_inner(), user.id); let new_link = NewLink::from_link_form(data.into_inner(), user.id);
info!("Creating link for: {:?}", &new_link);
diesel::insert_into(links::table) new_link.insert(server_config).await?;
.values(&new_link) let new_link = get_link_simple(&code, server_config).await?;
.execute(&connection)?;
let new_link = get_link_simple(&new_link.code, server_config)?;
Ok(Item { Ok(Item {
user, user,
item: new_link, item: new_link,

View File

@ -1,37 +0,0 @@
table! {
clicks (id) {
id -> Integer,
link -> Integer,
created_at -> Timestamp,
}
}
table! {
links (id) {
id -> Integer,
title -> Text,
target -> Text,
code -> Text,
author -> Integer,
created_at -> Timestamp,
}
}
table! {
users (id) {
id -> Integer,
username -> Text,
email -> Text,
password -> Text,
role -> Integer,
}
}
joinable!(clicks -> links (link));
joinable!(links -> users (author));
allow_tables_to_appear_in_same_query!(
clicks,
links,
users,
);

View File

@ -3,18 +3,26 @@ use std::time::SystemTime;
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{ use actix_web::{
http::header::{CacheControl, CacheDirective, ContentType, Expires}, http::header::{CacheControl, CacheDirective, ContentType, Expires},
web, HttpResponse, web, HttpRequest, HttpResponse,
}; };
use argonautica::Verifier; 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 image::{DynamicImage, ImageOutputFormat, Luma};
use qrcode::{render::svg, QrCode}; use qrcode::{render::svg, QrCode};
use queries::{authenticate, Role};
use tera::{Context, Tera}; use tera::{Context, Tera};
use tracing::{info, instrument, trace, warn};
use super::forms::LinkForm; use crate::forms::LinkForm;
use super::models::{LoginUser, NewUser}; use crate::models::{LoginUser, NewUser};
use crate::queries; use crate::queries;
use crate::ServerError; use crate::ServerError;
#[instrument]
fn redirect_builder(target: &str) -> HttpResponse { fn redirect_builder(target: &str) -> HttpResponse {
HttpResponse::SeeOther() HttpResponse::SeeOther()
.set(CacheControl(vec![ .set(CacheControl(vec![
@ -27,13 +35,44 @@ fn redirect_builder(target: &str) -> HttpResponse {
.body(format!("Redirect to {}", target)) .body(format!("Redirect to {}", target))
} }
#[instrument]
fn detect_language(request: &HttpRequest) -> Result<String, ServerError> {
let requested = parse_accepted_languages(
request
.headers()
.get(actix_web::http::header::ACCEPT_LANGUAGE)
.ok_or_else(|| ServerError::User("Failed to get Accept_Language".to_owned()))?
.to_str()
.map_err(|_| {
ServerError::User("Failed to convert Accept_language to str".to_owned())
})?,
);
let available = convert_vec_str_to_langids_lossy(&["de", "en"]);
let default: LanguageIdentifier = "en"
.parse()
.map_err(|_| ServerError::User("Failed to parse a langid.".to_owned()))?;
let supported = negotiate_languages(
&requested,
&available,
Some(&default),
NegotiationStrategy::Filtering,
);
let languagecode = supported
.get(0)
.map_or("en".to_string(), std::string::ToString::to_string);
Ok(languagecode)
}
/// Show the list of all available links if a user is authenticated /// Show the list of all available links if a user is authenticated
pub(crate) async fn index(
#[instrument(skip(id, tera))]
pub async fn index(
tera: web::Data<Tera>, tera: web::Data<Tera>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
if let Ok(links) = queries::list_all_allowed(&id, &config) { if let Ok(links) = queries::list_all_allowed(&id, &config).await {
let mut data = Context::new(); let mut data = Context::new();
data.insert("user", &links.user); data.insert("user", &links.user);
data.insert("title", &format!("Links der {}", &config.brand_name,)); data.insert("title", &format!("Links der {}", &config.brand_name,));
@ -46,12 +85,13 @@ pub(crate) async fn index(
} }
/// Show the list of all available links if a user is authenticated /// Show the list of all available links if a user is authenticated
pub(crate) async fn index_users( #[instrument(skip(id, tera))]
pub async fn index_users(
tera: web::Data<Tera>, tera: web::Data<Tera>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
if let Ok(users) = queries::list_users(&id, &config) { if let Ok(users) = queries::list_users(&id, &config).await {
let mut data = Context::new(); let mut data = Context::new();
data.insert("user", &users.user); data.insert("user", &users.user);
data.insert("title", &format!("Benutzer der {}", &config.brand_name,)); data.insert("title", &format!("Benutzer der {}", &config.brand_name,));
@ -63,7 +103,9 @@ pub(crate) async fn index_users(
Ok(redirect_builder("/admin/login")) Ok(redirect_builder("/admin/login"))
} }
} }
pub(crate) async fn view_link_empty(
#[instrument(skip(id, tera))]
pub async fn view_link_empty(
tera: web::Data<Tera>, tera: web::Data<Tera>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
@ -71,13 +113,14 @@ pub(crate) async fn view_link_empty(
view_link(tera, config, id, web::Path::from("".to_owned())).await view_link(tera, config, id, web::Path::from("".to_owned())).await
} }
pub(crate) async fn view_link( #[instrument(skip(id, tera))]
pub async fn view_link(
tera: web::Data<Tera>, tera: web::Data<Tera>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
link_id: web::Path<String>, link_id: web::Path<String>,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
if let Ok(link) = queries::get_link(&id, &link_id.0, &config) { if let Ok(link) = queries::get_link(&id, &link_id.0, &config).await {
let host = config.public_url.to_string(); let host = config.public_url.to_string();
let protocol = config.protocol.to_string(); let protocol = config.protocol.to_string();
let qr = QrCode::with_error_correction_level( let qr = QrCode::with_error_correction_level(
@ -110,14 +153,15 @@ pub(crate) async fn view_link(
} }
} }
pub(crate) async fn view_profile( #[instrument(skip(id, tera))]
pub async fn view_profile(
tera: web::Data<Tera>, tera: web::Data<Tera>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
user_id: web::Path<String>, user_id: web::Path<String>,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
slog_info!(config.log, "Viewing Profile!"); info!("Viewing Profile!");
if let Ok(query) = queries::get_user(&id, &user_id.0, &config) { if let Ok(query) = queries::get_user(&id, &user_id.0, &config).await {
let mut data = Context::new(); let mut data = Context::new();
data.insert("user", &query.user); data.insert("user", &query.user);
data.insert( data.insert(
@ -137,14 +181,15 @@ pub(crate) async fn view_profile(
} }
} }
pub(crate) async fn edit_profile( #[instrument(skip(id, tera))]
pub async fn edit_profile(
tera: web::Data<Tera>, tera: web::Data<Tera>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
user_id: web::Path<String>, user_id: web::Path<String>,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
slog_info!(config.log, "Editing Profile!"); info!("Editing Profile!");
if let Ok(query) = queries::get_user(&id, &user_id.0, &config) { if let Ok(query) = queries::get_user(&id, &user_id.0, &config).await {
let mut data = Context::new(); let mut data = Context::new();
data.insert("user", &query.user); data.insert("user", &query.user);
data.insert( data.insert(
@ -163,28 +208,27 @@ pub(crate) async fn edit_profile(
} }
} }
pub(crate) async fn process_edit_profile( #[instrument(skip(id))]
pub async fn process_edit_profile(
data: web::Form<NewUser>, data: web::Form<NewUser>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
user_id: web::Path<String>, user_id: web::Path<String>,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
if let Ok(query) = queries::update_user(&id, &user_id.0, &config, &data) { let query = queries::update_user(&id, &user_id.0, &config, &data).await?;
Ok(redirect_builder(&format!( Ok(redirect_builder(&format!(
"admin/view/profile/{}", "admin/view/profile/{}",
query.user.username query.user.username
))) )))
} else {
Ok(redirect_builder("/admin/index/"))
}
} }
pub(crate) async fn download_png( #[instrument(skip(id))]
pub async fn download_png(
id: Identity, id: Identity,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
link_code: web::Path<String>, link_code: web::Path<String>,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
match queries::get_link(&id, &link_code.0, &config) { match queries::get_link(&id, &link_code.0, &config).await {
Ok(query) => { Ok(query) => {
let qr = QrCode::with_error_correction_level( let qr = QrCode::with_error_correction_level(
&format!("http://{}/{}", config.public_url, &query.item.code), &format!("http://{}/{}", config.public_url, &query.item.code),
@ -203,12 +247,13 @@ pub(crate) async fn download_png(
} }
} }
pub(crate) async fn signup( #[instrument(skip(id, tera))]
pub async fn signup(
tera: web::Data<Tera>, tera: web::Data<Tera>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
match queries::authenticate(&id, &config)? { match queries::authenticate(&id, &config).await? {
queries::Role::Admin { user } => { queries::Role::Admin { user } => {
let mut data = Context::new(); let mut data = Context::new();
data.insert("title", "Ein Benutzerkonto erstellen"); data.insert("title", "Ein Benutzerkonto erstellen");
@ -223,52 +268,85 @@ pub(crate) async fn signup(
} }
} }
pub(crate) async fn process_signup( #[instrument(skip(id))]
pub async fn process_signup(
data: web::Form<NewUser>, data: web::Form<NewUser>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
slog_info!(config.log, "Creating a User: {:?}", &data); info!("Creating a User: {:?}", &data);
if let Ok(item) = queries::create_user(&id, &data, &config) { match queries::create_user(&id, &data, &config).await {
Ok(HttpResponse::Ok().body(format!("Successfully saved user: {}", item.item.username))) Ok(item) => {
} else { Ok(HttpResponse::Ok().body(format!("Successfully saved user: {}", item.item.username)))
Ok(redirect_builder("/admin/login/")) }
Err(e) => Err(e),
} }
} }
pub(crate) async fn toggle_admin( #[instrument(skip(id))]
pub async fn toggle_admin(
data: web::Path<String>, data: web::Path<String>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
let update = queries::toggle_admin(&id, &data.0, &config)?; let update = queries::toggle_admin(&id, &data.0, &config).await?;
Ok(redirect_builder(&format!( Ok(redirect_builder(&format!(
"/admin/view/profile/{}", "/admin/view/profile/{}",
update.item.id update.item.id
))) )))
} }
pub(crate) async fn login( #[instrument(skip(id))]
tera: web::Data<Tera>, pub async fn set_language(
data: web::Path<String>,
config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
) -> Result<HttpResponse, ServerError> { ) -> 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(); let mut data = Context::new();
data.insert("title", "Login"); data.insert("title", "Login");
data.insert("language", &language_code);
if let Some(_id) = id.identity() { if id.identity().is_some() {
return Ok(redirect_builder("/admin/index/")); if let Ok(r) = authenticate(&id, &config).await {
match r {
Role::Admin { user } | Role::Regular { user } => {
trace!(
"This user ({}) is already logged in redirecting to /admin/index/",
user.username
);
return Ok(redirect_builder("/admin/index/"));
}
Role::Disabled | Role::NotAuthenticated => (),
}
}
warn!("Invalid user session. The user might be deleted or something tampered with the cookies.");
id.forget();
} }
let rendered = tera.render("login.html", &data)?; let rendered = tera.render("login.html", &data)?;
Ok(HttpResponse::Ok().body(rendered)) Ok(HttpResponse::Ok().body(rendered))
} }
pub(crate) async fn process_login( #[instrument(skip(id))]
pub async fn process_login(
data: web::Form<LoginUser>, data: web::Form<LoginUser>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
let user = queries::get_user_by_name(&data.username, &config); let user = queries::get_user_by_name(&data.username, &config).await;
match user { match user {
Ok(u) => { Ok(u) => {
@ -276,11 +354,11 @@ pub(crate) async fn process_login(
let valid = Verifier::default() let valid = Verifier::default()
.with_hash(&u.password) .with_hash(&u.password)
.with_password(&data.password) .with_password(&data.password)
.with_secret_key(secret) .with_secret_key(&secret.secret)
.verify()?; .verify()?;
if valid { if valid {
slog_info!(config.log, "Log-in of user: {}", &u.username); info!("Log-in of user: {}", &u.username);
let session_token = u.username; let session_token = u.username;
id.remember(session_token); id.remember(session_token);
Ok(redirect_builder("/admin/index/")) Ok(redirect_builder("/admin/index/"))
@ -289,40 +367,43 @@ pub(crate) async fn process_login(
} }
} }
Err(e) => { Err(e) => {
slog_info!(config.log, "Failed to login: {}", e); info!("Failed to login: {}", e);
Ok(redirect_builder("/admin/login/")) Ok(redirect_builder("/admin/login/"))
} }
} }
} }
pub(crate) async fn logout(id: Identity) -> Result<HttpResponse, ServerError> { #[instrument(skip(id))]
pub async fn logout(id: Identity) -> Result<HttpResponse, ServerError> {
info!("Logging out the user");
id.forget(); id.forget();
Ok(redirect_builder("/admin/login/")) Ok(redirect_builder("/admin/login/"))
} }
pub(crate) async fn redirect( #[instrument]
pub async fn redirect(
tera: web::Data<Tera>, tera: web::Data<Tera>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
data: web::Path<String>, data: web::Path<String>,
req: HttpRequest,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
slog_info!(config.log, "Redirecting to {:?}", data); info!("Redirecting to {:?}", data);
let link = queries::get_link_simple(&data.0, &config); let link = queries::get_link_simple(&data.0, &config).await;
slog_info!(config.log, "link: {:?}", link); info!("link: {:?}", link);
match link { match link {
Ok(link) => { Ok(link) => {
queries::click_link(link.id, &config)?; queries::click_link(link.id, &config).await?;
Ok(redirect_builder(&link.target)) Ok(redirect_builder(&link.target))
} }
Err(ServerError::Diesel(e)) => { Err(ServerError::Database(e)) => {
slog_info!( info!(
config.log,
"Link was not found: http://{}/{} \n {}", "Link was not found: http://{}/{} \n {}",
&config.public_url, &config.public_url, &data.0, e
&data.0,
e
); );
let mut data = Context::new(); let mut data = Context::new();
data.insert("title", "Wurde gel\u{f6}scht"); 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)?; let rendered = tera.render("not_found.html", &data)?;
Ok(HttpResponse::NotFound().body(rendered)) Ok(HttpResponse::NotFound().body(rendered))
} }
@ -330,18 +411,20 @@ pub(crate) async fn redirect(
} }
} }
pub(crate) async fn redirect_empty( #[instrument]
pub async fn redirect_empty(
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
Ok(redirect_builder(&config.empty_forward_url)) Ok(redirect_builder(&config.empty_forward_url))
} }
pub(crate) async fn create_link( #[instrument(skip(id))]
pub async fn create_link(
tera: web::Data<Tera>, tera: web::Data<Tera>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
match queries::authenticate(&id, &config)? { match queries::authenticate(&id, &config).await? {
queries::Role::Admin { user } | queries::Role::Regular { user } => { queries::Role::Admin { user } | queries::Role::Regular { user } => {
let mut data = Context::new(); let mut data = Context::new();
data.insert("title", "Einen Kurzlink erstellen"); data.insert("title", "Einen Kurzlink erstellen");
@ -356,25 +439,27 @@ pub(crate) async fn create_link(
} }
} }
pub(crate) async fn process_link_creation( #[instrument(skip(id))]
pub async fn process_link_creation(
data: web::Form<LinkForm>, data: web::Form<LinkForm>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
let new_link = queries::create_link(&id, data, &config)?; let new_link = queries::create_link(&id, data, &config).await?;
Ok(redirect_builder(&format!( Ok(redirect_builder(&format!(
"/admin/view/link/{}", "/admin/view/link/{}",
new_link.item.code new_link.item.code
))) )))
} }
pub(crate) async fn edit_link( #[instrument(skip(id))]
pub async fn edit_link(
tera: web::Data<Tera>, tera: web::Data<Tera>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
link_id: web::Path<String>, link_id: web::Path<String>,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
if let Ok(query) = queries::get_link(&id, &link_id.0, &config) { if let Ok(query) = queries::get_link(&id, &link_id.0, &config).await {
let mut data = Context::new(); let mut data = Context::new();
data.insert("title", "Submit a Post"); data.insert("title", "Submit a Post");
data.insert("link", &query.item); data.insert("link", &query.item);
@ -385,13 +470,13 @@ pub(crate) async fn edit_link(
} }
Ok(redirect_builder("/admin/login/")) Ok(redirect_builder("/admin/login/"))
} }
pub(crate) async fn process_link_edit( pub async fn process_link_edit(
data: web::Form<LinkForm>, data: web::Form<LinkForm>,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
id: Identity, id: Identity,
link_code: web::Path<String>, link_code: web::Path<String>,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
match queries::update_link(&id, &link_code.0, &data, &config) { match queries::update_link(&id, &link_code.0, data, &config).await {
Ok(query) => Ok(redirect_builder(&format!( Ok(query) => Ok(redirect_builder(&format!(
"/admin/view/link/{}", "/admin/view/link/{}",
&query.item.code &query.item.code
@ -400,11 +485,12 @@ pub(crate) async fn process_link_edit(
} }
} }
pub(crate) async fn process_link_delete( #[instrument(skip(id))]
pub async fn process_link_delete(
id: Identity, id: Identity,
config: web::Data<crate::ServerConfig>, config: web::Data<crate::ServerConfig>,
link_code: web::Path<String>, link_code: web::Path<String>,
) -> Result<HttpResponse, ServerError> { ) -> Result<HttpResponse, ServerError> {
queries::delete_link(&id, &link_code.0, &config)?; queries::delete_link(&id, &link_code.0, &config).await?;
Ok(redirect_builder("/admin/login/")) Ok(redirect_builder("/admin/login/"))
} }

View File

@ -11,14 +11,19 @@
<div class="admin"> <div class="admin">
<nav> <nav>
<ol> <ol>
<li><a href="/admin/index/">Link Liste</a></li> <li><a href="/admin/index/">{{ fluent(key="list-links", lang=user.language) }}</a>
<li><a href="/admin/submit/">Link Hinzufügen</a></li>
{% if user.role == 2 %}<li><a href="/admin/signup/">Einladen</a></li>
<li><a href="/admin/view/users/">Benutzer</a></li>{% endif %}
<li style="float:right"><a href="/admin/logout/">Abmelden</a></li>
<li style="float:right">
<div class="willkommen">Herzlich willkommen {{ user.username }}</div>
</li> </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> </ol>
</nav> </nav>
{% block admin %} {% block admin %}

View File

@ -2,26 +2,26 @@
{% block admin %} {% block admin %}
<div class="center"> <div class="center">
<h1>Link Editieren: {{ link.title }}</h1> <h1>{{ fluent(key="edit-link-headline", lang=user.language, linktitle=link.title) }}</h1>
<form action="" method="POST"> <form action="" method="POST">
<div> <div>
<label for="title">Beschreibung:</label> <label for="title">{{ fluent(key="link-description", lang=user.language) }}:</label>
<input type="text" name="title" value="{{ link.title }}"> <input type="text" name="title" value="{{ link.title }}">
</div> </div>
<div> <div>
<label for="target">Ziel:</label> <label for="target">{{ fluent(key="link-target", lang=user.language) }}:</label>
<input type="text" name="target" value="{{link.target}}"> <input type="text" name="target" value="{{link.target}}">
</div> </div>
<div> <div>
<label for="code">Code:</label> <label for="code">{{ fluent(key="link-code", lang=user.language) }}:</label>
<input type="text" name="code" value="{{link.code}}"> <input type="text" name="code" value="{{link.code}}">
</div> </div>
<div class="actions danger"> <div class="actions danger">
<h2>Achtung!</h2> <h2>{{ fluent(key="danger-zone", lang=user.language) }}</h2>
<h3>Werden schon veröffentlichte Links gelöscht oder editiert sind die Links z.B. aus einem Buch <h3>{{ fluent(key="danger-zone-text", lang=user.language) }}</h3>
nicht mehr gültig! UNBEDINGT VERMEIDEN!</h3> <input type="submit" value='{{ fluent(key="save-edits", lang=user.language) }}'>
<input type="submit" value="Speichern"> <a class="button" href="/admin/delete/link/{{link.code}}">{{ fluent(key="delete-link", lang=user.language)
<a class="button" href="/admin/delete/link/{{link.code}}">Delete</a> }}</a>
</div> </div>
</form> </form>
</div> </div>

View File

@ -2,21 +2,27 @@
{% block admin %} {% block admin %}
<div class="center"> <div class="center">
<h1>Profil von {{user.username}}</h1> <h1>{{ fluent(key="edit-user-headline", lang=user.language, username=user.username) }}
</h1>
<form action="" method="POST"> <form action="" method="POST">
<div> <div>
<label for="username">Benutzername:</label> <label for="username">{{ fluent(key="username", lang=user.language)
}}:</label>
<input type="text" name="username" value="{{ user.username }}"> <input type="text" name="username" value="{{ user.username }}">
</div> </div>
<div> <div>
<label for="email">E-mail:</label> <label for="email">{{ fluent(key="email", lang=user.language)
}}:</label>
<input type="email" name="email" value="{{ user.email }}"> <input type="email" name="email" value="{{ user.email }}">
</div> </div>
<div> <div>
<label for="password">Passwort:</label> <label for="password">{{ fluent(key="password", lang=user.language)
<input type="password" name="password" placeholder="Leer lassen um nichts zu ändern"> }}:</label>
<input type="password" name="password" placeholder='{{ fluent(key="password-placeholder", lang=user.language)
}}'>
</div> </div>
<input type="submit" value="Speichern"> <input type="submit" value='{{ fluent(key="save-user", lang=user.language)
}}'>
</form> </form>
<h2>&nbsp;</h2> <h2>&nbsp;</h2>
</div> </div>

View File

@ -11,16 +11,20 @@
<tr> <tr>
<th> <th>
Kürzel {{ fluent(key="link-code", lang=user.language)
}}
</th> </th>
<th> <th>
Ziellink {{ fluent(key="link-target", lang=user.language)
}}
</th> </th>
<th> <th>
Benutzername {{ fluent(key="username", lang=user.language)
}}
</th> </th>
<th> <th>
Statistik {{ fluent(key="statistics", lang=user.language)
}}
</th> </th>
</tr> </tr>
{% for links_user in links_per_users %} {% for links_user in links_per_users %}
@ -45,7 +49,7 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{{ c.count }} {{ c.number }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -11,13 +11,16 @@
<tr> <tr>
<th> <th>
Kürzel {{ fluent(key="userid", lang=user.language)
}}
</th> </th>
<th> <th>
Emailadresse {{ fluent(key="email", lang=user.language)
}}
</th> </th>
<th> <th>
Benutzername {{ fluent(key="username", lang=user.language)
}}
</th> </th>
</tr> </tr>
{% for user in users %} {% for user in users %}

View File

@ -4,14 +4,16 @@
<div class="center"> <div class="center">
<form action="" method="POST"> <form action="" method="POST">
<div> <div>
<label for="username">Benutzername:</label> <label for="username">{{ fluent(key="username", lang=language)
}}:</label>
<input type="text" name="username"> <input type="text" name="username">
</div> </div>
<div> <div>
<label for="password">Passwort:</label> <label for="password">{{ fluent(key="password", lang=language)
}}:</label>
<input type="password" name="password"> <input type="password" name="password">
</div> </div>
<input type="submit" value="Login"> <input type="submit" value='{{ fluent(key="login", lang=language) }}'>
</form> </form>
<h2>&nbsp;</h2> <h2>&nbsp;</h2>
</div> </div>

View File

@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="center"> <div class="center">
<h3>This Link has not been found or has been deleted</h3> <h3>{{ fluent(key="not-found", lang=language) }}</h3>
<h2>&nbsp;</h2> <h2>&nbsp;</h2>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -4,18 +4,22 @@
<div class="center"> <div class="center">
<form action="" method="POST"> <form action="" method="POST">
<div> <div>
<label for="username">Benutzername:</label> <label for="username">{{ fluent(key="username", lang=user.language)
}}:</label>
<input type="text" name="username"> <input type="text" name="username">
</div> </div>
<div> <div>
<label for="email">E-mail:</label> <label for="email">{{ fluent(key="email", lang=user.language)
}}:</label>
<input type="email" name="email"> <input type="email" name="email">
</div> </div>
<div> <div>
<label for="password">Passwort:</label> <label for="password">{{ fluent(key="password", lang=user.language)
}}:</label>
<input type="password" name="password"> <input type="password" name="password">
</div> </div>
<input type="submit" value="Einladen"> <input type="submit" value='{{ fluent(key="invite-user", lang=user.language)
}}'>
</form> </form>
<h2>&nbsp;</h2> <h2>&nbsp;</h2>
</div> </div>

View File

@ -4,15 +4,18 @@
<div class="center"> <div class="center">
<form action="" method="POST"> <form action="" method="POST">
<div> <div>
<label for="title">Beschreibung:</label> <label for="title">{{ fluent(key="link-description", lang=user.language)
}}:</label>
<input type="text" name="title"> <input type="text" name="title">
</div> </div>
<div> <div>
<label for="target">Ziel:</label> <label for="target">{{ fluent(key="link-target", lang=user.language)
}}:</label>
<input type="text" name="target"> <input type="text" name="target">
</div> </div>
<div> <div>
<label for="code">Code:</label> <label for="code">{{ fluent(key="link-code", lang=user.language)
}}:</label>
<input type="text" name="code"> <input type="text" name="code">
</div> </div>
<input type="submit" value="Submit"> <input type="submit" value="Submit">

View File

@ -5,24 +5,29 @@
<h1>{{ link.title }}</h1> <h1>{{ link.title }}</h1>
<table> <table>
<tr> <tr>
<td>Beschreibung:</td> <td>{{ fluent(key="link-description", lang=user.language)
}}:</td>
<td>{{ link.title }}</td> <td>{{ link.title }}</td>
</tr> </tr>
<tr> <tr>
<td>Code:</td> <td>{{ fluent(key="link-code", lang=user.language)
}}:</td>
<td>{{ link.code }}</td> <td>{{ link.code }}</td>
</tr> </tr>
<tr> <tr>
<td>Kurzlink:</td> <td>{{ fluent(key="shortlink", lang=user.language)
}}:</td>
<td><a href="{{ protocol }}://{{ host }}/{{ link.code }}">{{ protocol }}://{{ host }}/{{ link.code }}</a> <td><a href="{{ protocol }}://{{ host }}/{{ link.code }}">{{ protocol }}://{{ host }}/{{ link.code }}</a>
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Ziel:</td> <td>{{ fluent(key="link-target", lang=user.language)
}}:</td>
<td>{{ link.target }}</td> <td>{{ link.target }}</td>
</tr> </tr>
<tr> <tr>
<td>QR-Code</td> <td>{{ fluent(key="qr-code", lang=user.language)
}}</td>
<td><a href="/admin/download/png/{{ link.code }}" download="{{ link.title | slugify }}.png"> <td><a href="/admin/download/png/{{ link.code }}" download="{{ link.title | slugify }}.png">
{{ qr | trim_start_matches(pat= {{ qr | trim_start_matches(pat=
'.*?>') '.*?>')
@ -33,7 +38,8 @@
</table> </table>
{% if user.role == 2 or user.id == link.author %} {% if user.role == 2 or user.id == link.author %}
<div class="actions"> <div class="actions">
<a class="button" href="/admin/edit/link/{{ link.code }}">Editieren</a> <a class="button" href="/admin/edit/link/{{ link.code }}">{{ fluent(key="edit-link", lang=user.language)
}}</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -2,31 +2,39 @@
{% block admin %} {% block admin %}
<div class="center"> <div class="center">
<h1>Profil von {{viewed_user.username}}</h1> <h1>{{ fluent(key="user-headline", lang=user.language, username=user.username) }}</h1>
<form action="" method="POST"> <form action="" method="POST">
<div> <div>
<label for="username">Benutzername:</label> <label for="username">{{ fluent(key="username", lang=user.language)
}}:</label>
<input type="text" name="username" value="{{ viewed_user.username }}" readonly> <input type="text" name="username" value="{{ viewed_user.username }}" readonly>
</div> </div>
<div> <div>
<label for="email">E-mail:</label> <label for="email">{{ fluent(key="email", lang=user.language)
}}:</label>
<input type="email" name="email" value="{{ viewed_user.email }}" readonly> <input type="email" name="email" value="{{ viewed_user.email }}" readonly>
</div> </div>
{% if user.role == 2 or user.id == viewed_user.id %} {% if user.role == 2 or user.id == viewed_user.id %}
<div> <div>
<label for="password">Passwort:</label> <label for="password">{{ fluent(key="password", lang=user.language)
}}:</label>
<input type="password" name="password" value="verschlüsselt" readonly> <input type="password" name="password" value="verschlüsselt" readonly>
</div> </div>
{% endif %} {% endif %}
</form> </form>
{% if user.role == 2 or user.id == viewed_user.id %} {% if user.role == 2 or user.id == viewed_user.id %}
<div class="actions"> <div class="actions">
<a class="button" href="/admin/edit/profile/{{ viewed_user.id }}">Editieren</a> <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 %} {% if user.role == 2 and viewed_user.role == 1 %}
<a class="button" href="/admin/edit/set_admin/{{ viewed_user.id }}">Zum Admin machen</a> <a class="button" href="/admin/edit/set_admin/{{ viewed_user.id }}">{{ fluent(key="make-user-admin",
lang=user.language)
}}</a>
{% endif %} {% endif %}
{% if user.role == 2 and viewed_user.role == 2 %} {% 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 }}">Zum Normalo machen</a> <a class="button" href="/admin/edit/set_admin/{{ viewed_user.id }}">{{ fluent(key="make-user-regular",
lang=user.language)
}}</a>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}

349
tests/integration-tests.rs Normal file
View File

@ -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/"));
}