dump: wip archiver

This commit is contained in:
Aravinth Manivannan 2022-12-02 19:07:09 +05:30
parent cfd1f485c2
commit fa4aa03baa
Signed by: realaravinth
GPG key ID: AD9F0F08E855ED88
86 changed files with 11725 additions and 0 deletions

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
/target
.env.local
tarpaulin-report.html

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/target
.env.local
tarpaulin-report.html
tmp/
node_modules/
assets/

3692
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

104
Cargo.toml Normal file
View file

@ -0,0 +1,104 @@
[package]
name = "pativu"
version = "0.1.0"
edition = "2021"
repository = "https://git.batsense.net/realaravinth/pativu"
homepage = "https://git.batsense.net/realaravinth/pativu"
documentation = "https://git.batsense.net/realaravinth/pativu"
readme = "README.md"
license = "AGPLv3 or later version"
authors = ["realaravinth <realaravinth@batsense.net>"]
build = "build.rs"
#[dependencies]
#actix-web = "4"
#futures-util = { version = "0.3.17", default-features = false, features = ["std"] }
#lazy_static = "1.4.0"
#log = "0.4.17"
#pretty_env_logger = "0.4.0"
#serde = { version = "1", features=["derive"]}
#actix-web-codegen-const-routes = { version = "0.1.0", tag = "0.1.0", git = "https://github.com/realaravinth/actix-web-codegen-const-routes" }
#derive_builder = "0.11.2"
#config = "0.11"
#derive_more = "0.99.17"
#url = { version = "2.2.2", features = ["serde"]}
#serde_json = { version ="1", features = ["raw_value"]}
#clap = { vesrion = "3.2.20", features = ["derive"]}
[build-dependencies]
serde_json = "1"
sqlx = { version = "0.6.1", features = [ "runtime-actix-rustls", "sqlite", "time", "offline"] }
#[dev-dependencies]
#actix-rt = "2.7.0"
#base64 = "0.13.0"
[dependencies]
actix-web = "4.0.1"
actix-http = "3.0.4"
actix-identity = "0.4.0"
actix-rt = "2"
actix-web-codegen-const-routes = { version = "0.1.0", tag = "0.1.0", git = "https://github.com/realaravinth/actix-web-codegen-const-routes" }
argon2-creds = { branch = "master", git = "https://github.com/realaravinth/argon2-creds"}
sqlx = { version = "0.6.2", features = ["sqlite", "time", "offline", "runtime-actix-rustls", "uuid", "json"] }
clap = { version = "3.2.20", features = ["derive"]}
config = "0.13"
serde = { version = "1", features = ["derive", "rc"]}
serde_json = "1"
pretty_env_logger = "0.4"
lazy_static = "1.4"
url = { version = "2.2", features = ["serde"] }
urlencoding = "2.1.0"
derive_more = "0.99"
num_cpus = "1.13"
tokio = { version = "1", features=["sync", "fs"]}
num_enum = "0.5.7"
mime_guess = "2.0.4"
mime = "0.3.16"
rust-embed = "6.3.0"
rand = "0.8.5"
tracing = { version = "0.1.37", features = ["log"]}
tracing-actix-web = "0.6.2"
toml = "0.5.9"
serde_yaml = "0.9.14"
uuid = { version = "1.2.2", features = ["serde"] }
reqwest = { version = "0.11.13", features = ["rustls-tls-native-roots", "stream", "gzip", "deflate", "brotli", "json"]}
futures-util = "0.3.25"
lol_html = "0.3.1"
scraper = "0.13.0"
[dependencies.cache-buster]
git = "https://github.com/realaravinth/cache-buster"
[dependencies.tera]
default-features = false
version = "1.15.0"
[dependencies.actix-auth-middleware]
branch = "v4"
features = ["actix_identity_backend"]
git = "https://github.com/realaravinth/actix-auth-middleware"
version = "0.2"
[dev-dependencies]
futures = "0.3.24"
mktemp = "0.4.1"
[workspace]
exclude = ["utils/cache-bust"]

72
Makefile Normal file
View file

@ -0,0 +1,72 @@
define cache_bust ## run cache_busting program
npm run sass
cd utils/cache-bust && cargo run
endef
default: ## Debug build
$(call cache_bust)
cargo run -- serve
cache-bust: ## Run cache buster on static assets
$(call cache_bust)
check: ## Check for syntax errors on all workspaces
cargo check --workspace --tests --all-features
#cd utils/cache-bust && cargo check --tests --all-features
clean: ## Clean all build artifacts and dependencies
@cargo clean
coverage: ## Generate HTML code coverage
$(call cache_bust)
cargo tarpaulin -t 1200 --out Html
dev-env: ## Download development dependencies
npm install
cargo fetch
doc: ## Prepare documentation
cargo doc --no-deps --workspace --all-features
docker: ## Build docker images
docker build \
-t realaravinth/pativu:master \
-t realaravinth/pativu:latest \
-t realaravinth/pativu:0.1.0 .
docker-publish: docker ## Build and publish docker images
docker push realaravinth/pativu:master
docker push realaravinth/pativu:latest
docker push realaravinth/pativu:0.1.0
lint: ## Lint codebase
cargo fmt -v --all -- --emit files
cargo clippy --workspace --tests --all-features
migrate: ## run migrations
$(call cache_bust)
unset DATABASE_URL && cargo build
cargo run -- migrate
release: ## Release build
$(call cache_bust)
cargo build --release
run: default ## Run debug build
cargo run -- serve
sqlx-offline-data: ## prepare sqlx offline data
cargo sqlx prepare \
--database-url=${DATABASE_URL} -- \
--all-features
test: ## Run tests
$(call cache_bust)
cargo test --all-features --no-fail-fast
xml-test-coverage: ## Generate cobertura.xml test coverage
$(call cache_bust)
cargo tarpaulin -t 1200 --out Xml
help: ## Prints help for targets with comments
@cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

22
README.md Normal file
View file

@ -0,0 +1,22 @@
[![Docker](https://img.shields.io/docker/pulls/realaravinth/librepages-conductor)](https://hub.docker.com/r/realaravinth/librepages-conductor)
[![status-badge](https://ci.batsense.net/api/badges/LibrePages/conductor/status.svg)](https://ci.batsense.net/LibrePages/conductor)
<div align="center">
<h1> Conductor </h1>
<p>
</div>
Enabling custom domain support(configuring reverse proxy, deploying TLS
certificates, etc.) are environtment dependent. LibrePages will notify
conductor when a new hostname should be deployed.
## Launch docker container
```bash
docker run -p 7000:7000 \
-v $(pwd)/config/:/etc/lpconductor/ \
-e LPCONDUCTOR_CONFIG=/etc/lpconductor/default.toml \
realaravinth/librepages-conductor:latest
```

26
build.rs Normal file
View file

@ -0,0 +1,26 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::process::Command;
fn main() {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.expect("error in git command, is git installed?");
let git_hash = String::from_utf8(output.stdout).unwrap();
println!("cargo:rustc-env=GIT_HASH={}", git_hash);
}

37
config/default.toml Normal file
View file

@ -0,0 +1,37 @@
debug = true
allow_registration = true
# source code of your copy of pages server.
source_code = "https://git.batsense.net/realaravinth/pativu"
support_email = "support@librepages.example.org"
[server]
# The port at which you want Pages to listen to
port = 7000
#IP address. Enter 0.0.0.0 to listen on all availale addresses
ip= "0.0.0.0"
# The number of worker threads that must be spun up by the Pages server.
# Minimum of two threads are advisable for top async performance but can work
# with one also.
workers = 2
domain = "localhost"
cookie_secret = "94b2b2732626fdb7736229a7c777cb451e6304c147c4549f30"
[pages]
base_path = "/tmp/pativu-defualt-config/"
[database]
# This section deals with the database location and how to access it
# Please note that at the moment, we have support for only sqlite.
# Example, if you are Batman, your config would be:
# hostname = "batcave.org"
# port = "5432"
# username = "batman"
# password = "somereallycomplicatedBatmanpassword"
hostname = "localhost"
port = "5432"
username = "sqlite"
password = "password"
name = "sqlite"
pool = 4
database_type="sqlite"

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS librepages_users (
name VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(100) UNIQUE NOT NULL,
email_verified BOOLEAN DEFAULT NULL,
password TEXT NOT NULL,
ID INTEGER PRIMARY KEY NOT NULL
);

View file

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS librepages_sites (
url VARCHAR(3000) NOT NULL UNIQUE,
ID INTEGER PRIMARY KEY NOT NULL,
owned_by INTEGER NOT NULL references librepages_users(ID) ON DELETE CASCADE
);

385
package-lock.json generated Normal file
View file

@ -0,0 +1,385 @@
{
"name": "librepages",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "librepages",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"sass": "^1.54.9"
}
},
"node_modules/anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true,
"dependencies": {
"fill-range": "^7.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/immutable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
"integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
"dev": true
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/sass": {
"version": "1.54.9",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.54.9.tgz",
"integrity": "sha512-xb1hjASzEH+0L0WI9oFjqhRi51t/gagWnxLiwUNMltA0Ab6jIDkAacgKiGYKM9Jhy109osM7woEEai6SXeJo5Q==",
"dev": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
}
},
"dependencies": {
"anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"dev": true,
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
}
},
"binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true
},
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true,
"requires": {
"fill-range": "^7.0.1"
}
},
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"dev": true,
"requires": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"fsevents": "~2.3.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dev": true,
"requires": {
"to-regex-range": "^5.0.1"
}
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"optional": true
},
"glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"requires": {
"is-glob": "^4.0.1"
}
},
"immutable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
"integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
"dev": true
},
"is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"requires": {
"binary-extensions": "^2.0.0"
}
},
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true
},
"is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"requires": {
"is-extglob": "^2.1.1"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true
},
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true
},
"readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"requires": {
"picomatch": "^2.2.1"
}
},
"sass": {
"version": "1.54.9",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.54.9.tgz",
"integrity": "sha512-xb1hjASzEH+0L0WI9oFjqhRi51t/gagWnxLiwUNMltA0Ab6jIDkAacgKiGYKM9Jhy109osM7woEEai6SXeJo5Q==",
"dev": true,
"requires": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
}
},
"source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"dev": true
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"requires": {
"is-number": "^7.0.0"
}
}
}
}

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "librepages",
"version": "1.0.0",
"description": "<div align=\"center\"> <h1> Pages </h1> <p>",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"sass": "rm -rf static/cache/css/*.css && sass templates/main.scss static/cache/css/main.css && sass templates/mobile.scss static/cache/css/mobile.css"
},
"repository": {
"type": "git",
"url": "git+https://github.com/realaravinth/librepages.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/realaravinth/librepages/issues"
},
"homepage": "https://github.com/realaravinth/librepages#readme",
"devDependencies": {
"sass": "^1.54.9"
}
}

3
sqlx-data.json Normal file
View file

@ -0,0 +1,3 @@
{
"db": "SQLite"
}

17
src/api/mod.rs Normal file
View file

@ -0,0 +1,17 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
pub mod v1;

144
src/api/v1/account/mod.rs Normal file
View file

@ -0,0 +1,144 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_identity::Identity;
use actix_web::{web, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
use crate::ctx::api::v1::account::*;
use crate::ctx::api::v1::auth::Password;
use crate::errors::*;
use crate::AppCtx;
#[cfg(test)]
pub mod test;
pub use super::auth;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AccountCheckPayload {
pub val: String,
}
pub fn services(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(username_exists);
cfg.service(set_username);
cfg.service(email_exists);
cfg.service(set_email);
cfg.service(delete_account);
cfg.service(update_user_password);
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Email {
pub email: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Username {
pub username: String,
}
/// update username
#[actix_web_codegen_const_routes::post(
path = "crate::V1_API_ROUTES.account.update_username",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Update username", skip(ctx, payload, id))]
async fn set_username(
id: Identity,
payload: web::Json<Username>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
let username = id.identity().unwrap();
let new_name = ctx.update_username(&username, &payload.username).await?;
id.forget();
id.remember(new_name);
Ok(HttpResponse::Ok())
}
#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.account.username_exists")]
#[tracing::instrument(name = "Check if username exists", skip(ctx, payload))]
async fn username_exists(
payload: web::Json<AccountCheckPayload>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
Ok(HttpResponse::Ok().json(ctx.username_exists(&payload.val).await?))
}
#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.account.email_exists")]
#[tracing::instrument(name = "Check if email exists", skip(ctx, payload))]
pub async fn email_exists(
payload: web::Json<AccountCheckPayload>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
Ok(HttpResponse::Ok().json(ctx.email_exists(&payload.val).await?))
}
/// update email
#[actix_web_codegen_const_routes::post(
path = "crate::V1_API_ROUTES.account.update_email",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Update email", skip(ctx, payload, id))]
async fn set_email(
id: Identity,
payload: web::Json<Email>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
let username = id.identity().unwrap();
ctx.set_email(&username, &payload.email).await?;
Ok(HttpResponse::Ok())
}
#[actix_web_codegen_const_routes::post(
path = "crate::V1_API_ROUTES.account.delete",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Delete account", skip(ctx, payload, id))]
async fn delete_account(
id: Identity,
payload: web::Json<Password>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
let username = id.identity().unwrap();
ctx.delete_user(&username, &payload.password).await?;
id.forget();
Ok(HttpResponse::Ok())
}
#[actix_web_codegen_const_routes::post(
path = "crate::V1_API_ROUTES.account.update_password",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Update user password", skip(ctx, payload, id))]
async fn update_user_password(
id: Identity,
ctx: AppCtx,
payload: web::Json<ChangePasswordReqest>,
) -> ServiceResult<impl Responder> {
let username = id.identity().unwrap();
let payload = payload.into_inner();
ctx.change_password(&username, &payload).await?;
Ok(HttpResponse::Ok())
}

296
src/api/v1/account/test.rs Normal file
View file

@ -0,0 +1,296 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::sync::Arc;
use actix_web::http::StatusCode;
use actix_web::test;
use super::*;
use crate::api::v1::ROUTES;
use crate::ctx::api::v1::auth::Password;
use crate::ctx::Ctx;
use crate::*;
#[actix_rt::test]
async fn postgrest_account_works() {
let (_, ctx) = crate::tests::get_ctx().await;
uname_email_exists_works(ctx.clone()).await;
email_udpate_password_validation_del_userworks(ctx.clone()).await;
username_update_works(ctx.clone()).await;
update_password_works(ctx.clone()).await;
}
async fn uname_email_exists_works(ctx: Arc<Ctx>) {
const NAME: &str = "testuserexists";
const PASSWORD: &str = "longpasswordasdfa2";
const EMAIL: &str = "testuserexists2@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
let mut payload = AccountCheckPayload { val: NAME.into() };
let user_exists_resp = test::call_service(
&app,
post_request!(&payload, ROUTES.account.username_exists)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(user_exists_resp.status(), StatusCode::OK);
let mut resp: AccountCheckResp = test::read_body_json(user_exists_resp).await;
assert!(resp.exists);
payload.val = PASSWORD.into();
let user_doesnt_exist = test::call_service(
&app,
post_request!(&payload, ROUTES.account.username_exists)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(user_doesnt_exist.status(), StatusCode::OK);
resp = test::read_body_json(user_doesnt_exist).await;
assert!(!resp.exists);
let email_doesnt_exist = test::call_service(
&app,
post_request!(&payload, ROUTES.account.email_exists)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(email_doesnt_exist.status(), StatusCode::OK);
resp = test::read_body_json(email_doesnt_exist).await;
assert!(!resp.exists);
payload.val = EMAIL.into();
let email_exist = test::call_service(
&app,
post_request!(&payload, ROUTES.account.email_exists)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(email_exist.status(), StatusCode::OK);
resp = test::read_body_json(email_exist).await;
assert!(resp.exists);
}
async fn email_udpate_password_validation_del_userworks(ctx: Arc<Ctx>) {
const NAME: &str = "testuser2";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "testuser1@a.com2";
const NAME2: &str = "eupdauser";
const EMAIL2: &str = "eupdauser@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let _ = ctx.delete_user(NAME2, PASSWORD).await;
let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await;
let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
// update email
let mut email_payload = Email {
email: EMAIL.into(),
};
let email_update_resp = test::call_service(
&app,
post_request!(&email_payload, ROUTES.account.update_email)
//post_request!(&email_payload, EMAIL_UPDATE)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(email_update_resp.status(), StatusCode::OK);
// check duplicate email while duplicate email
email_payload.email = EMAIL2.into();
// ctx.bad_post_req_test(
// NAME,
// PASSWORD,
// ROUTES.account.update_email,
// &email_payload,
// ServiceError::EmailTaken,
// )
// .await;
// wrong password while deleteing account
let mut payload = Password {
password: NAME.into(),
};
ctx.bad_post_req_test(
NAME,
PASSWORD,
ROUTES.account.delete,
&payload,
ServiceError::WrongPassword,
)
.await;
// delete account
payload.password = PASSWORD.into();
let delete_user_resp = test::call_service(
&app,
post_request!(&payload, ROUTES.account.delete)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(delete_user_resp.status(), StatusCode::OK);
// try to delete an account that doesn't exist
let account_not_found_resp = test::call_service(
&app,
post_request!(&payload, ROUTES.account.delete)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(account_not_found_resp.status(), StatusCode::NOT_FOUND);
let txt: ErrorToResponse = test::read_body_json(account_not_found_resp).await;
assert_eq!(txt.error, format!("{}", ServiceError::AccountNotFound));
}
async fn username_update_works(ctx: Arc<Ctx>) {
const NAME: &str = "testuserupda";
const EMAIL: &str = "testuserupda@sss.com";
const EMAIL2: &str = "testuserupda2@sss.com";
const PASSWORD: &str = "longpassword2";
const NAME2: &str = "terstusrtds";
const NAME_CHANGE: &str = "terstusrtdsxx";
let _ = futures::join!(
ctx.delete_user(NAME, PASSWORD),
ctx.delete_user(NAME2, PASSWORD),
ctx.delete_user(NAME_CHANGE, PASSWORD)
);
let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await;
let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
// update username
let mut username_udpate = Username {
username: NAME_CHANGE.into(),
};
let username_update_resp = test::call_service(
&app,
post_request!(&username_udpate, ROUTES.account.update_username)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(username_update_resp.status(), StatusCode::OK);
// check duplicate username with duplicate username
username_udpate.username = NAME2.into();
// ctx.bad_post_req_test(
// NAME_CHANGE,
// PASSWORD,
// ROUTES.account.update_username,
// &username_udpate,
// ServiceError::UsernameTaken,
// )
// .await;
}
async fn update_password_works(ctx: Arc<Ctx>) {
const NAME: &str = "updatepassuser";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "updatepassuser@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
let new_password = "newpassword";
let update_password = ChangePasswordReqest {
password: PASSWORD.into(),
new_password: new_password.into(),
confirm_new_password: PASSWORD.into(),
};
let res = ctx.change_password(NAME, &update_password).await;
assert!(res.is_err());
assert_eq!(res, Err(ServiceError::PasswordsDontMatch));
let update_password = ChangePasswordReqest {
password: PASSWORD.into(),
new_password: new_password.into(),
confirm_new_password: new_password.into(),
};
assert!(ctx.change_password(NAME, &update_password).await.is_ok());
let update_password = ChangePasswordReqest {
password: new_password.into(),
new_password: new_password.into(),
confirm_new_password: PASSWORD.into(),
};
ctx.bad_post_req_test(
NAME,
new_password,
ROUTES.account.update_password,
&update_password,
ServiceError::PasswordsDontMatch,
)
.await;
let update_password = ChangePasswordReqest {
password: PASSWORD.into(),
new_password: PASSWORD.into(),
confirm_new_password: PASSWORD.into(),
};
ctx.bad_post_req_test(
NAME,
new_password,
ROUTES.account.update_password,
&update_password,
ServiceError::WrongPassword,
)
.await;
let update_password = ChangePasswordReqest {
password: new_password.into(),
new_password: PASSWORD.into(),
confirm_new_password: PASSWORD.into(),
};
let update_password_resp = test::call_service(
&app,
post_request!(&update_password, ROUTES.account.update_password)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(update_password_resp.status(), StatusCode::OK);
}

74
src/api/v1/auth.rs Normal file
View file

@ -0,0 +1,74 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::ctx::api::v1::auth::{Login, Register};
use actix_identity::Identity;
use actix_web::http::header;
use actix_web::{web, HttpResponse, Responder};
use super::RedirectQuery;
use crate::errors::*;
use crate::AppCtx;
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(register);
cfg.service(login);
cfg.service(signout);
}
#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.auth.register")]
#[tracing::instrument(name = "Register new user", skip(ctx, payload))]
async fn register(payload: web::Json<Register>, ctx: AppCtx) -> ServiceResult<impl Responder> {
ctx.register(&payload).await?;
Ok(HttpResponse::Ok())
}
#[actix_web_codegen_const_routes::post(path = "crate::V1_API_ROUTES.auth.login")]
#[tracing::instrument(name = "Login", skip(ctx, payload, id, query))]
async fn login(
id: Identity,
payload: web::Json<Login>,
query: web::Query<RedirectQuery>,
ctx: AppCtx,
) -> ServiceResult<impl Responder> {
let payload = payload.into_inner();
let username = ctx.login(&payload).await?;
id.remember(username);
let query = query.into_inner();
if let Some(redirect_to) = query.redirect_to {
Ok(HttpResponse::Found()
.insert_header((header::LOCATION, redirect_to))
.finish())
} else {
Ok(HttpResponse::Ok().into())
}
}
#[actix_web_codegen_const_routes::get(
path = "crate::V1_API_ROUTES.auth.logout",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Sign out", skip(id))]
async fn signout(id: Identity) -> impl Responder {
use actix_auth_middleware::GetLoginRoute;
if id.identity().is_some() {
id.forget();
}
HttpResponse::Found()
.append_header((header::LOCATION, crate::V1_API_ROUTES.get_login_route(None)))
.finish()
}

114
src/api/v1/meta.rs Normal file
View file

@ -0,0 +1,114 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::{web, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
use crate::{AppCtx, GIT_COMMIT_HASH, VERSION};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BuildDetails<'a> {
pub version: &'a str,
pub git_commit_hash: &'a str,
pub source_code: &'a str,
}
pub mod routes {
pub struct Meta {
pub build_details: &'static str,
pub health: &'static str,
}
impl Meta {
pub const fn new() -> Self {
Self {
build_details: "/api/v1/meta/build",
health: "/api/v1/meta/health",
}
}
}
}
/// emits build details of the binary
#[actix_web_codegen_const_routes::get(path = "crate::V1_API_ROUTES.meta.build_details")]
#[tracing::instrument(name = "Fetch Build Details", skip(ctx))]
async fn build_details(ctx: AppCtx) -> impl Responder {
let build = BuildDetails {
version: VERSION,
git_commit_hash: GIT_COMMIT_HASH,
source_code: &ctx.settings.source_code,
};
HttpResponse::Ok().json(build)
}
#[derive(Clone, Debug, Deserialize, Serialize)]
/// Health check return datatype
pub struct Health {
db: bool,
}
/// checks all components of the system
#[actix_web_codegen_const_routes::get(path = "crate::V1_API_ROUTES.meta.health")]
#[tracing::instrument(name = "Fetch health", skip(ctx))]
async fn health(ctx: crate::AppCtx) -> impl Responder {
let res = Health {
db: ctx.db.ping().await,
};
HttpResponse::Ok().json(res)
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(build_details);
cfg.service(health);
}
#[cfg(test)]
mod tests {
use actix_web::{http::StatusCode, test};
use crate::*;
#[actix_rt::test]
async fn build_details_works() {
let (_dir, ctx) = tests::get_ctx().await;
println!("[log] test configuration {:#?}", ctx.settings);
let app = get_app!(ctx).await;
let resp = get_request!(app, V1_API_ROUTES.meta.build_details);
check_status!(resp, StatusCode::OK);
}
#[actix_rt::test]
async fn health_works() {
use actix_web::test;
let (_dir, ctx) = tests::get_ctx().await;
let app = get_app!(ctx).await;
let resp = test::call_service(
&app,
test::TestRequest::get()
.uri(crate::V1_API_ROUTES.meta.health)
.to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let health_resp: super::Health = test::read_body_json(resp).await;
assert!(health_resp.db);
}
}

44
src/api/v1/mod.rs Normal file
View file

@ -0,0 +1,44 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_auth_middleware::Authentication;
use actix_web::web::ServiceConfig;
use serde::Deserialize;
pub mod account;
pub mod auth;
pub mod meta;
pub mod routes;
pub use routes::ROUTES;
pub fn services(cfg: &mut ServiceConfig) {
auth::services(cfg);
account::services(cfg);
meta::services(cfg);
}
#[derive(Deserialize)]
pub struct RedirectQuery {
pub redirect_to: Option<String>,
}
pub fn get_auth_middleware() -> Authentication<routes::Routes> {
Authentication::with_identity(ROUTES)
}
#[cfg(test)]
mod tests;

100
src/api/v1/pages.rs Normal file
View file

@ -0,0 +1,100 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::{web, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::errors::*;
use crate::AppCtx;
pub mod routes {
pub struct Deploy {
pub update: &'static str,
}
impl Deploy {
pub const fn new() -> Self {
Self {
update: "/api/v1/update",
}
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DeployEvent {
pub secret: String,
pub branch: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DeployEventResp {
pub id: Uuid,
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(update);
}
#[cfg(test)]
mod tests {
use actix_web::{http::StatusCode, test};
use crate::tests;
use crate::*;
use super::*;
#[actix_rt::test]
async fn deploy_update_works() {
const NAME: &str = "dplyupdwrkuser";
const PASSWORD: &str = "longpasswordasdfa2";
const EMAIL: &str = "dplyupdwrkuser@a.com";
let (_dir, ctx) = tests::get_ctx().await;
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, _signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let page = ctx.add_test_site(NAME.into()).await;
let app = get_app!(ctx).await;
let mut payload = DeployEvent {
secret: page.secret.clone(),
branch: page.branch.clone(),
};
let resp = test::call_service(
&app,
post_request!(&payload, V1_API_ROUTES.deploy.update).to_request(),
)
.await;
check_status!(resp, StatusCode::OK);
let event_id: DeployEventResp = actix_web::test::read_body_json(resp).await;
let update_event = ctx.db.get_event(&page.domain, &event_id.id).await.unwrap();
assert_eq!(&update_event.site, &page.domain);
assert_eq!(update_event.id, event_id.id);
payload.secret = page.branch.clone();
let resp = test::call_service(
&app,
post_request!(&payload, V1_API_ROUTES.deploy.update).to_request(),
)
.await;
check_status!(resp, StatusCode::NOT_FOUND);
}
}

117
src/api/v1/routes.rs Normal file
View file

@ -0,0 +1,117 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! V1 API Routes
use actix_auth_middleware::GetLoginRoute;
use super::meta::routes::Meta;
/// constant [Routes](Routes) instance
pub const ROUTES: Routes = Routes::new();
/// Authentication routes
pub struct Auth {
/// logout route
pub logout: &'static str,
/// login route
pub login: &'static str,
/// registration route
pub register: &'static str,
}
impl Auth {
/// create new instance of Authentication route
pub const fn new() -> Auth {
let login = "/api/v1/signin";
let logout = "/api/v1/logout";
let register = "/api/v1/signup";
Auth {
logout,
login,
register,
}
}
}
/// Account management routes
pub struct Account {
/// delete account route
pub delete: &'static str,
/// route to check if an email exists
pub email_exists: &'static str,
/// route to update a user's email
pub update_email: &'static str,
/// route to update password
pub update_password: &'static str,
/// route to check if a username is already registered
pub username_exists: &'static str,
/// route to change username
pub update_username: &'static str,
}
impl Account {
/// create a new instance of [Account][Account] routes
pub const fn new() -> Account {
let delete = "/api/v1/account/delete";
let email_exists = "/api/v1/account/email/exists";
let username_exists = "/api/v1/account/username/exists";
let update_username = "/api/v1/account/username/update";
let update_email = "/api/v1/account/email/update";
let update_password = "/api/v1/account/password/update";
Account {
delete,
email_exists,
update_email,
update_password,
username_exists,
update_username,
}
}
}
/// Top-level routes data structure for V1 AP1
pub struct Routes {
/// Authentication routes
pub auth: Auth,
/// Account routes
pub account: Account,
/// Meta routes
pub meta: Meta,
}
impl Routes {
/// create new instance of Routes
const fn new() -> Routes {
Routes {
auth: Auth::new(),
account: Account::new(),
meta: Meta::new(),
}
}
}
impl GetLoginRoute for Routes {
fn get_login_route(&self, src: Option<&str>) -> String {
if let Some(redirect_to) = src {
format!(
"{}?redirect_to={}",
self.auth.login,
urlencoding::encode(redirect_to)
)
} else {
self.auth.register.to_string()
}
}
}

174
src/api/v1/tests/auth.rs Normal file
View file

@ -0,0 +1,174 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_auth_middleware::GetLoginRoute;
use actix_web::http::{header, StatusCode};
use actix_web::test;
use crate::api::v1::ROUTES;
use crate::ctx::api::v1::auth::{Login, Register};
use crate::ctx::ArcCtx;
use crate::errors::*;
use crate::*;
#[actix_rt::test]
async fn postgrest_auth_works() {
let (_, ctx) = crate::tests::get_ctx().await;
auth_works(ctx.clone()).await;
serverside_password_validation_works(ctx).await;
}
async fn auth_works(ctx: ArcCtx) {
const NAME: &str = "testuserfoo";
const PASSWORD: &str = "longpassword";
const EMAIL: &str = "testuser1foo@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let app = get_app!(ctx).await;
// 1. Register and signin
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
// Sign in with email
ctx.signin_test(EMAIL, PASSWORD).await;
// 2. check if duplicate username is allowed
let mut msg = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: PASSWORD.into(),
email: EMAIL.into(),
};
msg.username = format!("asdfasd{}", msg.username);
// ctx.bad_post_req_test(
// NAME,
// PASSWORD,
// ROUTES.auth.register,
// &msg,
// ServiceError::EmailTaken,
// )
// .await;
msg.email = format!("asdfasd{}", msg.email);
msg.username = NAME.into();
// ctx.bad_post_req_test(
// NAME,
// PASSWORD,
// ROUTES.auth.register,
// &msg,
// ServiceError::UsernameTaken,
// )
// .await;
// 3. sigining in with non-existent user
let mut creds = Login {
login: "nonexistantuser".into(),
password: msg.password.clone(),
};
ctx.bad_post_req_test(
NAME,
PASSWORD,
ROUTES.auth.login,
&creds,
ServiceError::AccountNotFound,
)
.await;
creds.login = "nonexistantuser@example.com".into();
ctx.bad_post_req_test(
NAME,
PASSWORD,
ROUTES.auth.login,
&creds,
ServiceError::AccountNotFound,
)
.await;
// 4. trying to signin with wrong password
creds.login = NAME.into();
creds.password = NAME.into();
ctx.bad_post_req_test(
NAME,
PASSWORD,
ROUTES.auth.login,
&creds,
ServiceError::WrongPassword,
)
.await;
// 5. signout
let signout_resp = test::call_service(
&app,
test::TestRequest::get()
.uri(ROUTES.auth.logout)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(signout_resp.status(), StatusCode::FOUND);
let headers = signout_resp.headers();
assert_eq!(
headers.get(header::LOCATION).unwrap(),
&crate::V1_API_ROUTES.get_login_route(None)
);
let creds = Login {
login: NAME.into(),
password: PASSWORD.into(),
};
//6. sigin with redirect URL set
let redirect_to = ROUTES.auth.logout;
let resp = test::call_service(
&app,
post_request!(&creds, &ROUTES.get_login_route(Some(redirect_to))).to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::FOUND);
let headers = resp.headers();
assert_eq!(headers.get(header::LOCATION).unwrap(), &redirect_to);
}
async fn serverside_password_validation_works(ctx: ArcCtx) {
const NAME: &str = "testuser542";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "testuser542@example.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let app = get_app!(ctx).await;
// checking to see if server-side password validation (password == password_config)
// works
let register_msg = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: NAME.into(),
email: EMAIL.into(),
};
let resp = test::call_service(
&app,
post_request!(&register_msg, ROUTES.auth.register).to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let txt: ErrorToResponse = test::read_body_json(resp).await;
assert_eq!(txt.error, format!("{}", ServiceError::PasswordsDontMatch));
}

18
src/api/v1/tests/mod.rs Normal file
View file

@ -0,0 +1,18 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
mod auth;
mod protected;

View file

@ -0,0 +1,70 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::http::StatusCode;
use actix_web::test;
use crate::ctx::ArcCtx;
//use crate::pages::PAGES;
use crate::*;
use crate::tests::*;
#[actix_rt::test]
async fn postgrest_protected_routes_work() {
let (_, ctx) = get_ctx().await;
protected_routes_work(ctx.clone()).await
}
async fn protected_routes_work(ctx: ArcCtx) {
const NAME: &str = "testuser619";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "testuser119@a.com2";
let _post_protected_urls = [
"/api/v1/account/secret/",
"/api/v1/account/email/",
"/api/v1/account/delete",
];
let get_protected_urls = [
V1_API_ROUTES.auth.logout,
// PAGES.auth.logout,
// PAGES.home,
];
let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
for url in get_protected_urls.iter() {
let resp = get_request!(&app, url);
assert_eq!(resp.status(), StatusCode::FOUND);
let authenticated_resp = get_request!(&app, url, cookies.clone());
println!("{url}");
if url == &V1_API_ROUTES.auth.logout {
// || url == &PAGES.auth.logout {
assert_eq!(authenticated_resp.status(), StatusCode::FOUND);
} else {
assert_eq!(authenticated_resp.status(), StatusCode::OK);
}
}
}

View file

@ -0,0 +1 @@
{"map":{"./static/cache/css/main.css":"./assets/css/main.C5E0456C4A1FB573F1A5A95D4490E00C60355FBF576EE380B61FB3A5C9D8DBB9.css","./static/cache/css/mobile.css.map":"./assets/css/mobile.css.53B52AE67347949A7066AA3C490CCB02A83D37D6B98A315365C7BB9E21794CA8.map","./static/cache/css/main.css.map":"./assets/css/main.css.F6A1AB66BB2E32F0C4BB1C550A741B010F8FBD134AFF5C65CF9C286D252AE05A.map","./static/cache/css/mobile.css":"./assets/css/mobile.6A41051B957A206B1CD6CFE04FD161558EA3D3EC944CBC191E318F3A765DF75B.css"},"base_dir":"./assets"}

17
src/ctx/api/mod.rs Normal file
View file

@ -0,0 +1,17 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
pub mod v1;

136
src/ctx/api/v1/account.rs Normal file
View file

@ -0,0 +1,136 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Account management utility datastructures and methods
use serde::{Deserialize, Serialize};
pub use super::auth;
use crate::ctx::Ctx;
use crate::db;
use crate::errors::*;
#[derive(Clone, Debug, Deserialize, Serialize)]
/// Data structure used in `*_exists` methods
pub struct AccountCheckResp {
/// set to true if the attribute in question exists
pub exists: bool,
}
/// Data structure used to change password of a registered user
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ChangePasswordReqest {
/// current password
pub password: String,
/// new password
pub new_password: String,
/// new password confirmation
pub confirm_new_password: String,
}
impl Ctx {
/// check if email exists on database
pub async fn email_exists(&self, email: &str) -> ServiceResult<AccountCheckResp> {
let resp = AccountCheckResp {
exists: self.db.email_exists(email).await?,
};
Ok(resp)
}
/// update email
pub async fn set_email(&self, username: &str, new_email: &str) -> ServiceResult<()> {
self.creds.email(new_email)?;
let username = self.creds.username(username)?;
let payload = db::UpdateEmail {
username: &username,
new_email,
};
self.db.update_email(&payload).await?;
Ok(())
}
/// check if email exists in database
pub async fn username_exists(&self, username: &str) -> ServiceResult<AccountCheckResp> {
let processed_uname = self.creds.username(username)?;
let resp = AccountCheckResp {
exists: self.db.username_exists(&processed_uname).await?,
};
Ok(resp)
}
/// update username of a registered user
pub async fn update_username(
&self,
current_username: &str,
new_username: &str,
) -> ServiceResult<String> {
let processed_uname = self.creds.username(new_username)?;
self.db
.update_username(current_username, &processed_uname)
.await?;
Ok(processed_uname)
}
// returns Ok(()) upon successful authentication
async fn authenticate(&self, username: &str, password: &str) -> ServiceResult<()> {
use argon2_creds::Config;
let username = self.creds.username(username)?;
let resp = self
.db
.get_password(&db::Login::Username(&username))
.await?;
if Config::verify(&resp.hash, password)? {
Ok(())
} else {
Err(ServiceError::WrongPassword)
}
}
/// delete user
pub async fn delete_user(&self, username: &str, password: &str) -> ServiceResult<()> {
let username = self.creds.username(username)?;
self.authenticate(&username, password).await?;
self.db.delete_user(&username).await?;
Ok(())
}
/// change password
pub async fn change_password(
&self,
username: &str,
payload: &ChangePasswordReqest,
) -> ServiceResult<()> {
if payload.new_password != payload.confirm_new_password {
return Err(ServiceError::PasswordsDontMatch);
}
self.authenticate(username, &payload.password).await?;
let hash = self.creds.password(&payload.new_password)?;
let username = self.creds.username(username)?;
let db_payload = db::NameHash { username, hash };
self.db.update_password(&db_payload).await?;
Ok(())
}
}

104
src/ctx/api/v1/auth.rs Normal file
View file

@ -0,0 +1,104 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Authentication helper methods and data structures
use serde::{Deserialize, Serialize};
use crate::ctx::Ctx;
use crate::db;
use crate::errors::*;
/// Register payload
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Register {
/// username
pub username: String,
/// password
pub password: String,
/// password confirmation: `password` and `confirm_password` must match
pub confirm_password: String,
pub email: String,
}
/// Login payload
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Login {
// login accepts both username and email under "username field"
// TODO update all instances where login is used
/// user identifier: either username or email
/// an email is detected by checkinf for the existence of `@` character
pub login: String,
/// password
pub password: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
/// struct used to represent password
pub struct Password {
/// password
pub password: String,
}
impl Ctx {
/// Log in method. Returns `Ok(())` when user is authenticated and errors when authentication
/// fails
pub async fn login(&self, payload: &Login) -> ServiceResult<String> {
use argon2_creds::Config;
let verify = |stored: &str, received: &str| {
if Config::verify(stored, received)? {
Ok(())
} else {
Err(ServiceError::WrongPassword)
}
};
let creds = if payload.login.contains('@') {
self.db
.get_password(&db::Login::Email(&payload.login))
.await?
} else {
self.db
.get_password(&db::Login::Username(&payload.login))
.await?
};
verify(&creds.hash, &payload.password)?;
Ok(creds.username)
}
/// register new user
pub async fn register(&self, payload: &Register) -> ServiceResult<()> {
if !self.settings.allow_registration {
return Err(ServiceError::ClosedForRegistration);
}
if payload.password != payload.confirm_password {
return Err(ServiceError::PasswordsDontMatch);
}
let username = self.creds.username(&payload.username)?;
let hash = self.creds.password(&payload.password)?;
self.creds.email(&payload.email)?;
let db_payload = db::Register {
username: &username,
hash: &hash,
email: &payload.email,
};
self.db.register(&db_payload).await
}
}

22
src/ctx/api/v1/mod.rs Normal file
View file

@ -0,0 +1,22 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
pub mod account;
pub mod auth;
pub mod pages;
#[cfg(test)]
mod tests;

53
src/ctx/api/v1/pages.rs Normal file
View file

@ -0,0 +1,53 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use serde::{Deserialize, Serialize};
use url::Url;
use crate::ctx::Ctx;
use crate::db;
use crate::db::Site;
use crate::errors::*;
use crate::page::Page;
use crate::settings::Settings;
use crate::utils::get_random;
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
/// Data required to add site
pub struct AddSite {
pub url: Url,
pub owner: String,
}
impl AddSite {
fn to_site(self, s: &Settings) -> Site {
let site_secret = get_random(32);
Site {
url: self.url,
owner: self.owner,
}
}
}
impl Ctx {
pub async fn add_site(&self, site: AddSite) -> ServiceResult<Page> {
let db_site = site.to_site(&self.settings);
self.db.add_site(&db_site).await.unwrap();
let page = Page::from_site(&self.settings, db_site);
page.archive(&self).await.unwrap();
Ok(page)
}
}

View file

@ -0,0 +1,227 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::api::v1::account::{Email, Username};
use crate::ctx::api::v1::account::ChangePasswordReqest;
use crate::ctx::api::v1::auth::Password;
use crate::ctx::api::v1::auth::Register;
use crate::ctx::ArcCtx;
use crate::errors::*;
use crate::*;
#[actix_rt::test]
async fn postgrest_account_works() {
let (_dir, ctx) = crate::tests::get_ctx().await;
uname_email_exists_works(ctx.clone()).await;
email_udpate_password_validation_del_userworks(ctx.clone()).await;
username_update_works(ctx).await;
}
async fn uname_email_exists_works(ctx: ArcCtx) {
const NAME: &str = "testuserexistsfoo";
const NAME2: &str = "testuserexists22";
const NAME3: &str = "testuserexists32";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "accotestsuser22@a.com";
const EMAIL2: &str = "accotestsuser222@a.com";
const EMAIL3: &str = "accotestsuser322@a.com";
let _ = ctx.db.delete_user(NAME).await;
let _ = ctx.db.delete_user(PASSWORD).await;
let _ = ctx.db.delete_user(NAME2).await;
let _ = ctx.db.delete_user(NAME3).await;
// check username exists for non existent account
println!("{:?}", ctx.username_exists(NAME).await);
assert!(!ctx.username_exists(NAME).await.unwrap().exists);
// check username email for non existent account
assert!(!ctx.email_exists(EMAIL).await.unwrap().exists);
let mut register_payload = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: PASSWORD.into(),
email: EMAIL.into(),
};
ctx.register(&register_payload).await.unwrap();
register_payload.username = NAME2.into();
register_payload.email = EMAIL2.into();
ctx.register(&register_payload).await.unwrap();
// check username exists
assert!(ctx.username_exists(NAME).await.unwrap().exists);
assert!(ctx.username_exists(NAME2).await.unwrap().exists);
// check email exists
assert!(ctx.email_exists(EMAIL).await.unwrap().exists);
// update username
ctx.update_username(NAME2, NAME3).await.unwrap();
assert!(!ctx.username_exists(NAME2).await.unwrap().exists);
assert!(ctx.username_exists(NAME3).await.unwrap().exists);
// assert!(matches!(
// ctx.update_username(NAME3, NAME).await.err(),
// Some(ServiceError::UsernameTaken)
// ));
// update email
// assert_eq!(
// ctx.set_email(NAME, EMAIL2).await.err(),
// Some(ServiceError::EmailTaken)
// );
ctx.set_email(NAME, EMAIL3).await.unwrap();
// change password
let mut change_password_req = ChangePasswordReqest {
password: PASSWORD.into(),
new_password: NAME.into(),
confirm_new_password: PASSWORD.into(),
};
assert_eq!(
ctx.change_password(NAME, &change_password_req).await.err(),
Some(ServiceError::PasswordsDontMatch)
);
change_password_req.confirm_new_password = NAME.into();
ctx.change_password(NAME, &change_password_req)
.await
.unwrap();
}
async fn email_udpate_password_validation_del_userworks(ctx: ArcCtx) {
const NAME: &str = "testuser32sd2";
const PASSWORD: &str = "longpassword2";
const EMAIL: &str = "testuser12232@a.com2";
const NAME2: &str = "eupdauser22";
const EMAIL2: &str = "eupdauser22@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let _ = ctx.delete_user(NAME2, PASSWORD).await;
let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await;
let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
// update email
let mut email_payload = Email {
email: EMAIL.into(),
};
let email_update_resp = actix_web::test::call_service(
&app,
post_request!(&email_payload, crate::V1_API_ROUTES.account.update_email)
//post_request!(&email_payload, EMAIL_UPDATE)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(email_update_resp.status(), StatusCode::OK);
// check duplicate email while duplicate email
email_payload.email = EMAIL2.into();
// ctx.bad_post_req_test(
// NAME,
// PASSWORD,
// crate::V1_API_ROUTES.account.update_email,
// &email_payload,
// ServiceError::EmailTaken,
// )
// .await;
// wrong password while deleting account
let mut payload = Password {
password: NAME.into(),
};
ctx.bad_post_req_test(
NAME,
PASSWORD,
V1_API_ROUTES.account.delete,
&payload,
ServiceError::WrongPassword,
)
.await;
// delete account
payload.password = PASSWORD.into();
let delete_user_resp = actix_web::test::call_service(
&app,
post_request!(&payload, crate::V1_API_ROUTES.account.delete)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(delete_user_resp.status(), StatusCode::OK);
// try to delete an account that doesn't exist
let account_not_found_resp = actix_web::test::call_service(
&app,
post_request!(&payload, crate::V1_API_ROUTES.account.delete)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(account_not_found_resp.status(), StatusCode::NOT_FOUND);
let txt: ErrorToResponse = actix_web::test::read_body_json(account_not_found_resp).await;
assert_eq!(txt.error, format!("{}", ServiceError::AccountNotFound));
}
async fn username_update_works(ctx: ArcCtx) {
const NAME: &str = "testuse23423rupda";
const EMAIL: &str = "testu23423serupda@sss.com";
const EMAIL2: &str = "testu234serupda2@sss.com";
const PASSWORD: &str = "longpassword2";
const NAME2: &str = "terstusrt23423ds";
const NAME_CHANGE: &str = "terstu234234srtdsxx";
let _ = futures::join!(
ctx.delete_user(NAME, PASSWORD),
ctx.delete_user(NAME2, PASSWORD),
ctx.delete_user(NAME_CHANGE, PASSWORD)
);
let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await;
let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(ctx).await;
// update username
let mut username_udpate = Username {
username: NAME_CHANGE.into(),
};
let username_update_resp = actix_web::test::call_service(
&app,
post_request!(
&username_udpate,
crate::V1_API_ROUTES.account.update_username
)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(username_update_resp.status(), StatusCode::OK);
// check duplicate username with duplicate username
username_udpate.username = NAME2.into();
// ctx.bad_post_req_test(
// NAME_CHANGE,
// PASSWORD,
// V1_API_ROUTES.account.update_username,
// &username_udpate,
// ServiceError::UsernameTaken,
// )
// .await;
}

View file

@ -0,0 +1,104 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::sync::Arc;
//use crate::api::v1::auth::{Login, Register};
use crate::ctx::api::v1::auth::{Login, Register};
use crate::ctx::Ctx;
use crate::errors::*;
#[actix_rt::test]
async fn postgrest_auth_works() {
let (_dir, ctx) = crate::tests::get_ctx().await;
auth_works(ctx).await;
}
async fn auth_works(ctx: Arc<Ctx>) {
const NAME: &str = "testuser";
const PASSWORD: &str = "longpassword";
const EMAIL: &str = "testuser1@a.com";
let _ = ctx.delete_user(NAME, PASSWORD).await;
// 1. Register with email == None
let mut register_payload = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: PASSWORD.into(),
email: EMAIL.into(),
};
// registration: passwords don't match
register_payload.confirm_password = NAME.into();
assert!(matches!(
ctx.register(&register_payload).await.err(),
Some(ServiceError::PasswordsDontMatch)
));
register_payload.confirm_password = PASSWORD.into();
ctx.register(&register_payload).await.unwrap();
// check if duplicate username is allowed
// assert!(matches!(
// ctx.register(&register_payload).await.err(),
// Some(ServiceError::UsernameTaken)
// ));
// check if duplicate email is allowed
let name = format!("{}dupemail", NAME);
register_payload.username = name;
// assert!(matches!(
// ctx.register(&register_payload).await.err(),
// Some(ServiceError::EmailTaken)
// ));
// Sign in with email
let mut creds = Login {
login: EMAIL.into(),
password: PASSWORD.into(),
};
ctx.login(&creds).await.unwrap();
// signin with username
creds.login = NAME.into();
ctx.login(&creds).await.unwrap();
// sigining in with non-existent username
creds.login = "nonexistantuser".into();
assert!(matches!(
ctx.login(&creds).await.err(),
Some(ServiceError::AccountNotFound)
));
// sigining in with non-existent email
creds.login = "nonexistantuser@example.com".into();
assert!(matches!(
ctx.login(&creds).await.err(),
Some(ServiceError::AccountNotFound)
));
// sign in with incorrect password
creds.login = NAME.into();
creds.password = NAME.into();
assert!(matches!(
ctx.login(&creds).await.err(),
Some(ServiceError::WrongPassword)
));
// delete user
ctx.delete_user(NAME, PASSWORD).await.unwrap();
}

View file

@ -0,0 +1,2 @@
mod accounts;
mod auth;

73
src/ctx/mod.rs Normal file
View file

@ -0,0 +1,73 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::sync::Arc;
use std::thread;
use crate::db::*;
use crate::settings::Settings;
use argon2_creds::{Config as ArgonConfig, ConfigBuilder as ArgonConfigBuilder, PasswordPolicy};
use reqwest::Client;
use tracing::info;
pub mod api;
pub type ArcCtx = Arc<Ctx>;
#[derive(Clone)]
pub struct Ctx {
pub settings: Settings,
pub db: Database,
/// credential-procession policy
pub creds: ArgonConfig,
pub client: Client,
}
impl Ctx {
/// Get credential-processing policy
pub fn get_creds() -> ArgonConfig {
ArgonConfigBuilder::default()
.username_case_mapped(true)
.profanity(true)
.blacklist(true)
.password_policy(PasswordPolicy::default())
.build()
.unwrap()
}
pub async fn new(settings: Settings) -> Arc<Self> {
let creds = Self::get_creds();
let c = creds.clone();
#[allow(unused_variables)]
let init = thread::spawn(move || {
info!("Initializing credential manager");
c.init();
info!("Initialized credential manager");
});
let db = get_db(&settings).await;
#[cfg(not(debug_assertions))]
init.join();
Arc::new(Self {
settings,
db,
creds,
client: Client::default(),
})
}
}

624
src/db.rs Normal file
View file

@ -0,0 +1,624 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::str::FromStr;
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::types::time::OffsetDateTime;
use sqlx::ConnectOptions;
use sqlx::Error;
use sqlx::SqlitePool;
use tracing::error;
use url::Url;
use uuid::Uuid;
use crate::errors::*;
/// Connect to databse
pub enum ConnectionOptions {
/// fresh connection
Fresh(Fresh),
/// existing connection
Existing(Conn),
}
/// Use an existing database pool
pub struct Conn(pub SqlitePool);
pub struct Fresh {
pub pool_options: SqlitePoolOptions,
pub disable_logging: bool,
pub url: String,
}
impl ConnectionOptions {
async fn connect(self) -> ServiceResult<Database> {
let pool = match self {
Self::Fresh(fresh) => {
println!("from db.rs: {}", fresh.url);
let mut connect_options =
sqlx::sqlite::SqliteConnectOptions::from_str(&fresh.url).unwrap();
if fresh.disable_logging {
connect_options.disable_statement_logging();
}
sqlx::sqlite::SqliteConnectOptions::from_str(&fresh.url)
.unwrap()
.disable_statement_logging();
fresh
.pool_options
.connect_with(connect_options)
.await
.unwrap()
//.map_err(|e| ServiceError::ServiceError(Box::new(e)))?
}
Self::Existing(conn) => conn.0,
};
Ok(Database { pool })
}
}
#[derive(Clone)]
pub struct Database {
pub pool: SqlitePool,
}
impl Database {
pub async fn migrate(&self) -> ServiceResult<()> {
sqlx::migrate!("./migrations/")
.run(&self.pool)
.await
.unwrap();
//.map_err(|e| ServiceError::ServiceError(Box::new(e)))?;
Ok(())
}
pub async fn ping(&self) -> bool {
use sqlx::Connection;
if let Ok(mut con) = self.pool.acquire().await {
con.ping().await.is_ok()
} else {
false
}
}
/// register a new user
pub async fn register(&self, p: &Register<'_>) -> ServiceResult<()> {
sqlx::query!(
"INSERT INTO librepages_users
(name , password, email) VALUES ($1, $2, $3)",
p.username,
p.hash,
p.email,
)
.execute(&self.pool)
.await
.map_err(map_register_err)?;
Ok(())
}
/// delete a user
pub async fn delete_user(&self, username: &str) -> ServiceResult<()> {
sqlx::query!("DELETE FROM librepages_users WHERE name = ($1)", username)
.execute(&self.pool)
.await
.map_err(|e| map_row_not_found_err(e, ServiceError::AccountNotFound))?;
Ok(())
}
/// check if username exists
pub async fn username_exists(&self, username: &str) -> ServiceResult<bool> {
let res = sqlx::query!("SELECT ID from librepages_users WHERE name = $1", username,)
.fetch_one(&self.pool)
.await;
match res {
Ok(_) => Ok(true),
Err(Error::RowNotFound) => Ok(false),
Err(e) => Err(map_register_err(e)),
}
}
/// get user email
pub async fn get_email(&self, username: &str) -> ServiceResult<String> {
struct Email {
email: String,
}
let res = sqlx::query_as!(
Email,
"SELECT email FROM librepages_users WHERE name = $1",
username
)
.fetch_one(&self.pool)
.await
.map_err(|e| map_row_not_found_err(e, ServiceError::AccountNotFound))?;
Ok(res.email)
}
/// check if email exists
pub async fn email_exists(&self, email: &str) -> ServiceResult<bool> {
let res = sqlx::query!("SELECT ID from librepages_users WHERE email = $1", email)
.fetch_one(&self.pool)
.await;
match res {
Ok(_) => Ok(true),
Err(Error::RowNotFound) => Ok(false),
Err(e) => Err(map_register_err(e)),
}
}
/// update a user's email
pub async fn update_email(&self, p: &UpdateEmail<'_>) -> ServiceResult<()> {
sqlx::query!(
"UPDATE librepages_users set email = $1
WHERE name = $2",
p.new_email,
p.username,
)
.execute(&self.pool)
.await
.map_err(|e| map_row_not_found_err(e, ServiceError::AccountNotFound))?;
Ok(())
}
/// get a user's password
pub async fn get_password(&self, l: &Login<'_>) -> ServiceResult<NameHash> {
struct Password {
name: String,
password: String,
}
let rec = match l {
Login::Username(u) => sqlx::query_as!(
Password,
r#"SELECT name, password FROM librepages_users WHERE name = ($1)"#,
u,
)
.fetch_one(&self.pool)
.await
.map_err(|e| map_row_not_found_err(e, ServiceError::AccountNotFound))?,
Login::Email(e) => sqlx::query_as!(
Password,
r#"SELECT name, password FROM librepages_users WHERE email = ($1)"#,
e,
)
.fetch_one(&self.pool)
.await
.map_err(|e| map_row_not_found_err(e, ServiceError::AccountNotFound))?,
};
let res = NameHash {
hash: rec.password,
username: rec.name,
};
Ok(res)
}
/// update user's password
pub async fn update_password(&self, p: &NameHash) -> ServiceResult<()> {
sqlx::query!(
"UPDATE librepages_users set password = $1
WHERE name = $2",
p.hash,
p.username,
)
.execute(&self.pool)
.await
.map_err(|e| map_row_not_found_err(e, ServiceError::AccountNotFound))?;
Ok(())
}
/// update username
pub async fn update_username(&self, current: &str, new: &str) -> ServiceResult<()> {
sqlx::query!(
"UPDATE librepages_users set name = $1
WHERE name = $2",
new,
current,
)
.execute(&self.pool)
.await
.map_err(|e| map_row_not_found_err(e, ServiceError::AccountNotFound))?;
Ok(())
}
pub async fn add_site(&self, msg: &Site) -> ServiceResult<()> {
let url = msg.url.as_str();
sqlx::query!(
"
INSERT INTO librepages_sites
(url, owned_by)
VALUES ($1, ( SELECT ID FROM librepages_users WHERE name = $2 ));
",
url,
msg.owner,
)
.execute(&self.pool)
.await
.unwrap();
// .map_err(|e| map_row_not_found_err(e, ServiceError::AccountNotFound))?;
Ok(())
}
pub async fn get_site(&self, owner: &str, url: &str) -> ServiceResult<Site> {
let site = sqlx::query_as!(
InnerSite,
"SELECT url
FROM librepages_sites
WHERE owned_by = (SELECT ID FROM librepages_users WHERE name = $1 )
AND url = $2;
",
owner,
url
)
.fetch_one(&self.pool)
.await
.map_err(|e| map_row_not_found_err(e, ServiceError::WebsiteNotFound))?;
site.to_site(owner.into())
}
pub async fn list_all_sites(&self, owner: &str) -> ServiceResult<Vec<Site>> {
let mut sites = sqlx::query_as!(
InnerSite,
"SELECT url
FROM librepages_sites
WHERE owned_by = (SELECT ID FROM librepages_users WHERE name = $1 );
",
owner,
)
.fetch_all(&self.pool)
.await
.map_err(|e| map_row_not_found_err(e, ServiceError::AccountNotFound))?;
let mut res = Vec::with_capacity(sites.len());
for site in sites.drain(0..) {
res.push(site.to_site(owner.into())?);
}
Ok(res)
}
pub async fn delete_site(&self, owner: &str, url: &str) -> ServiceResult<()> {
sqlx::query!(
"DELETE FROM librepages_sites
WHERE url = ($1)
AND owned_by = ( SELECT ID FROM librepages_users WHERE name = $2);
",
url,
owner
)
.execute(&self.pool)
.await
.map_err(|e| map_row_not_found_err(e, ServiceError::WebsiteNotFound))?;
Ok(())
}
/// check if url exists
pub async fn url_exists(&self, url: &str) -> ServiceResult<bool> {
let res = sqlx::query!("SELECT ID from librepages_sites WHERE url = $1", url,)
.fetch_one(&self.pool)
.await;
match res {
Ok(_) => Ok(true),
Err(Error::RowNotFound) => Ok(false),
Err(e) => Err(map_register_err(e)),
}
}
}
struct InnerSite {
url: String,
}
impl InnerSite {
fn to_site(self, owner: String) -> ServiceResult<Site> {
Ok(Site {
url: Url::parse(&self.url)?,
owner,
})
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
/// Data required to add a new site
pub struct Site {
pub url: Url,
pub owner: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
/// Data required to register a new user
pub struct Register<'a> {
/// username of new user
pub username: &'a str,
/// hashed password of new use
pub hash: &'a str,
/// Optionally, email of new use
pub email: &'a str,
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
/// data required to update them email of a user
pub struct UpdateEmail<'a> {
/// username of the user
pub username: &'a str,
/// new email address of the user
pub new_email: &'a str,
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
/// types of credentials used as identifiers during login
pub enum Login<'a> {
/// username as login
Username(&'a str),
/// email as login
Email(&'a str),
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
/// type encapsulating username and hashed password of a user
pub struct NameHash {
/// username
pub username: String,
/// hashed password
pub hash: String,
}
fn now_unix_time_stamp() -> OffsetDateTime {
OffsetDateTime::now_utc()
}
pub async fn get_db(settings: &crate::settings::Settings) -> Database {
let pool_options = SqlitePoolOptions::new().max_connections(settings.database.pool);
ConnectionOptions::Fresh(Fresh {
pool_options,
url: settings.database.url.clone(),
disable_logging: !settings.debug,
})
.connect()
.await
.unwrap()
}
/// map custom row not found error to DB error
pub fn map_row_not_found_err(e: sqlx::Error, row_not_found: ServiceError) -> ServiceError {
if let sqlx::Error::RowNotFound = e {
row_not_found
} else {
map_register_err(e)
}
}
/// map sqlite errors to [ServiceError](ServiceError) types
fn map_register_err(e: sqlx::Error) -> ServiceError {
use sqlx::Error;
use std::borrow::Cow;
if let Error::Database(err) = e {
if err.code() == Some(Cow::from("23505")) {
let msg = err.message();
println!("{}", msg);
if msg.contains("librepages_users_name_key") {
ServiceError::UsernameTaken
} else if msg.contains("librepages_users_email_key") {
ServiceError::EmailTaken
} else {
error!("{}", msg);
ServiceError::InternalServerError
}
} else {
ServiceError::InternalServerError
}
} else {
ServiceError::InternalServerError
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
use crate::settings::Settings;
#[actix_rt::test]
async fn db_works() {
let settings = Settings::new().unwrap();
let pool_options = SqlitePoolOptions::new().max_connections(1);
let db = ConnectionOptions::Fresh(Fresh {
pool_options,
url: settings.database.url.clone(),
disable_logging: !settings.debug,
})
.connect()
.await
.unwrap();
assert!(db.ping().await);
const EMAIL: &str = "sqliteuser@foo.com";
const EMAIL2: &str = "sqliteuser2@foo.com";
const NAME: &str = "sqliteuser";
const PASSWORD: &str = "pasdfasdfasdfadf";
db.migrate().await.unwrap();
let p = super::Register {
username: NAME,
email: EMAIL,
hash: PASSWORD,
};
if db.username_exists(p.username).await.unwrap() {
db.delete_user(p.username).await.unwrap();
assert!(
!db.username_exists(p.username).await.unwrap(),
"user is deleted so username shouldn't exist"
);
}
db.register(&p).await.unwrap();
// assert!(matches!(
// db.register(&p).await,
// Err(ServiceError::UsernameTaken)
// ));
// testing get_password
// with username
let name_hash = db.get_password(&Login::Username(p.username)).await.unwrap();
assert_eq!(name_hash.hash, p.hash, "user password matches");
assert_eq!(name_hash.username, p.username, "username matches");
// with email
let mut name_hash = db.get_password(&Login::Email(p.email)).await.unwrap();
assert_eq!(name_hash.hash, p.hash, "user password matches");
assert_eq!(name_hash.username, p.username, "username matches");
// testing get_email
assert_eq!(db.get_email(p.username).await.unwrap(), p.email);
// testing email exists
assert!(
db.email_exists(p.email).await.unwrap(),
"user is registered so email should exist"
);
assert!(
db.username_exists(p.username).await.unwrap(),
"user is registered so username should exist"
);
// update password test. setting password = username
name_hash.hash = name_hash.username.clone();
db.update_password(&name_hash).await.unwrap();
let name_hash = db.get_password(&Login::Username(p.username)).await.unwrap();
assert_eq!(
name_hash.hash, p.username,
"user password matches with changed value"
);
assert_eq!(name_hash.username, p.username, "username matches");
// update username to p.email
assert!(
!db.username_exists(p.email).await.unwrap(),
"user with p.email doesn't exist. pre-check to update username to p.email"
);
db.update_username(p.username, p.email).await.unwrap();
assert!(
db.username_exists(p.email).await.unwrap(),
"user with p.email exist post-update"
);
// testing update email
let update_email = UpdateEmail {
username: p.username,
new_email: EMAIL2,
};
db.update_email(&update_email).await.unwrap();
println!(
"null user email: {}",
db.email_exists(p.email).await.unwrap()
);
assert!(
db.email_exists(p.email).await.unwrap(),
"user was with empty email but email is set; so email should exist"
);
// deleting user
db.delete_user(p.email).await.unwrap();
assert!(
!db.username_exists(p.email).await.unwrap(),
"user is deleted so username shouldn't exist"
);
}
#[actix_rt::test]
pub async fn test_db_sites() {
let settings = Settings::new().unwrap();
let pool_options = SqlitePoolOptions::new().max_connections(1);
let db = ConnectionOptions::Fresh(Fresh {
pool_options,
url: settings.database.url.clone(),
disable_logging: !settings.debug,
})
.connect()
.await
.unwrap();
assert!(db.ping().await);
const EMAIL: &str = "sqlitedbsiteuser@foo.com";
const NAME: &str = "sqlitedbsiteuser";
const PASSWORD: &str = "pasdfasdfasdfadf";
db.migrate().await.unwrap();
let p = super::Register {
username: NAME,
email: EMAIL,
hash: PASSWORD,
};
if db.username_exists(p.username).await.unwrap() {
db.delete_user(p.username).await.unwrap();
assert!(
!db.username_exists(p.username).await.unwrap(),
"user is deleted so username shouldn't exist"
);
}
db.register(&p).await.unwrap();
let site = Site {
url: Url::parse("https://db_works.tests.librepages.librepages.org").unwrap(),
owner: p.username.into(),
};
// test if url exists. Should be false
assert!(!db.url_exists(site.url.as_str()).await.unwrap());
// testing adding site
db.add_site(&site).await.unwrap();
// test if url exists. Should be true
assert!(db.url_exists(site.url.as_str()).await.unwrap());
// get site
let db_site = db.get_site(p.username, site.url.as_str()).await.unwrap();
assert_eq!(db_site, site);
// list all sites owned by user
let db_sites = db.list_all_sites(p.username).await.unwrap();
assert_eq!(db_sites.len(), 1);
assert_eq!(db_sites, vec![site.clone()]);
// delete site
db.delete_site(p.username, site.url.as_str()).await.unwrap();
// test if url exists. Should be false
assert!(!db.url_exists(site.url.as_str()).await.unwrap());
}
}

268
src/errors.rs Normal file
View file

@ -0,0 +1,268 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
//! Represents all the ways a trait can fail using this crate
use std::convert::From;
use std::io::Error as FSErrorInner;
use std::sync::Arc;
use actix_web::{
error::ResponseError,
http::{header, StatusCode},
HttpResponse, HttpResponseBuilder,
};
use argon2_creds::errors::CredsError;
use config::ConfigError as ConfigErrorInner;
use derive_more::{Display, Error};
use reqwest::Error as ReqwestError;
use serde::{Deserialize, Serialize};
use url::ParseError;
use crate::page::Page;
#[derive(Debug, Display, Error)]
pub struct FSError(#[display(fmt = "File System Error {}", _0)] pub FSErrorInner);
#[derive(Debug, Display, Error)]
pub struct ConfigError(#[display(fmt = "Configuration Error {}", _0)] pub ConfigErrorInner);
#[cfg(not(tarpaulin_include))]
impl PartialEq for FSError {
fn eq(&self, other: &Self) -> bool {
self.0.kind() == other.0.kind()
}
}
#[cfg(not(tarpaulin_include))]
impl PartialEq for ConfigError {
fn eq(&self, other: &Self) -> bool {
self.0.to_string().trim() == other.0.to_string().trim()
}
}
#[cfg(not(tarpaulin_include))]
impl From<FSErrorInner> for ServiceError {
fn from(e: FSErrorInner) -> Self {
Self::FSError(FSError(e))
}
}
#[cfg(not(tarpaulin_include))]
impl From<ConfigErrorInner> for ServiceError {
fn from(e: ConfigErrorInner) -> Self {
Self::ConfigError(ConfigError(e))
}
}
#[derive(Debug, Display, PartialEq, Error)]
#[cfg(not(tarpaulin_include))]
/// Error data structure grouping various error subtypes
pub enum ServiceError {
/// All non-specific errors are grouped under this category
#[display(fmt = "internal server error")]
InternalServerError,
#[display(fmt = "The value you entered for URL is not a URL")] //405j
/// The value you entered for url is not url"
NotAUrl,
#[display(fmt = "URL too long, maximum length can't be greater then 2048 characters")] //405
/// URL too long, maximum length can't be greater then 2048 characters
URLTooLong,
#[display(fmt = "Website not found")]
/// website not found
WebsiteNotFound,
#[display(fmt = "File not found")]
/// File not found
FileNotFound,
/// when the a path configured for a page is already taken
#[display(
fmt = "Path already used for another website. lhs: {:?} rhs: {:?}",
_0,
_1
)]
PathTaken(Arc<Page>, Arc<Page>),
/// when the a Secret configured for a page is already taken
#[display(
fmt = "Secret already used for another website. lhs: {:?} rhs: {:?}",
_0,
_1
)]
SecretTaken(Arc<Page>, Arc<Page>),
/// when the a Repository URL configured for a page is already taken
#[display(
fmt = "Repository URL already configured for another website deployment. lhs: {:?} rhs: {:?}",
_0,
_1
)]
DuplicateRepositoryURL(Arc<Page>, Arc<Page>),
#[display(fmt = "File System Error {}", _0)]
FSError(FSError),
#[display(fmt = "Unauthorized {}", _0)]
UnauthorizedOperation(#[error(not(source))] String),
#[display(fmt = "Bad request: {}", _0)]
BadRequest(#[error(not(source))] String),
#[display(fmt = "Configuration Error {}", _0)]
ConfigError(ConfigError),
#[display(fmt = "Branch {} not found", _0)]
BranchNotFound(#[error(not(source))] String),
/// Username is taken
#[display(fmt = "Username is taken")]
UsernameTaken,
/// Email is taken
#[display(fmt = "Email is taken")]
EmailTaken,
/// Account not found
#[display(fmt = "Account not found")]
AccountNotFound,
#[display(
fmt = "This server is is closed for registration. Contact admin if this is unexpecter"
)]
/// registration failure, server is is closed for registration
ClosedForRegistration,
#[display(fmt = "The value you entered for email is not an email")] //405j
/// The value you entered for email is not an email"
NotAnEmail,
#[display(fmt = "Wrong password")]
/// wrong password
WrongPassword,
/// when the value passed contains profanity
#[display(fmt = "Can't allow profanity in usernames")]
ProfanityError,
/// when the value passed contains blacklisted words
/// see [blacklist](https://github.com/shuttlecraft/The-Big-Username-Blacklist)
#[display(fmt = "Username contains blacklisted words")]
BlacklistError,
/// when the value passed contains characters not present
/// in [UsernameCaseMapped](https://tools.ietf.org/html/rfc8265#page-7)
/// profile
#[display(fmt = "username_case_mapped violation")]
UsernameCaseMappedError,
#[display(fmt = "Passsword too short")]
/// password too short
PasswordTooShort,
#[display(fmt = "password too long")]
/// password too long
PasswordTooLong,
#[display(fmt = "Passwords don't match")]
/// passwords don't match
PasswordsDontMatch,
}
impl From<ReqwestError> for ServiceError {
#[cfg(not(tarpaulin_include))]
fn from(e: ReqwestError) -> ServiceError {
tracing::error!("{}", e);
ServiceError::InternalServerError
}
}
impl From<ParseError> for ServiceError {
#[cfg(not(tarpaulin_include))]
fn from(_: ParseError) -> ServiceError {
ServiceError::NotAUrl
}
}
/// Generic result data structure
#[cfg(not(tarpaulin_include))]
pub type ServiceResult<V> = std::result::Result<V, ServiceError>;
#[derive(Serialize, Deserialize, Debug)]
#[cfg(not(tarpaulin_include))]
pub struct ErrorToResponse {
pub error: String,
}
#[cfg(not(tarpaulin_include))]
impl ResponseError for ServiceError {
#[cfg(not(tarpaulin_include))]
fn error_response(&self) -> HttpResponse {
HttpResponseBuilder::new(self.status_code())
.append_header((header::CONTENT_TYPE, "application/json; charset=UTF-8"))
.body(
serde_json::to_string(&ErrorToResponse {
error: self.to_string(),
})
.unwrap(),
)
}
#[cfg(not(tarpaulin_include))]
fn status_code(&self) -> StatusCode {
match self {
ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, // INTERNAL SERVER ERROR
ServiceError::ConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR, // INTERNAL SERVER ERROR
ServiceError::NotAUrl => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::URLTooLong => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::WebsiteNotFound => StatusCode::NOT_FOUND, //NOT FOUND,
ServiceError::PathTaken(_, _) => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::DuplicateRepositoryURL(_, _) => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::SecretTaken(_, _) => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::FSError(_) => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::UnauthorizedOperation(_) => StatusCode::UNAUTHORIZED,
ServiceError::BadRequest(_) => StatusCode::BAD_REQUEST,
ServiceError::BranchNotFound(_) => StatusCode::CONFLICT,
ServiceError::EmailTaken => StatusCode::BAD_REQUEST,
ServiceError::UsernameTaken => StatusCode::BAD_REQUEST,
ServiceError::AccountNotFound => StatusCode::NOT_FOUND,
ServiceError::FileNotFound => StatusCode::NOT_FOUND,
ServiceError::ProfanityError => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::BlacklistError => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::ClosedForRegistration => StatusCode::FORBIDDEN, //FORBIDDEN,
ServiceError::NotAnEmail => StatusCode::BAD_REQUEST, //BADREQUEST,
ServiceError::WrongPassword => StatusCode::UNAUTHORIZED, //UNAUTHORIZED,
}
}
}
impl From<CredsError> for ServiceError {
#[cfg(not(tarpaulin_include))]
fn from(e: CredsError) -> ServiceError {
match e {
CredsError::UsernameCaseMappedError => ServiceError::UsernameCaseMappedError,
CredsError::ProfainityError => ServiceError::ProfanityError,
CredsError::BlacklistError => ServiceError::BlacklistError,
CredsError::NotAnEmail => ServiceError::NotAnEmail,
CredsError::Argon2Error(_) => ServiceError::InternalServerError,
CredsError::PasswordTooLong => ServiceError::PasswordTooLong,
CredsError::PasswordTooShort => ServiceError::PasswordTooShort,
}
}
}

159
src/main.rs Normal file
View file

@ -0,0 +1,159 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::env;
use actix_identity::{CookieIdentityPolicy, IdentityService};
use actix_web::{
error::InternalError, http::StatusCode, middleware as actix_middleware, web::Data as WebData,
web::JsonConfig, App, HttpServer,
};
use clap::{Parser, Subcommand};
use static_assets::FileMap;
use tracing::info;
use tracing_actix_web::TracingLogger;
mod api;
mod ctx;
mod db;
mod errors;
mod page;
mod pages;
mod serve;
mod settings;
mod static_assets;
#[cfg(test)]
mod tests;
mod utils;
pub use crate::api::v1::ROUTES as V1_API_ROUTES;
use ctx::Ctx;
pub use settings::Settings;
pub const CACHE_AGE: u32 = 604800;
pub const GIT_COMMIT_HASH: &str = env!("GIT_HASH");
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const PKG_NAME: &str = env!("CARGO_PKG_NAME");
pub const PKG_DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
pub const PKG_HOMEPAGE: &str = env!("CARGO_PKG_HOMEPAGE");
pub type AppCtx = WebData<ctx::ArcCtx>;
lazy_static::lazy_static! {
pub static ref FILES: FileMap = FileMap::new();
}
#[derive(Parser)]
#[clap(author, version, about, long_about = None)]
struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// run database migrations
Migrate,
/// run server
Serve,
}
#[actix_web::main]
#[cfg(not(tarpaulin_include))]
async fn main() -> std::io::Result<()> {
if env::var("RUST_LOG").is_err() {
env::set_var("RUST_LOG", "info");
}
pretty_env_logger::init();
let cli = Cli::parse();
info!(
"{}: {}.\nFor more information, see: {}\nBuild info:\nVersion: {} commit: {}",
PKG_NAME, PKG_DESCRIPTION, PKG_HOMEPAGE, VERSION, GIT_COMMIT_HASH
);
let settings = Settings::new().unwrap();
match &cli.command {
Commands::Migrate => db::get_db(&settings).await.migrate().await.unwrap(),
Commands::Serve => {
let ctx = Ctx::new(settings.clone()).await;
let ctx = actix_web::web::Data::new(ctx);
serve(settings, ctx).await.unwrap();
}
}
Ok(())
}
async fn serve(settings: Settings, ctx: AppCtx) -> std::io::Result<()> {
let ip = settings.server.get_ip();
let workers = settings.server.workers.unwrap_or_else(num_cpus::get);
info!("Starting server on: http://{}", ip);
HttpServer::new(move || {
App::new()
.wrap(TracingLogger::default())
.wrap(actix_middleware::Compress::default())
.app_data(ctx.clone())
.app_data(get_json_err())
.wrap(get_identity_service(&(settings.clone())))
.wrap(
actix_middleware::DefaultHeaders::new()
.add(("Permissions-Policy", "interest-cohort=()")),
)
.wrap(actix_middleware::NormalizePath::new(
actix_middleware::TrailingSlash::Trim,
))
.configure(services)
})
.workers(workers)
.bind(ip)
.unwrap()
.run()
.await
}
#[cfg(not(tarpaulin_include))]
pub fn get_json_err() -> JsonConfig {
JsonConfig::default().error_handler(|err, _| {
//debug!("JSON deserialization error: {:?}", &err);
InternalError::new(err, StatusCode::BAD_REQUEST).into()
})
}
#[cfg(not(tarpaulin_include))]
pub fn get_identity_service(settings: &Settings) -> IdentityService<CookieIdentityPolicy> {
let cookie_secret = &settings.server.cookie_secret;
IdentityService::new(
CookieIdentityPolicy::new(cookie_secret.as_bytes())
.path("/")
.name("Authorization")
//TODO change cookie age
.max_age_secs(216000)
.domain(&settings.server.domain)
.secure(false),
)
}
pub fn services(cfg: &mut actix_web::web::ServiceConfig) {
crate::api::v1::services(cfg);
crate::pages::services(cfg);
crate::serve::services(cfg);
crate::static_assets::services(cfg);
}

93
src/page.rs Normal file
View file

@ -0,0 +1,93 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::path::Path;
#[cfg(test)]
use std::println as info;
#[cfg(test)]
use std::println as error;
#[cfg(test)]
use std::println as debug;
use futures_util::StreamExt;
use reqwest::header::CONTENT_TYPE;
use serde::Deserialize;
use serde::Serialize;
use tokio::fs;
use tokio::io::{self, AsyncWriteExt, BufWriter};
#[cfg(not(test))]
use tracing::{debug, error, info};
use url::Url;
use crate::ctx::Ctx;
use crate::db::Site;
use crate::errors::*;
use crate::settings::Settings;
use crate::utils;
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct Page {
pub file_path: String,
pub url: Url,
}
impl Page {
pub fn from_site(settings: &Settings, s: Site) -> Self {
Self {
file_path: utils::get_website_path(settings, &s.owner, &s.url)
.to_str()
.unwrap()
.to_owned(),
url: s.url,
}
}
async fn create_parent_dir_all(&self, path: &str) -> ServiceResult<()> {
if let Some(parent) = Path::new(path).parent() {
fs::create_dir_all(parent).await?;
}
Ok(())
}
pub async fn archive(&self, ctx: &Ctx) -> ServiceResult<()> {
self.create_parent_dir_all(&self.file_path).await?;
let res = ctx.client.get(self.url.as_str()).send().await?;
let mut fetch_res = false;
if let Some(content_type) = res.headers().get(CONTENT_TYPE) {
if let Ok(content_type) = content_type.to_str() {
if content_type.contains("text/html") {
fetch_res = true;
}
}
}
let mut bytes = res.bytes_stream();
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.truncate(true)
.create(true)
.open(&self.file_path)
.await?;
let mut writer = BufWriter::new(file);
while let Some(item) = bytes.next().await {
let _ = writer.write(&item?).await?;
}
writer.flush().await?;
Ok(())
}
}

121
src/pages/auth/login.rs Normal file
View file

@ -0,0 +1,121 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header::ContentType;
use tera::Context;
use crate::api::v1::RedirectQuery;
use crate::ctx::api::v1::auth::Login as LoginPayload;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub struct Login {
ctx: RefCell<Context>,
}
pub const LOGIN: TemplateFile = TemplateFile::new("login", "pages/auth/login.html");
impl CtxError for Login {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl Login {
pub fn new(settings: &Settings, payload: Option<&LoginPayload>) -> Self {
let ctx = RefCell::new(context(settings));
if let Some(payload) = payload {
ctx.borrow_mut().insert(PAYLOAD_KEY, payload);
}
Self { ctx }
}
pub fn render(&self) -> String {
TEMPLATES.render(LOGIN.name, &self.ctx.borrow()).unwrap()
}
pub fn page(s: &Settings) -> String {
let p = Self::new(s, None);
p.render()
}
}
#[actix_web_codegen_const_routes::get(path = "PAGES.auth.login")]
#[tracing::instrument(name = "Serve login page", skip(ctx))]
pub async fn get_login(ctx: AppCtx) -> impl Responder {
let login = Login::page(&ctx.settings);
let html = ContentType::html();
HttpResponse::Ok().content_type(html).body(login)
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_login);
cfg.service(login_submit);
}
#[actix_web_codegen_const_routes::post(path = "PAGES.auth.login")]
#[tracing::instrument(name = "Web UI Login", skip(id, payload, query, ctx))]
pub async fn login_submit(
id: Identity,
payload: web::Form<LoginPayload>,
query: web::Query<RedirectQuery>,
ctx: AppCtx,
) -> PageResult<impl Responder, Login> {
let username = ctx
.login(&payload)
.await
.map_err(|e| PageError::new(Login::new(&ctx.settings, Some(&payload)), e))?;
id.remember(username);
let query = query.into_inner();
if let Some(redirect_to) = query.redirect_to {
Ok(HttpResponse::Found()
.insert_header((http::header::LOCATION, redirect_to))
.finish())
} else {
Ok(HttpResponse::Found()
.insert_header((http::header::LOCATION, PAGES.dash.home))
.finish())
}
}
#[cfg(test)]
mod tests {
use super::Login;
use super::LoginPayload;
use crate::errors::*;
use crate::pages::errors::*;
use crate::settings::Settings;
#[test]
fn register_page_renders() {
let settings = Settings::new().unwrap();
Login::page(&settings);
let payload = LoginPayload {
login: "foo".into(),
password: "foo".into(),
};
let page = Login::new(&settings, Some(&payload));
page.with_error(&ReadableError::new(&ServiceError::WrongPassword));
page.render();
}
}

162
src/pages/auth/mod.rs Normal file
View file

@ -0,0 +1,162 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_identity::Identity;
use actix_web::*;
pub use super::{context, Footer, TemplateFile, PAGES, PAYLOAD_KEY, TEMPLATES};
pub mod login;
pub mod register;
#[cfg(test)]
mod test;
pub const AUTH_BASE: TemplateFile = TemplateFile::new("authbase", "pages/auth/base.html");
pub fn register_templates(t: &mut tera::Tera) {
for template in [AUTH_BASE, login::LOGIN, register::REGISTER].iter() {
template.register(t).expect(template.name);
}
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(signout);
register::services(cfg);
login::services(cfg);
}
#[actix_web_codegen_const_routes::get(
path = "PAGES.auth.logout",
wrap = "super::get_auth_middleware()"
)]
#[tracing::instrument(name = "Sign out", skip(id))]
async fn signout(id: Identity) -> impl Responder {
use actix_auth_middleware::GetLoginRoute;
if id.identity().is_some() {
id.forget();
}
HttpResponse::Found()
.append_header((http::header::LOCATION, PAGES.get_login_route(None)))
.finish()
}
//#[post(path = "PAGES.auth.login")]
//pub async fn login_submit(
// id: Identity,
// payload: web::Form<runners::Login>,
// data: AppData,
//) -> PageResult<impl Responder> {
// let payload = payload.into_inner();
// match runners::login_runner(&payload, &data).await {
// Ok(username) => {
// id.remember(username);
// Ok(HttpResponse::Found()
// .insert_header((header::LOCATION, PAGES.home))
// .finish())
// }
// Err(e) => {
// let status = e.status_code();
// let heading = status.canonical_reason().unwrap_or("Error");
//
// Ok(HttpResponseBuilder::new(status)
// .content_type("text/html; charset=utf-8")
// .body(
// IndexPage::new(heading, &format!("{}", e))
// .render_once()
// .unwrap(),
// ))
// }
// }
//}
//
//#[cfg(test)]
//mod tests {
// use actix_web::test;
//
// use super::*;
//
// use crate::api::v1::auth::runners::{Login, Register};
// use crate::data::Data;
// use crate::tests::*;
// use crate::*;
// use actix_web::http::StatusCode;
//
// #[actix_rt::test]
// async fn auth_form_works() {
// let data = Data::new().await;
// const NAME: &str = "testuserform";
// const PASSWORD: &str = "longpassword";
//
// let app = get_app!(data).await;
//
// delete_user(NAME, &data).await;
//
// // 1. Register with email == None
// let msg = Register {
// username: NAME.into(),
// password: PASSWORD.into(),
// confirm_password: PASSWORD.into(),
// email: None,
// };
// let resp = test::call_service(
// &app,
// post_request!(&msg, V1_API_ROUTES.auth.register).to_request(),
// )
// .await;
// assert_eq!(resp.status(), StatusCode::OK);
//
// // correct form login
// let msg = Login {
// login: NAME.into(),
// password: PASSWORD.into(),
// };
//
// let resp = test::call_service(
// &app,
// post_request!(&msg, PAGES.auth.login, FORM).to_request(),
// )
// .await;
// assert_eq!(resp.status(), StatusCode::FOUND);
// let headers = resp.headers();
// assert_eq!(headers.get(header::LOCATION).unwrap(), PAGES.home,);
//
// // incorrect form login
// let msg = Login {
// login: NAME.into(),
// password: NAME.into(),
// };
// let resp = test::call_service(
// &app,
// post_request!(&msg, PAGES.auth.login, FORM).to_request(),
// )
// .await;
// assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
//
// // non-existent form login
// let msg = Login {
// login: PASSWORD.into(),
// password: PASSWORD.into(),
// };
// let resp = test::call_service(
// &app,
// post_request!(&msg, PAGES.auth.login, FORM).to_request(),
// )
// .await;
// assert_eq!(resp.status(), StatusCode::NOT_FOUND);
// }
//}
//

109
src/pages/auth/register.rs Normal file
View file

@ -0,0 +1,109 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::http::header::ContentType;
use std::cell::RefCell;
use tera::Context;
use crate::ctx::api::v1::auth::Register as RegisterPayload;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub const REGISTER: TemplateFile = TemplateFile::new("register", "pages/auth/register.html");
pub struct Register {
ctx: RefCell<Context>,
}
impl CtxError for Register {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl Register {
fn new(settings: &Settings, payload: Option<&RegisterPayload>) -> Self {
let ctx = RefCell::new(context(settings));
if let Some(payload) = payload {
ctx.borrow_mut().insert(PAYLOAD_KEY, payload);
}
Self { ctx }
}
pub fn render(&self) -> String {
TEMPLATES.render(REGISTER.name, &self.ctx.borrow()).unwrap()
}
pub fn page(s: &Settings) -> String {
let p = Self::new(s, None);
p.render()
}
}
#[actix_web_codegen_const_routes::get(path = "PAGES.auth.register")]
#[tracing::instrument(name = "Serve registration page", skip(ctx))]
pub async fn get_register(ctx: AppCtx) -> impl Responder {
let login = Register::page(&ctx.settings);
let html = ContentType::html();
HttpResponse::Ok().content_type(html).body(login)
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_register);
cfg.service(register_submit);
}
#[actix_web_codegen_const_routes::post(path = "PAGES.auth.register")]
#[tracing::instrument(name = "Process web UI registration", skip(ctx))]
pub async fn register_submit(
payload: web::Form<RegisterPayload>,
ctx: AppCtx,
) -> PageResult<impl Responder, Register> {
ctx.register(&payload)
.await
.map_err(|e| PageError::new(Register::new(&ctx.settings, Some(&payload)), e))?;
Ok(HttpResponse::Found()
.insert_header((http::header::LOCATION, PAGES.auth.login))
.finish())
}
#[cfg(test)]
mod tests {
use super::Register;
use super::RegisterPayload;
use crate::errors::*;
use crate::pages::errors::*;
use crate::settings::Settings;
#[test]
fn register_page_renders() {
let settings = Settings::new().unwrap();
Register::page(&settings);
let payload = RegisterPayload {
username: "foo".into(),
password: "foo".into(),
confirm_password: "foo".into(),
email: "foo".into(),
};
let page = Register::new(&settings, Some(&payload));
page.with_error(&ReadableError::new(&ServiceError::WrongPassword));
page.render();
}
}

144
src/pages/auth/test.rs Normal file
View file

@ -0,0 +1,144 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_auth_middleware::GetLoginRoute;
use actix_web::http::header;
use actix_web::http::StatusCode;
use actix_web::test;
use super::*;
use crate::ctx::api::v1::auth::{Login, Register};
use crate::ctx::ArcCtx;
use crate::errors::*;
use crate::tests::*;
use crate::*;
#[actix_rt::test]
async fn postgrest_pages_auth_works() {
let (_, ctx) = get_ctx().await;
auth_works(ctx.clone()).await;
serverside_password_validation_works(ctx.clone()).await;
}
async fn auth_works(ctx: ArcCtx) {
const NAME: &str = "testuserform";
const EMAIL: &str = "testuserform@foo.com";
const PASSWORD: &str = "longpassword";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let app = get_app!(ctx).await;
// 1. Register with email
let msg = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: PASSWORD.into(),
email: EMAIL.into(),
};
let resp = test::call_service(
&app,
post_request!(&msg, PAGES.auth.register, FORM).to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::FOUND);
let headers = resp.headers();
assert_eq!(headers.get(header::LOCATION).unwrap(), PAGES.auth.login);
// sign in
let msg = Login {
login: NAME.into(),
password: PASSWORD.into(),
};
let resp = test::call_service(
&app,
post_request!(&msg, PAGES.auth.login, FORM).to_request(),
)
.await;
assert_eq!(resp.status(), StatusCode::FOUND);
let headers = resp.headers();
assert_eq!(headers.get(header::LOCATION).unwrap(), PAGES.dash.home);
let cookies = get_cookie!(resp);
// redirect after signin
let redirect = "/foo/bar/nonexistantuser";
let url = PAGES.get_login_route(Some(redirect));
let resp = test::call_service(&app, post_request!(&msg, &url, FORM).to_request()).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let headers = resp.headers();
assert_eq!(headers.get(header::LOCATION).unwrap(), &redirect);
// wrong password signin
let msg = Login {
login: NAME.into(),
password: NAME.into(),
};
let resp = test::call_service(
&app,
post_request!(&msg, PAGES.auth.login, FORM).to_request(),
)
.await;
assert_eq!(resp.status(), ServiceError::WrongPassword.status_code());
// signout
println!("{}", PAGES.auth.logout);
let signout_resp = test::call_service(
&app,
test::TestRequest::get()
.uri(PAGES.auth.logout)
.cookie(cookies)
.to_request(),
)
.await;
assert_eq!(signout_resp.status(), StatusCode::FOUND);
let headers = signout_resp.headers();
assert_eq!(
headers.get(header::LOCATION).unwrap(),
&PAGES.get_login_route(None)
);
let _ = ctx.delete_user(NAME, PASSWORD).await;
}
async fn serverside_password_validation_works(ctx: ArcCtx) {
const NAME: &str = "pagetestuser542";
const EMAIL: &str = "pagetestuser542@foo.com";
const PASSWORD: &str = "longpassword2";
let _ = ctx.delete_user(NAME, PASSWORD).await;
let app = get_app!(ctx).await;
// checking to see if server-side password validation (password == password_config)
// works
let register_msg = Register {
username: NAME.into(),
password: PASSWORD.into(),
confirm_password: NAME.into(),
email: EMAIL.into(),
};
let resp = test::call_service(
&app,
post_request!(&register_msg, PAGES.auth.register, FORM).to_request(),
)
.await;
assert_eq!(
resp.status(),
ServiceError::PasswordsDontMatch.status_code()
);
}

76
src/pages/dash/home.rs Normal file
View file

@ -0,0 +1,76 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header::ContentType;
use serde::{Deserialize, Serialize};
use tera::Context;
use super::get_auth_middleware;
use crate::db::Site;
use crate::errors::ServiceResult;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub const DASH_HOME: TemplateFile = TemplateFile::new("dash_home", "pages/dash/index.html");
pub struct Home {
ctx: RefCell<Context>,
}
impl CtxError for Home {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl Home {
pub fn new(settings: &Settings, sites: Option<&[Site]>) -> Self {
let ctx = RefCell::new(context(settings));
if let Some(sites) = sites {
ctx.borrow_mut().insert(PAYLOAD_KEY, sites);
}
Self { ctx }
}
pub fn render(&self) -> String {
TEMPLATES
.render(DASH_HOME.name, &self.ctx.borrow())
.unwrap()
}
}
#[actix_web_codegen_const_routes::get(path = "PAGES.dash.home", wrap = "get_auth_middleware()")]
#[tracing::instrument(name = "Dashboard homepage", skip(ctx, id))]
pub async fn get_home(ctx: AppCtx, id: Identity) -> PageResult<impl Responder, Home> {
let db_sites = ctx.db.list_all_sites(&id.identity().unwrap())
.await
.map_err(|e| PageError::new(Home::new(&ctx.settings, None), e))?;
let home = Home::new(&ctx.settings, Some(&db_sites)).render();
let html = ContentType::html();
Ok(HttpResponse::Ok().content_type(html).body(home))
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_home);
}

35
src/pages/dash/mod.rs Normal file
View file

@ -0,0 +1,35 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::get_auth_middleware;
pub use super::{context, Footer, TemplateFile, PAGES, PAYLOAD_KEY, TEMPLATES};
pub mod home;
pub mod sites;
pub fn register_templates(t: &mut tera::Tera) {
home::DASH_HOME.register(t).expect(home::DASH_HOME.name);
sites::ADD_SITE.register(t).expect(sites::ADD_SITE.name);
}
pub fn services(cfg: &mut web::ServiceConfig) {
home::services(cfg);
sites::services(cfg);
}

100
src/pages/dash/sites.rs Normal file
View file

@ -0,0 +1,100 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header::ContentType;
use serde::{Deserialize, Serialize};
use tera::Context;
use url::Url;
use super::get_auth_middleware;
use crate::ctx::api::v1::pages;
use crate::errors::ServiceResult;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::AppCtx;
pub use super::*;
pub const ADD_SITE: TemplateFile = TemplateFile::new("dash_add_site", "pages/dash/sites/add.html");
pub struct AddSite {
ctx: RefCell<Context>,
}
impl CtxError for AddSite {
fn with_error(&self, e: &ReadableError) -> String {
self.ctx.borrow_mut().insert(ERROR_KEY, e);
self.render()
}
}
impl AddSite {
pub fn new(settings: &Settings) -> Self {
let ctx = RefCell::new(context(settings));
Self { ctx }
}
pub fn render(&self) -> String {
TEMPLATES.render(ADD_SITE.name, &self.ctx.borrow()).unwrap()
}
}
#[actix_web_codegen_const_routes::get(path = "PAGES.dash.add_site", wrap = "get_auth_middleware()")]
#[tracing::instrument(name = "get add site", skip(ctx, id))]
pub async fn get_add_site(ctx: AppCtx, id: Identity) -> PageResult<impl Responder, AddSite> {
let home = AddSite::new(&ctx.settings).render();
let html = ContentType::html();
Ok(HttpResponse::Ok().content_type(html).body(home))
}
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
/// Data required to add site
pub struct TemplatePayloadAddSite {
pub url: Url,
}
#[actix_web_codegen_const_routes::post(
path = "PAGES.dash.add_site",
wrap = "get_auth_middleware()"
)]
#[tracing::instrument(name = "Post add site", skip(ctx, id))]
pub async fn post_add_site(
ctx: AppCtx,
id: Identity,
payload: web::Form<TemplatePayloadAddSite>,
) -> PageResult<impl Responder, AddSite> {
let payload = payload.into_inner();
let owner = id.identity().unwrap();
let location = format!("{}?url={}", PAGES.serve.catch_all, &payload.url);
ctx.add_site(pages::AddSite {
url: payload.url,
owner,
})
.await
.unwrap();
Ok(HttpResponse::Found()
.append_header((http::header::LOCATION, location))
.finish())
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(get_add_site);
cfg.service(post_add_site);
}

105
src/pages/errors.rs Normal file
View file

@ -0,0 +1,105 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::fmt;
use actix_web::{
error::ResponseError,
http::{header::ContentType, StatusCode},
HttpResponse, HttpResponseBuilder,
};
use derive_more::Display;
use derive_more::Error;
use serde::*;
use super::TemplateFile;
use crate::errors::ServiceError;
pub const ERROR_KEY: &str = "error";
pub const ERROR_TEMPLATE: TemplateFile = TemplateFile::new("error_comp", "components/error.html");
pub fn register_templates(t: &mut tera::Tera) {
ERROR_TEMPLATE.register(t).expect(ERROR_TEMPLATE.name);
}
/// Render template with error context
pub trait CtxError {
fn with_error(&self, e: &ReadableError) -> String;
}
#[derive(Serialize, Debug, Display, Clone)]
#[display(fmt = "title: {} reason: {}", title, reason)]
pub struct ReadableError {
pub reason: String,
pub title: String,
}
impl ReadableError {
pub fn new(e: &ServiceError) -> Self {
let reason = format!("{}", e);
let title = format!("{}", e.status_code());
Self { reason, title }
}
}
#[derive(Error, Display)]
#[display(fmt = "{}", readable)]
pub struct PageError<T> {
#[error(not(source))]
template: T,
readable: ReadableError,
#[error(not(source))]
error: ServiceError,
}
impl<T> fmt::Debug for PageError<T> {
#[cfg(not(tarpaulin_include))]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PageError")
.field("readable", &self.readable)
.finish()
}
}
impl<T: CtxError> PageError<T> {
/// create new instance of [PageError] from a template and an error
pub fn new(template: T, error: ServiceError) -> Self {
let readable = ReadableError::new(&error);
Self {
error,
template,
readable,
}
}
}
#[cfg(not(tarpaulin_include))]
impl<T: CtxError> ResponseError for PageError<T> {
fn error_response(&self) -> HttpResponse {
HttpResponseBuilder::new(self.status_code())
.content_type(ContentType::html())
.body(self.template.with_error(&self.readable))
}
fn status_code(&self) -> StatusCode {
self.error.status_code()
}
}
/// Generic result data structure
#[cfg(not(tarpaulin_include))]
pub type PageResult<V, T> = std::result::Result<V, PageError<T>>;

209
src/pages/mod.rs Normal file
View file

@ -0,0 +1,209 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::cell::RefCell;
use actix_identity::Identity;
use actix_web::http::header;
use actix_web::*;
use lazy_static::lazy_static;
use rust_embed::RustEmbed;
use serde::*;
use tera::*;
use crate::pages::errors::*;
use crate::settings::Settings;
use crate::static_assets::ASSETS;
use crate::AppCtx;
use crate::{GIT_COMMIT_HASH, VERSION};
pub mod auth;
pub mod dash;
pub mod errors;
pub mod routes;
pub use routes::get_auth_middleware;
pub use routes::PAGES;
pub struct TemplateFile {
pub name: &'static str,
pub path: &'static str,
}
impl TemplateFile {
pub const fn new(name: &'static str, path: &'static str) -> Self {
Self { name, path }
}
pub fn register(&self, t: &mut Tera) -> std::result::Result<(), tera::Error> {
t.add_raw_template(self.name, &Templates::get_template(self).expect(self.name))
}
#[cfg(test)]
#[allow(dead_code)]
pub fn register_from_file(&self, t: &mut Tera) -> std::result::Result<(), tera::Error> {
use std::path::Path;
t.add_template_file(Path::new("templates/").join(self.path), Some(self.name))
}
}
pub const PAYLOAD_KEY: &str = "payload";
pub const BASE: TemplateFile = TemplateFile::new("base", "components/base.html");
pub const FOOTER: TemplateFile = TemplateFile::new("footer", "components/footer.html");
pub const PUB_NAV: TemplateFile = TemplateFile::new("pub_nav", "components/nav/pub.html");
pub const AUTH_NAV: TemplateFile = TemplateFile::new("auth_nav", "components/nav/auth.html");
lazy_static! {
pub static ref TEMPLATES: Tera = {
let mut tera = Tera::default();
for t in [BASE, FOOTER, PUB_NAV, AUTH_NAV].iter() {
t.register(&mut tera).unwrap();
}
errors::register_templates(&mut tera);
tera.autoescape_on(vec![".html", ".sql"]);
auth::register_templates(&mut tera);
dash::register_templates(&mut tera);
tera
};
}
#[derive(RustEmbed)]
#[folder = "templates/"]
pub struct Templates;
impl Templates {
pub fn get_template(t: &TemplateFile) -> Option<String> {
match Self::get(t.path) {
Some(file) => Some(String::from_utf8_lossy(&file.data).into_owned()),
None => None,
}
}
}
pub fn context(s: &Settings) -> Context {
let mut ctx = Context::new();
let footer = Footer::new(s);
ctx.insert("footer", &footer);
ctx.insert("page", &PAGES);
ctx.insert("assets", &*ASSETS);
ctx
}
pub fn auth_ctx(_username: Option<&str>, s: &Settings) -> Context {
let mut ctx = Context::new();
let footer = Footer::new(s);
ctx.insert("footer", &footer);
ctx.insert("page", &PAGES);
ctx.insert("assets", &*ASSETS);
// ctx.insert("loggedin_user", &profile_link);
ctx
}
#[derive(Serialize)]
pub struct Footer<'a> {
version: &'a str,
support_email: &'a str,
source_code: &'a str,
git_hash: &'a str,
settings: &'a Settings,
}
impl<'a> Footer<'a> {
pub fn new(settings: &'a Settings) -> Self {
Self {
version: VERSION,
source_code: &settings.source_code,
support_email: &settings.support_email,
git_hash: &GIT_COMMIT_HASH[..8],
settings,
}
}
}
#[actix_web_codegen_const_routes::get(path = "PAGES.home")]
#[tracing::instrument(name = "Serve index page", skip(ctx, id))]
pub async fn home(ctx: AppCtx, id: Identity) -> HttpResponse {
let location = if id.identity().is_some() {
PAGES.auth.login
} else {
PAGES.dash.home
};
HttpResponse::Found()
.append_header((header::LOCATION, location))
.finish()
}
pub fn services(cfg: &mut web::ServiceConfig) {
dash::services(cfg);
auth::services(cfg);
cfg.service(home);
}
#[cfg(test)]
mod tests {
#[test]
fn templates_work_basic() {
use super::*;
use tera::Tera;
let mut tera = Tera::default();
let mut tera2 = Tera::default();
for t in [
BASE,
FOOTER,
PUB_NAV,
AUTH_NAV,
auth::AUTH_BASE,
auth::login::LOGIN,
auth::register::REGISTER,
errors::ERROR_TEMPLATE,
super::dash::home::DASH_HOME,
]
.iter()
{
t.register_from_file(&mut tera2).unwrap();
t.register(&mut tera).unwrap();
}
}
}
#[cfg(test)]
mod http_page_tests {
use actix_web::http::StatusCode;
use actix_web::test;
use crate::ctx::ArcCtx;
use crate::*;
use super::PAGES;
#[actix_rt::test]
async fn postgrest_templates_work() {
let (_, ctx) = crate::tests::get_ctx().await;
templates_work(ctx).await;
}
async fn templates_work(ctx: ArcCtx) {
let app = get_app!(ctx).await;
for file in [PAGES.auth.login, PAGES.auth.register, PAGES.home].iter() {
let resp = get_request!(&app, file);
assert_eq!(resp.status(), StatusCode::OK);
}
}
}

105
src/pages/routes.rs Normal file
View file

@ -0,0 +1,105 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_auth_middleware::{Authentication, GetLoginRoute};
use serde::*;
use crate::serve::routes::Serve;
/// constant [Pages](Pages) instance
pub const PAGES: Pages = Pages::new();
#[derive(Serialize)]
/// Top-level routes data structure for V1 AP1
pub struct Pages {
/// Authentication routes
pub auth: Auth,
/// home page
pub home: &'static str,
pub dash: Dash,
pub serve: Serve,
}
impl Pages {
/// create new instance of Routes
const fn new() -> Pages {
let auth = Auth::new();
let dash = Dash::new();
let serve = Serve::new();
let home = "/";
Pages { auth, home, dash, serve }
}
}
#[derive(Serialize)]
/// Authentication routes
pub struct Auth {
/// logout route
pub logout: &'static str,
/// login route
pub login: &'static str,
/// registration route
pub register: &'static str,
}
impl Auth {
/// create new instance of Authentication route
pub const fn new() -> Auth {
let login = "/login";
let logout = "/logout";
let register = "/join";
Auth {
logout,
login,
register,
}
}
}
#[derive(Serialize)]
/// Dashboard routes
pub struct Dash {
/// home route
pub home: &'static str,
pub add_site: &'static str,
}
impl Dash {
/// create new instance of Dash route
pub const fn new() -> Dash {
let home = "/dash";
let add_site = "/dash/sites/add";
Dash { home, add_site }
}
}
pub fn get_auth_middleware() -> Authentication<Pages> {
Authentication::with_identity(PAGES)
}
impl GetLoginRoute for Pages {
fn get_login_route(&self, src: Option<&str>) -> String {
if let Some(redirect_to) = src {
format!(
"{}?redirect_to={}",
self.auth.login,
urlencoding::encode(redirect_to)
)
} else {
self.auth.login.to_string()
}
}
}

76
src/serve.rs Normal file
View file

@ -0,0 +1,76 @@
use std::convert::identity;
use actix_identity::Identity;
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::{http::header::ContentType, web, HttpRequest, HttpResponse, Responder};
use serde::{Serialize, Deserialize};
use tokio::fs;
use crate::errors::*;
use crate::pages;
use crate::AppCtx;
pub mod routes {
use serde::Serialize;
#[derive(Serialize)]
pub struct Serve {
pub catch_all: &'static str,
}
impl Serve {
pub const fn new() -> Self {
Self {
catch_all: "/archive",
}
}
}
}
#[derive(Serialize, Deserialize)]
struct Q {
url: url::Url,
}
#[actix_web_codegen_const_routes::get(path = "crate::pages::PAGES.serve.catch_all")]
#[tracing::instrument(name = "Serve webpages", skip(ctx, id, q))]
async fn serve_webpage(q: web::Query<(Q)>, ctx: AppCtx, id: Identity) -> ServiceResult<impl Responder> {
let url = q.into_inner().url;
let id = id.identity().unwrap();
if ctx.db.url_exists(url.as_str()).await? {
let path = crate::utils::get_website_path(&ctx.settings, &id, &url);
let content = fs::read(&path).await?;
let mime = if let Some(mime) = mime_guess::from_path(&path).first_raw() {
mime
} else {
"text/html; charset=utf-8"
};
Ok(HttpResponse::Ok()
.content_type(mime)
.body(content))
} else {
Err(ServiceError::WebsiteNotFound)
}
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(serve_webpage);
}

170
src/settings.rs Normal file
View file

@ -0,0 +1,170 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::env;
use std::path::Path;
use config::{Config, ConfigError, Environment, File};
use derive_more::Display;
#[cfg(not(test))]
use tracing::warn;
#[cfg(test)]
use std::println as warn;
use serde::Deserialize;
use serde::Serialize;
use url::Url;
use crate::errors::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Server {
pub port: u32,
pub ip: String,
pub workers: Option<usize>,
pub cookie_secret: String,
pub domain: String,
}
#[derive(Deserialize, Serialize, Display, Eq, PartialEq, Clone, Debug)]
pub struct Pages {
pub base_path: String,
}
impl Server {
#[cfg(not(tarpaulin_include))]
pub fn get_ip(&self) -> String {
format!("{}:{}", self.ip, self.port)
}
}
#[derive(Deserialize, Serialize, Display, Eq, PartialEq, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub enum DBType {
#[display(fmt = "sqlite")]
Sqlite,
// #[display(fmt = "maria")]
// Maria,
}
impl DBType {
fn from_url(url: &str) -> Result<Self, ConfigError> {
Ok(Self::Sqlite)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Database {
pub url: String,
pub pool: u32,
pub database_type: DBType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub allow_registration: bool,
pub support_email: String,
pub debug: bool,
pub server: Server,
pub source_code: String,
pub pages: Pages,
pub database: Database,
}
#[cfg(not(tarpaulin_include))]
impl Settings {
pub fn new() -> ServiceResult<Self> {
let mut s = Config::builder();
const CURRENT_DIR: &str = "./config/default.toml";
const ETC: &str = "/etc/pativu/config.toml";
let mut read_file = false;
if Path::new(ETC).exists() {
s = s.add_source(File::with_name(ETC));
read_file = true;
}
if Path::new(CURRENT_DIR).exists() {
// merging default config from file
s = s.add_source(File::with_name(CURRENT_DIR));
read_file = true;
}
if let Ok(path) = env::var("PATIVU_CONFIG") {
s = s.add_source(File::with_name(&path));
read_file = true;
}
if !read_file {
warn!("configuration file not found");
}
s = s.add_source(Environment::with_prefix("PATIVU").separator("__"));
match env::var("PORT") {
Ok(val) => {
s = s.set_override("server.port", val).unwrap();
}
Err(e) => warn!("couldn't interpret PORT: {}", e),
}
let intermediate_config = s.build_cloned().unwrap();
s = s
.set_override(
"database.url",
format!(
r"sqlite://{}:{}@{}:{}/{}",
intermediate_config
.get::<String>("database.username")
.expect("Couldn't access database username"),
intermediate_config
.get::<String>("database.password")
.expect("Couldn't access database password"),
intermediate_config
.get::<String>("database.hostname")
.expect("Couldn't access database hostname"),
intermediate_config
.get::<String>("database.port")
.expect("Couldn't access database port"),
intermediate_config
.get::<String>("database.name")
.expect("Couldn't access database name")
),
)
.expect("Couldn't set database url");
if let Ok(val) = env::var("DATABASE_URL") {
let database_type = DBType::from_url(&val).unwrap();
s = s.set_override("database.url", val).unwrap();
s = s
.set_override("database.database_type", database_type.to_string())
.unwrap();
}
let settings = s.build()?.try_deserialize::<Settings>()?;
settings.check_url();
Ok(settings)
}
#[cfg(not(tarpaulin_include))]
fn check_url(&self) {
Url::parse(&self.source_code).expect("Please enter a URL for source_code in settings");
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use cache_buster::Files;
pub struct FileMap {
pub files: Files,
}
impl FileMap {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
let map = include_str!("../cache_buster_data.json");
let files = Files::new(map);
Self { files }
}
pub fn get(&self, path: impl AsRef<str>) -> Option<&str> {
let file_path = self.files.get_full_path(path);
file_path.map(|file_path| &file_path[1..])
}
}
#[cfg(test)]
mod tests {
#[test]
fn filemap_works() {
let files = super::FileMap::new();
let css = files.get("./static/cache/css/main.css").unwrap();
println!("{}", css);
assert!(css.contains("/assets/css/main"));
}
}

56
src/static_assets/mod.rs Normal file
View file

@ -0,0 +1,56 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use actix_web::*;
pub mod filemap;
pub mod static_files;
pub use filemap::FileMap;
pub use routes::{Assets, ASSETS};
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(static_files::static_files);
}
pub mod routes {
use lazy_static::lazy_static;
use serde::*;
use super::*;
lazy_static! {
pub static ref ASSETS: Assets = Assets::new();
}
#[derive(Serialize)]
/// Top-level routes data structure for V1 AP1
pub struct Assets {
/// Authentication routes
pub css: &'static str,
pub mobile_css: &'static str,
}
impl Assets {
/// create new instance of Routes
pub fn new() -> Assets {
Assets {
css: &static_files::assets::CSS,
mobile_css: &static_files::assets::CSS,
}
}
}
}

View file

@ -0,0 +1,94 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::borrow::Cow;
use actix_web::body::BoxBody;
use actix_web::{get, http::header, web, HttpResponse, Responder};
use mime_guess::from_path;
use rust_embed::RustEmbed;
use crate::CACHE_AGE;
pub mod assets {
use crate::FILES;
use lazy_static::lazy_static;
lazy_static! {
pub static ref CSS: &'static str = FILES.get("./static/cache/css/main.css").unwrap();
pub static ref MOBILE_CSS: &'static str =
FILES.get("./static/cache/css/mobile.css").unwrap();
}
}
#[derive(RustEmbed)]
#[folder = "assets/"]
struct Asset;
fn handle_assets(path: &str) -> HttpResponse {
match Asset::get(path) {
Some(content) => {
let body: BoxBody = match content.data {
Cow::Borrowed(bytes) => BoxBody::new(bytes),
Cow::Owned(bytes) => BoxBody::new(bytes),
};
HttpResponse::Ok()
.insert_header(header::CacheControl(vec![
header::CacheDirective::Public,
header::CacheDirective::Extension("immutable".into(), None),
header::CacheDirective::MaxAge(CACHE_AGE),
]))
.content_type(from_path(path).first_or_octet_stream().as_ref())
.body(body)
}
None => HttpResponse::NotFound().body("404 Not Found"),
}
}
#[get("/assets/{_:.*}")]
pub async fn static_files(path: web::Path<String>) -> impl Responder {
handle_assets(&path)
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::test;
use crate::ctx::ArcCtx;
use crate::tests::*;
use crate::*;
use super::assets::CSS;
use super::assets::MOBILE_CSS;
#[actix_rt::test]
async fn postgrest_static_files_works() {
let (_, ctx) = get_ctx().await;
static_assets_work(ctx).await;
}
async fn static_assets_work(ctx: ArcCtx) {
let app = get_app!(ctx).await;
for file in [*CSS, *MOBILE_CSS].iter() {
println!("testing file {file}");
let resp = get_request!(&app, file);
assert_eq!(resp.status(), StatusCode::OK);
}
}
}

274
src/tests.rs Normal file
View file

@ -0,0 +1,274 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::sync::Arc;
use actix_web::{
body::{BoxBody, EitherBody},
dev::ServiceResponse,
error::ResponseError,
http::StatusCode,
};
use mktemp::Temp;
use serde::Serialize;
use crate::ctx::api::v1::auth::{Login, Register};
use crate::ctx::api::v1::pages::AddSite;
use crate::ctx::Ctx;
use crate::errors::*;
use crate::page::Page;
use crate::settings::Settings;
use crate::*;
pub async fn get_ctx() -> (Temp, Arc<Ctx>) {
// mktemp::Temp is returned because the temp directory created
// is removed once the variable goes out of scope
let settings = Settings::new().unwrap();
let tmp_dir = Temp::new_dir().unwrap();
println!("[log] Test temp directory: {}", tmp_dir.to_str().unwrap());
let page_base_path = tmp_dir.as_path().join("base_path");
println!("[log] Initialzing settings again with test config");
(tmp_dir, Ctx::new(settings).await)
}
#[allow(dead_code, clippy::upper_case_acronyms)]
pub struct FORM;
#[macro_export]
macro_rules! post_request {
($uri:expr) => {
actix_web::test::TestRequest::post().uri($uri)
};
($serializable:expr, $uri:expr) => {
actix_web::test::TestRequest::post()
.uri($uri)
.insert_header((actix_web::http::header::CONTENT_TYPE, "application/json"))
.set_payload(serde_json::to_string($serializable).unwrap())
};
($serializable:expr, $uri:expr, FORM) => {
actix_web::test::TestRequest::post()
.uri($uri)
.set_form($serializable)
};
}
#[macro_export]
macro_rules! get_request {
($app:expr,$route:expr ) => {
test::call_service(&$app, test::TestRequest::get().uri($route).to_request()).await
};
($app:expr, $route:expr, $cookies:expr) => {
test::call_service(
&$app,
test::TestRequest::get()
.uri($route)
.cookie($cookies)
.to_request(),
)
.await
};
}
#[macro_export]
macro_rules! delete_request {
($app:expr,$route:expr ) => {
test::call_service(&$app, test::TestRequest::delete().uri($route).to_request()).await
};
($app:expr, $route:expr, $cookies:expr) => {
test::call_service(
&$app,
test::TestRequest::delete()
.uri($route)
.cookie($cookies)
.to_request(),
)
.await
};
}
#[macro_export]
macro_rules! get_app {
($ctx:expr) => {
actix_web::test::init_service(
actix_web::App::new()
.app_data($crate::get_json_err())
.wrap($crate::get_identity_service(&$ctx.settings))
.wrap(actix_web::middleware::NormalizePath::new(
actix_web::middleware::TrailingSlash::Trim,
))
.configure($crate::services)
.app_data($crate::WebData::new($ctx.clone())),
)
};
}
/// Utility function to check for status of a test response, attempt response payload serialization
/// and print payload if response status doesn't match expected status
#[macro_export]
macro_rules! check_status {
($resp:expr, $expected:expr) => {
let status = $resp.status();
if status != $expected {
eprintln!(
"[error] Expected status code: {} received: {status}",
$expected
);
let response: serde_json::Value = actix_web::test::read_body_json($resp).await;
eprintln!("[error] Body:\n{:#?}", response);
assert_eq!(status, $expected);
panic!()
}
{
assert_eq!(status, $expected);
}
};
}
#[macro_export]
macro_rules! get_cookie {
($resp:expr) => {
$resp.response().cookies().next().unwrap().to_owned()
};
}
impl Ctx {
/// register and signin utility
pub async fn register_and_signin(
&self,
name: &str,
email: &str,
password: &str,
) -> (Login, ServiceResponse<EitherBody<BoxBody>>) {
self.register_test(name, email, password).await;
self.signin_test(name, password).await
}
pub fn to_arc(&self) -> Arc<Self> {
Arc::new(self.clone())
}
/// register utility
pub async fn register_test(&self, name: &str, email: &str, password: &str) {
let app = get_app!(self.to_arc()).await;
// 1. Register
let msg = Register {
username: name.into(),
password: password.into(),
confirm_password: password.into(),
email: email.into(),
};
println!("{:?}", msg);
let resp = actix_web::test::call_service(
&app,
post_request!(&msg, crate::V1_API_ROUTES.auth.register).to_request(),
)
.await;
if resp.status() != StatusCode::OK {
let resp_err: ErrorToResponse = actix_web::test::read_body_json(resp).await;
panic!("{}", resp_err.error);
}
}
/// signin util
pub async fn signin_test(
&self,
name: &str,
password: &str,
) -> (Login, ServiceResponse<EitherBody<BoxBody>>) {
let app = get_app!(self.to_arc()).await;
// 2. signin
let creds = Login {
login: name.into(),
password: password.into(),
};
let signin_resp = actix_web::test::call_service(
&app,
post_request!(&creds, V1_API_ROUTES.auth.login).to_request(),
)
.await;
assert_eq!(signin_resp.status(), StatusCode::OK);
(creds, signin_resp)
}
/// pub duplicate test
pub async fn bad_post_req_test<T: Serialize>(
&self,
name: &str,
password: &str,
url: &str,
payload: &T,
err: ServiceError,
) {
let (_, signin_resp) = self.signin_test(name, password).await;
let cookies = get_cookie!(signin_resp);
let app = get_app!(self.to_arc()).await;
let resp = actix_web::test::call_service(
&app,
post_request!(&payload, url)
.cookie(cookies.clone())
.to_request(),
)
.await;
assert_eq!(resp.status(), err.status_code());
let resp_err: ErrorToResponse = actix_web::test::read_body_json(resp).await;
//println!("{}", txt.error);
assert_eq!(resp_err.error, format!("{}", err));
}
/// bad post req test without payload
pub async fn bad_post_req_test_witout_payload(
&self,
name: &str,
password: &str,
url: &str,
err: ServiceError,
) {
let (_, signin_resp) = self.signin_test(name, password).await;
let app = get_app!(self.to_arc()).await;
let cookies = get_cookie!(signin_resp);
let resp = actix_web::test::call_service(
&app,
post_request!(url).cookie(cookies.clone()).to_request(),
)
.await;
assert_eq!(resp.status(), err.status_code());
let resp_err: ErrorToResponse = actix_web::test::read_body_json(resp).await;
//println!("{}", resp_err.error);
assert_eq!(resp_err.error, format!("{}", err));
}
pub async fn add_test_site(&self, owner: String) -> Page {
unimplemented!()
// let msg = AddSite {
// repo_url: REPO_URL.into(),
// branch: BRANCH.into(),
// owner,
// };
// self.add_site(msg).await.unwrap()
}
}

42
src/utils.rs Normal file
View file

@ -0,0 +1,42 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::Settings;
use std::path::{Path, PathBuf};
use url::Url;
/// Get random string of specific length
pub(crate) fn get_random(len: usize) -> String {
use rand::{distributions::Alphanumeric, rngs::ThreadRng, thread_rng, Rng};
use std::iter;
let mut rng: ThreadRng = thread_rng();
iter::repeat(())
.map(|()| rng.sample(Alphanumeric))
.map(char::from)
.take(len)
.collect::<String>()
}
pub(crate) fn get_website_path(s: &Settings, username: &str, url: &Url) -> PathBuf {
let path = url.as_str().replace('/', "-");
Path::new(&s.pages.base_path)
.join(username)
.join(path)
}

423
static/cache/css/main.css vendored Normal file
View file

@ -0,0 +1,423 @@
* {
padding: 0;
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
a {
text-decoration: none;
}
a:hover, button:hover {
cursor: pointer;
}
a,
a:visited {
color: rgb(0, 86, 179);
}
.base {
min-height: 100vh;
display: flex;
flex-direction: column;
width: 100%;
}
.main__content-container {
display: flex;
flex-direction: column;
min-height: 100%;
justify-content: space-between;
flex: 2;
}
p,
h1,
h2,
h3,
h4,
li,
ol,
ul {
color: #333;
}
main {
width: 100%;
}
blockquote {
border-left: 0.3em solid rgba(55, 55, 55, 0.4);
margin-bottom: 16px;
padding: 0 1em;
color: #707070;
}
blockquote p,
blockquote h1,
blockquote h2,
blockquote h3,
blockquote h4,
blockquote li,
blockquote ol,
blockquote ul {
color: inherit;
}
.auth__body {
display: flex;
height: 100vh;
min-height: 500px;
max-height: 800px;
flex-direction: column;
justify-content: space-between;
}
.index-banner__container {
width: 100%;
display: flex;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
flex-grow: 1;
}
.index-banner {
margin: auto;
display: flex;
justify-content: space-between;
}
.index-banner__logo-container {
margin: auto;
align-items: center;
display: flex;
flex-direction: column;
width: 500px;
}
.index-banner__title {
margin: auto;
font-style: none;
}
.index-banner__tagline {
margin: auto;
}
.index-banner__title-container {
display: flex;
}
.index-banner__logo {
width: 120px;
margin: auto;
border-radius: 20px;
}
.index-banner__main-action-btn {
display: block;
display: block;
font-weight: 400;
padding: 15px;
border: none;
margin: 20px 0;
background-color: green;
}
.index-banner__main-action-link {
color: white !important;
}
.index-banner__features-list {
margin: 20px;
}
.index-banner__features {
margin: 10px 0;
}
.home__features {
display: flex;
flex-direction: column;
align-items: center;
}
.home__features-title {
margin: auto;
}
.index__group-content .page__container {
width: 80%;
height: 100vh;
min-height: 500px;
max-height: 800px;
height: 90vh !important;
display: flex;
flex-direction: column;
justify-content: space-around;
}
.action-call__container {
background: #1f5818;
width: 100%;
padding: 60px 0;
}
.action-call__margin-container {
display: flex;
width: 80%;
margin: auto;
align-items: center;
justify-content: space-around;
}
.action-call__prompt {
color: white;
font-weight: 400;
font-size: 1.7rem;
}
.action-call__button {
display: block;
display: block;
font-weight: 400;
padding: 15px;
border: none;
margin: 20px 0;
background-color: #fff;
}
.action-call__button:hover {
background-color: lightgray;
}
.action-call_link {
color: #000 !important;
}
.action-call_link:hover {
text-decoration: none !important;
}
.auth-form {
display: flex;
flex-direction: column;
width: 80%;
margin: auto;
padding: 0 10px;
}
.auth-form__input {
display: block;
width: 100%;
margin: 10px 0;
padding: 5px 0;
}
.auth-form__submit {
width: 100%;
display: block;
margin: 10px 0;
background-color: green;
color: #fff;
border: none;
padding: 5px 0;
cursor: pointer;
}
.auth-form__submit:hover {
background-color: green;
}
footer {
display: block;
color: #333;
font-size: 0.7rem;
padding: 0;
margin: 0;
}
.footer__container {
width: 100%;
padding: 0;
justify-content: space-between;
margin: auto;
display: flex;
flex-direction: row;
overflow: hidden;
}
.footer__column {
list-style: none;
display: flex;
margin: auto 50px;
align-items: center;
flex: 2.5;
}
.footer__column--center {
list-style: none;
display: flex;
margin: auto 50px;
align-items: center;
flex: 2.5;
margin: auto;
flex-direction: column;
align-items: center;
flex: 2;
}
.footer__column:last-child {
justify-content: flex-end;
}
.footer__column:last-child a {
margin: 10px;
}
.footer__link-container {
margin: 5px;
}
.footer__link {
text-decoration: none;
}
.license__link {
display: inline;
}
.license__link:hover {
color: rgb(0, 86, 179);
text-decoration: underline;
}
.footer__column-divider,
.footer__column-divider--mobile-visible,
.footer__column-divider--mobile-only {
font-weight: 500;
opacity: 0.7;
margin: 0 5px;
}
.footer__column-divider--mobile-only {
display: none;
}
.footer__icon {
margin: auto 5px;
height: 20px;
}
header {
z-index: 5;
position: sticky;
top: 0;
background-color: #fff;
}
.nav__container {
display: flex;
flex-direction: row;
box-sizing: border-box;
width: 100%;
padding-top: 5px;
border-bottom: 1px solid rgb(211, 211, 211);
}
.nav__home-btn {
font-weight: bold;
margin: auto;
margin-left: 10px;
}
.nav__hamburger-menu {
display: none;
}
.nav__spacer--small {
width: 100px;
margin: auto;
}
.nav__spacer {
flex: 4;
margin: auto;
}
.nav__logo-container {
display: inline-flex;
text-decoration: none;
}
.nav__logo-container:hover {
color: rgb(0, 86, 179);
text-decoration: underline;
}
.nav__toggle {
display: none;
}
.nav__logo {
display: inline-flex;
margin: auto;
padding: 5px;
width: 40px;
}
.nav__link-group {
flex: 1.5;
list-style: none;
display: flex;
flex-direction: row;
align-items: center;
align-self: center;
margin: auto;
text-align: center;
}
.nav__link-group--small {
flex: 1.5;
list-style: none;
display: flex;
flex-direction: row;
align-items: center;
align-self: center;
margin: auto;
text-align: center;
flex: 0.5;
margin-right: 10px;
}
.nav__link-container {
display: flex;
padding: 10px;
height: 100%;
margin: auto;
}
.nav__link-container--action {
display: flex;
padding: 10px;
height: 100%;
margin: auto;
background-color: green;
padding: 15px;
}
.nav__link-container--action .nav__link {
color: white !important;
}
.nav__link {
text-decoration: none;
color: black !important;
font-weight: 600;
font-size: 14px;
}
.nav__link:hover {
color: rgb(0, 86, 179);
text-decoration: underline;
}
/*# sourceMappingURL=main.css.map */

1
static/cache/css/main.css.map vendored Normal file
View file

@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["../../../templates/defaults.scss","../../../templates/pages/auth/sass/main.scss","../../../templates/components/sass/_fullscreen.scss","../../../templates/pages/auth/sass/form/main.scss","../../../templates/components/sass/footer/main.scss","../../../templates/components/sass/_link.scss","../../../templates/components/nav/sass/main.scss"],"names":[],"mappings":"AAAA;EACC;EACA;EAGA;EACA;;;AAGD;EACC;;;AAGD;EACC;;;AAGD;AAAA;EAEC;;;AAGD;EACC;EACA;EACA;EACA;;;AAGD;EACC;EACA;EACA;EACA;EACA;;;AAGD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EAQC;;;AAGD;EACC;;;AAGD;EACC;EACA;EAEA;EACA;;AAEA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EAQC;;;ACjEF;EACC;ECFA;EACA;EACA;EDEA;EACA;;;AAID;EACC;EACA;EAIA;EACA;;;AAGD;EACC;EACA;EAEA;;;AASD;EACC;EACA;EACA;EACA;EACA;;;AAGD;EACC;EACA;;;AAID;EACC;;;AAKD;EACC;;;AAGD;EACC;EACA;EACA;;;AAGD;EACC;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGD;EACC;;;AAOD;EACC;;;AAGD;EACC;;;AAGD;EACC;EACA;EACA;;;AAGD;EACC;;;AAKA;EACC,OAHmB;EClGpB;EACA;EACA;EDqGC;EACA;EACA;EACA;;;AAIF;EACC;EACA;EACA;;;AAGD;EACC;EACA,OApBoB;EAqBpB;EACA;EACA;;;AAGD;EACC;EACA;EACA;;;AAGD;EACC;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGD;EACC;;;AAGD;EACC;;;AAGD;EACC;;;AEtJD;EACC;EACA;EACA;EACA;EACA;;;AAGD;EACC;EACA;EACA;EACA;;;AAGD;EACC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGD;EACC;;;ACzBD;EACC;EACA;EACA;EACA;EACA;;;AAGD;EACC;EACA;EACA;EACA;EACA;EACA;EACA;;;AAWD;EAPC;EACA;EACA;EACA;EACA;;;AAOD;EAXC;EACA;EACA;EACA;EACA;EASA;EACA;EACA;EACA;;;AAGD;EACC;;AACA;EACC;;;AAIF;EACC;;;AAGD;EACC;;;AAGD;EACC;;;AAGD;EC1DC;EACA;;;AD6DD;AAAA;AAAA;EAGC;EACA;EACA;;;AAGD;EACC;;;AAGD;EACC;EACA;;;AE3ED;EACC;EACA;EACA;EACA;;;AAGD;EACC;EACA;EAEA;EACA;EACA;EACA;;;AAGD;EACC;EAEA;EACA;;;AAGD;EACC;;;AAGD;EACC;EACA;;;AAGD;EACC;EACA;;;AAGD;EACC;EACA;;;AAGD;ED5CC;EACA;;;AC+CD;EACC;;;AAGD;EACC;EACA;EACA;EACA;;;AAcD;EAVC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAQD;EAfC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAUA;EACA;;;AAUD;EANC;EACA;EACA;EACA;;;AAOD;EAVC;EACA;EACA;EACA;EASA;EACA;;AACA;EACC;;;AAIF;EACC;EACA;EACA;EACA;;;AAGD;ED5GC;EACA","file":"main.css"}

209
static/cache/css/mobile.css vendored Normal file
View file

@ -0,0 +1,209 @@
footer {
font-size: 0.44rem;
}
.footer__container {
display: grid;
grid-template-rows: repeat(3, 100%);
align-items: center;
margin: auto;
justify-content: center;
}
.footer__link {
font-size: 0.5rem;
}
.license__conatiner,
.license__link {
text-align: center;
}
.footer__column:first-child {
grid-row-start: 3;
flex-direction: row;
}
.footer__column:last-child {
grid-row-start: 2;
}
.footer__column {
margin: 0 auto;
display: flex;
padding: 0;
align-self: flex-end;
}
.footer__column--center {
margin: 0 auto;
display: flex;
padding: 0;
align-self: flex-start;
}
.footer__column-divider--mobile-only {
margin: 0 3px;
font-size: 9.9px;
}
.home__container {
max-height: 100vh;
height: 100vh;
}
.home__name {
font-size: 2rem;
}
.index-banner {
margin: auto;
}
.index-banner__title {
font-size: 2.5rem;
margin: auto;
}
.index__group-content .page__container {
width: 90%;
}
.index-banner__logo-container {
display: none;
}
.action-call__margin-container {
flex-direction: column;
width: 85%;
}
.action-call__prompt {
text-align: center;
}
.nav__container {
flex-direction: column;
}
.nav__header {
display: flex;
flex-direction: row;
min-width: 100%;
justify-content: space-between;
}
.nav__link-group,
.nav__link-group--small {
position: sticky;
flex-direction: column;
margin: auto;
align-items: center;
width: 100%;
}
.nav__link-container--action {
background-color: #fff;
}
.nav__link-container--action .nav__link {
color: #000 !important;
}
.nav__link-container {
border-bottom: 1px dashed rgba(55, 55, 55, 0.4);
width: 70%;
}
.nav__link-container--action {
border-bottom: 1px dashed rgba(55, 55, 55, 0.4);
width: 70%;
}
.nav__link-container:last-child {
border-bottom: none;
}
.nav__link {
margin: auto;
}
.nav__hamburger-menu {
display: inline-block;
width: 50px;
height: 50px;
}
.nav__spacer {
display: none;
}
.nav__link-group {
margin-right: auto;
}
.nav__toggle:not(:checked) ~ .nav__link-group, .nav__link-group--small {
max-height: 0;
transition: max-height 0.4s ease-out;
overflow: hidden;
}
.nav__toggle:checked ~ .nav__link-group, .nav__toggle:checked ~ .nav__link-group--small {
max-height: 500px;
transition: max-height 0.4s ease-out;
}
.nav__toggle:checked ~ .nav__header .nav__hamburger-inner::after {
width: 24px;
bottom: 1.3px;
transform: rotate(-90deg);
transition: bottom 0.1s ease-out, transform 0.22s cubic-bezier(0.215, 0.61, 0.355, 1) 0.12s, width 0.1s ease-out;
}
.nav__toggle:checked ~ .nav__header .nav__hamburger-inner::before {
top: 0;
opacity: 0;
transition: top 0.1s ease-out, opacity 0.1s ease-out 0.12s;
}
.nav__toggle:checked ~ .nav__header .nav__hamburger-inner {
transform: rotate(225deg);
transition-delay: 0.12s;
transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}
.nav__hamburger-inner::after {
bottom: -7px;
transition: bottom 0.1s ease-in 0.25s, transform 0.22s cubic-bezier(0.55, 0.055, 0.675, 0.19), width 0.1s ease-in 0.25s;
}
.nav__hamburger-inner::after,
.nav__hamburger-inner::before {
content: "";
display: block;
}
.nav__hamburger-inner::before {
top: -7px;
transition: top 0.1s ease-in 0.25s, opacity 0.1s ease-in;
}
.nav__hamburger-inner {
top: 50%;
margin: auto;
transition-duration: 0.22s;
transition-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19);
}
.nav__hamburger-inner,
.nav__hamburger-inner::after,
.nav__hamburger-inner::before {
width: 24px;
height: 1.3px;
position: relative;
background: #000;
}
.nav__hamburger-menu,
.nav__hamburger-inner {
display: block;
}
/*# sourceMappingURL=mobile.css.map */

1
static/cache/css/mobile.css.map vendored Normal file
View file

@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["../../../templates/components/sass/footer/mobile.scss","../../../templates/pages/auth/sass/mobile.scss","../../../templates/components/nav/sass/mobile.scss"],"names":[],"mappings":"AAEA;EACC,WAHkB;;;AAMnB;EACC;EACA;EACA;EACA;EACA;;;AAGD;EACC;;;AAGD;AAAA;EAEC;;;AASD;EACC;EACA;;;AAGD;EACC;;;AAGD;EAdC;EACA;EACA;EAcA;;;AAGD;EAnBC;EACA;EACA;EAmBA;;;AAGD;EACC;EACA;;;AClDD;EACC;EACA;;;AAGD;EACC;;;AAGD;EACC;;;AAGD;EACC;EACA;;;AAIA;EACC;;;AAIF;EACC;;;AAGD;EACC;EACA;;;AAGD;EACC;;;AC7BD;EACC;;;AAGD;EACC;EACA;EACA;EACA;;;AAGD;AAAA;EAEC;EACA;EACA;EACA;EACA;;;AAID;EACC;;AACA;EACC;;;AASF;EAJC;EACA;;;AAOD;EARC;EACA;;;AAWD;EACC;;;AAGD;EACC;;;AAGD;EACC;EACA;EACA;;;AAGD;EACC;;;AAGD;EACC;;;AAGD;EACC;EACA;EACA;;;AAGD;EACC;EACA;;;AAIA;EACC;EACA,QA/E4B;EAgF5B;EACA;;AAKD;EACC;EACA;EACA;;AAGD;EACC;EACA;EACA;;;AAIF;EACC;EACA;;;AAKD;AAAA;EAEC;EACA;;;AAGD;EACC;EACA;;;AAGD;EACC;EACA;EACA;EACA;;;AAGD;AAAA;AAAA;EAGC;EACA,QAhI6B;EAiI7B;EAEA;;;AAGD;AAAA;EAEC","file":"mobile.css"}

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="{{ assets.css }}" />
<title>Pativu</title>
<title>{% block title %} {% endblock %}</title>
</head>
<body>
<header>{% block nav %} {% endblock %}</header>
{% block main %} {% endblock %}
{% include "footer" %}
</body>
</html>

View file

@ -0,0 +1,6 @@
{% if error %}
<div class="error_container">
<h3 class="error-title">ERROR: {{ error.title }}</h3>
<p class="error-message">{{ error.reason }}</p>
</div>
{% endif %}

View file

@ -0,0 +1,37 @@
<footer>
<div class="footer__container">
<div class="footer__column">
<span class="license__conatiner">
<a class="license__link" rel="noreferrer" href="/docs" target="_blank"
>Docs</a
>
</div>
<div class="footer__column">
<a
href="https://librepages.org"
class="footer__link"
target="_blank"
rel="noopener"
title="Project Homepage"
>
Home
</a>
<div class="footer__column-divider">|</div>
<a href="mailto:{{ footer.support_email }}" class="footer__link"
>Support</a
>
<div class="footer__column-divider">|</div>
<a
class="footer__link"
href="{{ footer.source_code }}"
target="_blank"
rel="noopener"
title="Source Code"
>
v{{ footer.version }}-{{ footer.git_hash }}
</a>
</div>
</div>
</footer>

View file

@ -0,0 +1,28 @@
<nav class="nav__container">
<input type="checkbox" class="nav__toggle" id="nav__toggle" />
<div class="nav__header">
<a class="nav__logo-container" href="/">
<p class="nav__home-btn">Pativu</p>
</a>
<label class="nav__hamburger-menu" for="nav__toggle">
<span class="nav__hamburger-inner"></span>
</label>
</div>
<div class="nav__spacer"></div>
<div class="nav__link-group">
<div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="{{ page.gist.new }}">New Paste</a>
</div>
{% if loggedin_user %}
<div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="{{ loggedin_user }}">Profile</a>
</div>
{% endif %}
<div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="{{ page.auth.logout }}">Log out</a>
</div>
</div>
</nav>

View file

@ -0,0 +1,18 @@
<nav class="nav__container">
<input type="checkbox" class="nav__toggle" id="nav__toggle" />
<div class="nav__header">
<a class="nav__logo-container" href="/">
<p class="nav__home-btn">Pativu</p>
</a>
<label class="nav__hamburger-menu" for="nav__toggle">
<span class="nav__hamburger-inner"></span>
</label>
</div>
<div class="nav__spacer"></div>
<div class="nav__link-group">
{% block nav_links %} {% endblock %}
</div>
</nav>

View file

@ -0,0 +1,23 @@
<nav class="nav__container">
<input type="checkbox" class="nav__toggle" id="nav__toggle" />
<div class="nav__header">
<a class="nav__logo-container" href="/">
<p class="nav__home-btn">Pativu</p>
</a>
<label class="nav__hamburger-menu" for="nav__toggle">
<span class="nav__hamburger-inner"></span>
</label>
</div>
<div class="nav__spacer"></div>
<div class="nav__link-group">
<div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="{{ page.auth.login }}">Login</a>
</div>
<div class="nav__link-container">
<a class="nav__link" rel="noreferrer" href="{{ page.auth.register }}">Register</a>
</div>
</div>
</nav>

View file

@ -0,0 +1,112 @@
@import "../../sass/_link";
header {
z-index: 5;
position: sticky;
top: 0;
background-color: #fff;
}
.nav__container {
display: flex;
flex-direction: row;
box-sizing: border-box;
width: 100%;
padding-top: 5px;
border-bottom: 1px solid rgb(211, 211, 211);
}
.nav__home-btn {
font-weight: bold;
// font-family: monospace, monospace;
margin: auto;
margin-left: 10px;
}
.nav__hamburger-menu {
display: none;
}
.nav__spacer--small {
width: 100px;
margin: auto;
}
.nav__spacer {
flex: 4;
margin: auto;
}
.nav__logo-container {
display: inline-flex;
text-decoration: none;
}
.nav__logo-container:hover {
@include a_hover;
}
.nav__toggle {
display: none;
}
.nav__logo {
display: inline-flex;
margin: auto;
padding: 5px;
width: 40px;
}
@mixin nav__link-group {
flex: 1.5;
list-style: none;
display: flex;
flex-direction: row;
align-items: center;
align-self: center;
margin: auto;
text-align: center;
}
.nav__link-group {
@include nav__link-group;
}
.nav__link-group--small {
@include nav__link-group;
flex: 0.5;
margin-right: 10px;
}
@mixin nav__link-container {
display: flex;
padding: 10px;
height: 100%;
margin: auto;
}
.nav__link-container {
@include nav__link-container;
}
.nav__link-container--action {
@include nav__link-container;
background-color: green;
padding: 15px;
.nav__link {
color: white !important;
}
}
.nav__link {
text-decoration: none;
color: black !important;
font-weight: 600;
font-size: 14px;
}
.nav__link:hover {
@include a_hover;
}

View file

@ -0,0 +1,141 @@
//@import '../_vars';
$hamburger-menu-animation: 0.4s ease-out;
$nav__hamburger-inner-height: 1.3px;
.nav__container {
flex-direction: column;
}
.nav__header {
display: flex;
flex-direction: row;
min-width: 100%;
justify-content: space-between;
}
.nav__link-group,
.nav__link-group--small {
position: sticky;
flex-direction: column;
margin: auto;
align-items: center;
width: 100%;
// background-color: $light-blue;
}
.nav__link-container--action {
background-color: #fff;
.nav__link {
color: #000 !important;
}
}
@mixin nav__link-container {
border-bottom: 1px dashed rgba(55, 55, 55, 0.4);
width: 70%;
}
.nav__link-container {
@include nav__link-container;
}
.nav__link-container--action {
@include nav__link-container;
}
.nav__link-container:last-child {
border-bottom: none;
}
.nav__link {
margin: auto;
}
.nav__hamburger-menu {
display: inline-block;
width: 50px;
height: 50px;
}
.nav__spacer {
display: none;
}
.nav__link-group {
margin-right: auto;
}
.nav__toggle:not(:checked) ~ .nav__link-group, .nav__link-group--small {
max-height: 0;
transition: max-height $hamburger-menu-animation;
overflow: hidden;
}
.nav__toggle:checked ~ .nav__link-group, .nav__toggle:checked ~ .nav__link-group--small {
max-height: 500px;
transition: max-height $hamburger-menu-animation;
}
.nav__toggle:checked ~ .nav__header {
.nav__hamburger-inner::after {
width: 24px;
bottom: $nav__hamburger-inner-height;
transform: rotate(-90deg);
transition: bottom 0.1s ease-out,
transform 0.22s cubic-bezier(0.215, 0.61, 0.355, 1) 0.12s,
width 0.1s ease-out;
}
.nav__hamburger-inner::before {
top: 0;
opacity: 0;
transition: top 0.1s ease-out, opacity 0.1s ease-out 0.12s;
}
.nav__hamburger-inner {
transform: rotate(225deg);
transition-delay: 0.12s;
transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}
}
.nav__hamburger-inner::after {
bottom: -7px;
transition: bottom 0.1s ease-in 0.25s,
transform 0.22s cubic-bezier(0.55, 0.055, 0.675, 0.19),
width 0.1s ease-in 0.25s;
}
.nav__hamburger-inner::after,
.nav__hamburger-inner::before {
content: "";
display: block;
}
.nav__hamburger-inner::before {
top: -7px;
transition: top 0.1s ease-in 0.25s, opacity 0.1s ease-in;
}
.nav__hamburger-inner {
top: 50%;
margin: auto;
transition-duration: 0.22s;
transition-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19);
}
.nav__hamburger-inner,
.nav__hamburger-inner::after,
.nav__hamburger-inner::before {
width: 24px;
height: $nav__hamburger-inner-height;
position: relative;
// background: $dark-black;
background: #000;
}
.nav__hamburger-menu,
.nav__hamburger-inner {
display: block;
}

View file

@ -0,0 +1,5 @@
@mixin fullscreen {
height: 100vh;
min-height: 500px;
max-height: 800px;
}

View file

@ -0,0 +1,4 @@
@mixin a_hover {
color: rgb(0, 86, 179);
text-decoration: underline;
}

View file

@ -0,0 +1,79 @@
@import "../_link";
footer {
display: block;
color: #333;
font-size: 0.7rem;
padding: 0;
margin: 0;
}
.footer__container {
width: 100%;
padding: 0;
justify-content: space-between;
margin: auto;
display: flex;
flex-direction: row;
overflow: hidden;
}
@mixin footer__column-base {
list-style: none;
display: flex;
margin: auto 50px;
align-items: center;
flex: 2.5;
}
.footer__column {
@include footer__column-base;
}
.footer__column--center {
@include footer__column-base;
margin: auto;
flex-direction: column;
align-items: center;
flex: 2;
}
.footer__column:last-child {
justify-content: flex-end;
a {
margin: 10px;
}
}
.footer__link-container {
margin: 5px;
}
.footer__link {
text-decoration: none;
}
.license__link {
display: inline;
}
.license__link:hover {
@include a_hover;
}
.footer__column-divider,
.footer__column-divider--mobile-visible,
.footer__column-divider--mobile-only {
font-weight: 500;
opacity: 0.7;
margin: 0 5px;
}
.footer__column-divider--mobile-only {
display: none;
}
.footer__icon {
margin: auto 5px;
height: 20px;
}

View file

@ -0,0 +1,52 @@
$footer-font-size: 0.44rem;
footer {
font-size: $footer-font-size;
}
.footer__container {
display: grid;
grid-template-rows: repeat(3, 100%);
align-items: center;
margin: auto;
justify-content: center;
}
.footer__link {
font-size: 0.5rem;
}
.license__conatiner,
.license__link {
text-align: center;
}
@mixin footer__column-base {
margin: 0 auto;
display: flex;
padding: 0;
}
.footer__column:first-child {
grid-row-start: 3;
flex-direction: row;
}
.footer__column:last-child {
grid-row-start: 2;
}
.footer__column {
@include footer__column-base;
align-self: flex-end;
}
.footer__column--center {
@include footer__column-base;
align-self: flex-start;
}
.footer__column-divider--mobile-only {
margin: 0 3px;
font-size: 9.9px;
}

70
templates/defaults.scss Normal file
View file

@ -0,0 +1,70 @@
* {
padding: 0;
margin: 0;
//font-family: "Inter UI", -apple-system, BlinkMacSystemFont, "Roboto",
// "Segoe UI", Helvetica, Arial, sans-serif;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
a {
text-decoration: none;
}
a:hover, button:hover {
cursor: pointer;
}
a,
a:visited {
color: rgb(0, 86, 179);
}
.base {
min-height: 100vh;
display: flex;
flex-direction: column;
width: 100%;
}
.main__content-container {
display: flex;
flex-direction: column;
min-height: 100%;
justify-content: space-between;
flex: 2;
}
p,
h1,
h2,
h3,
h4,
li,
ol,
ul {
color: #333;
}
main {
width: 100%;
}
blockquote {
border-left: 0.3em solid rgba(55, 55, 55, 0.4);
margin-bottom: 16px;
//padding-left: 20px;
padding: 0 1em;
color: #707070;
p,
h1,
h2,
h3,
h4,
li,
ol,
ul {
color: inherit;
}
}

5
templates/main.scss Normal file
View file

@ -0,0 +1,5 @@
@import "defaults.scss";
@import "pages/auth/sass/main.scss";
@import "pages/auth/sass/form/main.scss";
@import "components/sass/footer/main.scss";
@import "components/nav/sass/main.scss";

3
templates/mobile.scss Normal file
View file

@ -0,0 +1,3 @@
@import "components/sass/footer/mobile.scss";
@import "pages/auth/sass/mobile.scss";
@import "components/nav/sass/mobile.scss";

View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="{{ assets.css }}" />
<title>Pativu</title>
</head>
<body class="auth__body">
<header>{% include "pub_nav" %}</header>
<main class="index-banner__container">
<section class="index-banner">
<div class="index-banner__content-container">
<h1 class="index-banner__title">Self-hosted internet archive</h1>
<p class="index-banner__tagline">
Personal internet archiving platform with focus on speed
</p>
<ul class="index-banner__features-list">
<li class="index-banner__features">
<b>Sanitize webpages</b> improving reader focus
</li>
<li class="index-banner__features">
<b>Small, single-binary</b> making self-hosting easy
</li>
<li class="index-banner__features">
<b>Crawl linked static assets</b> to completely archive a webpage
</li>
<li class="index-banner__features">
<b>
100%
<a href="https://www.gnu.org/philosophy/free-sw.html"
>Free Software</a
> </b
>: deploy your own instance
</li>
</ul>
</div>
</section>
<section class="index-banner__logo-container">
{% block login %} {% endblock %}
</section>
</main>
{% include "footer" %}
</body>
</html>

View file

@ -0,0 +1,44 @@
{% extends 'authbase' %}
{% block login %}
<h2>Sign In</h2>
<form action="{{ page.auth.login }}" method="POST" class="auth-form" accept-charset="utf-8">
{% include "error_comp" %}
<label class="auth-form__label" for="login">
Username or Email
<input
class="auth-form__input"
name="login"
autofocus
required
id="login"
type="text"
{% if payload.username %}
value={{ payload.username }}
{% endif %}
/>
</label>
<label class="auth-form__label" for="password">
Password
<input
class="auth-form__input"
name="password"
required
id="password"
type="password"
{% if payload.password %}
value={{ payload.password }}
{% endif %}
/>
</label>
<div class="auth-form__action-container">
<a href="">Forgot password?</a>
<button class="auth-form__submit" type="submit">Login</button>
</div>
</form>
<p class="auth-form__alt-action">
New to Pativu?
<a href="{{ page.auth.register }}">Create an account</a>
</p>
{% endblock %}

View file

@ -0,0 +1,73 @@
{% extends 'authbase' %}
{% block title_name %}Sign Up {% endblock %}
{% block login %}
<h2>Sign Up</h2>
<form action="{{ page.auth.register }}" method="POST" class="auth-form" accept-charset="utf-8">
{% include "error_comp" %}
<label class="auth-form__label" for="username">
Username
<input
class="auth-form__input"
autofocus
name="username"
required
id="username"
type="text"
{% if payload.username %}
value={{ payload.username }}
{% endif %}
/>
</label>
<label class="auth-form__label" for="email">
Email
<input
class="auth-form__input"
name="email"
id="email"
type="email"
{% if payload.email %}
value={{ payload.email }}
{% endif %}
/>
</label>
<label class="auth-form__label" for="password">
password
<input
class="auth-form__input"
name="password"
required
id="password"
type="password"
{% if payload.password %}
value={{ payload.password }}
{% endif %}
/>
</label>
<label class="auth-form__label" for="confirm_password">
Re-enter Password
<input
class="auth-form__input"
name="confirm_password"
required
id="confirm_password"
type="password"
{% if payload.confirm_password %}
value={{ payload.confirm_password }}
{% endif %}
/>
</label>
<div class="auth-form__action-container">
<a href="/forgot-password">Forgot password?</a>
<button class="auth-form__submit" type="submit">Sign Up</button>
</div>
</form>
<p class="auth-form__alt-action">
Already have an account?
<a href="{{ page.auth.login }}"> Login </a>
</p>
{% endblock %}

View file

@ -0,0 +1,29 @@
.auth-form {
display: flex;
flex-direction: column;
width: 80%;
margin: auto;
padding: 0 10px;
}
.auth-form__input {
display: block;
width: 100%;
margin: 10px 0;
padding: 5px 0;
}
.auth-form__submit {
width: 100%;
display: block;
margin: 10px 0;
background-color: green;
color: #fff;
border: none;
padding: 5px 0;
cursor: pointer;
}
.auth-form__submit:hover {
background-color: green;
}

View file

@ -0,0 +1,152 @@
@import "../../../components/sass/fullscreen";
.auth__body {
display: flex;
@include fullscreen;
flex-direction: column;
justify-content: space-between;
}
$heading-letter-spacing: 20px;
.index-banner__container {
width: 100%;
display: flex;
//background-color: #d1875a;
// background-color: #3c3c3c;
// background-color: #58181f;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
flex-grow: 1;
}
.index-banner {
margin: auto;
display: flex;
// flex-direction: column;
justify-content: space-between;
}
.index-banner__content-container {
// height: 300px;
li {
// color: white;
}
}
.index-banner__logo-container {
margin: auto;
align-items: center;
display: flex;
flex-direction: column;
width: 500px;
}
.index-banner__title {
margin: auto;
font-style: none;
//color: #fff;
}
.index-banner__tagline {
margin: auto;
// color: #fff;
// font-size: 1.4rem;
}
.index-banner__title-container {
display: flex;
}
.index-banner__logo {
width: 120px;
margin: auto;
border-radius: 20px;
}
.index-banner__main-action-btn {
display: block;
display: block;
font-weight: 400;
padding: 15px;
border: none;
margin: 20px 0;
background-color: green;
}
.index-banner__main-action-link {
color: white !important;
}
.index-banner__main-action-btn:hover {
// background-color: lightgray;
}
.index-banner__features-list {
margin: 20px;
}
.index-banner__features {
margin: 10px 0;
}
.home__features {
display: flex;
flex-direction: column;
align-items: center;
}
.home__features-title {
margin: auto;
}
$page-content-width: 80%;
.index__group-content {
.page__container {
width: $page-content-width;
@include fullscreen;
height: 90vh !important;
display: flex;
flex-direction: column;
justify-content: space-around;
}
}
.action-call__container {
background: #1f5818;
width: 100%;
padding: 60px 0;
}
.action-call__margin-container {
display: flex;
width: $page-content-width;
margin: auto;
align-items: center;
justify-content: space-around;
}
.action-call__prompt {
color: white;
font-weight: 400;
font-size: 1.7rem;
}
.action-call__button {
display: block;
display: block;
font-weight: 400;
padding: 15px;
border: none;
margin: 20px 0;
background-color: #fff;
}
.action-call__button:hover {
background-color: lightgray;
}
.action-call_link {
color: #000 !important;
}
.action-call_link:hover {
text-decoration: none !important;
}

View file

@ -0,0 +1,36 @@
.home__container {
max-height: 100vh;
height: 100vh;
}
.home__name {
font-size: 2rem;
}
.index-banner {
margin: auto;
}
.index-banner__title {
font-size: 2.5rem;
margin: auto;
}
.index__group-content {
.page__container {
width: 90%;
}
}
.index-banner__logo-container {
display: none;
}
.action-call__margin-container {
flex-direction: column;
width: 85%;
}
.action-call__prompt {
text-align: center;
}

View file

@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="{{ assets.css }}" />
<title>Pativu</title>
</head>
<body class="auth__body">
<header>
<nav>
<p>Pativu</p>
<span class="nav__spacer"></span>
<ul class="nav__links">
<li class="nav__item">Help</li>
<li class="nav__item">Settings</li>
<li class="nav__item">Logout</li>
</ul>
</nav>
</header>
<main>
<div class="sites__collection">
<div class="sites__actions">
<a class="sites__actions__new-site" href="{{ page.dash.add_site }}">
<button>Add new site</button>
</a>
</div>
{% for site in payload %}
<a href="{{ page.serve.catch_all }}?url={{ site.url }}" class="site__container">
<div class="site__info--head">
<div class="site__info--column">
<p><b></b>{{ site.url }}</b></p>
</div>
</div>
</a>
{% endfor %}
</div>
</main>
{% include "footer" %}
</body>
<style>
header {
width: 100%;
}
nav {
width: 100%;
margin: auto;
display: flex;
}
.nav__spacer {
flex: 4;
}
.nav__links {
display: flex;
list-style: none;
}
.nav__item {
margin: 0 20px;
}
body {
display: flex;
flex-direction: column;
align-items: center;
}
main {
width: 100%;
margin: 20px;
}
.sites__collection {
margin: auto;
width: 70%;
border: 1px solid #e8ebed;
border-radius: 8px;
}
.sites__actions {
width: 100%;
height: 50px;
display: flex;
flex-direction: row-reverse;
align-items: center;
box-sizing: border-box;
padding: 0px 20px;
margin: 10px 0;
}
.sites__actions__new-site {
min-height: 36px;
background: green;
display: flex;
align-items: center;
padding: 0px 8px;
}
.sites__actions__new-site > button {
margin: 0;
padding: 0;
height: 100%;
border: none;
width: 100%;
color: white;
background: none;
}
.site__container {
box-sizing: border-box;
margin: 10px 0;
width: 100%;
display: flex;
justify-content: space-between;
padding: 10px 20px;
align-items: center;
}
.site__container:hover {
background: #f7f8f8;
}
.site__info--head {
display: flex;
align-items: center;
}
.site__info--column {
margin-left: 20px;
}
.site__info--column > p,
.site__info--column > a {
margin: 0;
padding: 0;
}
.site__container:visited,
.site__container {
color: black;
text-decoration: none;
}
.site__container--preview {
width: 50px;
height: 50px;
border-radius: 50%;
}
</style>
</html>

View file

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="{{ assets.css }}" />
<title>Pativu</title>
</head>
<body class="auth__body">
<header>
<nav>
<p>Pativu</p>
<span class="nav__spacer"></span>
<ul class="nav__links">
<li class="nav__item">Help</li>
<li class="nav__item">Settings</li>
<li class="nav__item"><a href="{{ page.auth.logout }}">Logout</a></li>
</ul>
</nav>
</header>
<main>
</main>
<form action="{{ page.dash.add_site }}" method="post">
<label for="url">
<input type="url" name="url" id="url">
</label>
<button type="submit">Archive</button>
</form>
{% include "footer" %}
</body>
<style>
header {
width: 100%;
}
nav {
width: 100%;
margin: auto;
display: flex;
}
.nav__spacer {
flex: 4;
}
.nav__links {
display: flex;
list-style: none;
}
.nav__item {
margin: 0 20px;
}
body {
display: flex;
flex-direction: column;
align-items: center;
}
main {
width: 100%;
margin: 20px;
}
.sites__collection {
margin: auto;
width: 70%;
border: 1px solid #e8ebed;
border-radius: 8px;
}
.sites__actions {
width: 100%;
height: 50px;
display: flex;
flex-direction: row-reverse;
align-items: center;
box-sizing: border-box;
padding: 0px 20px;
margin: 10px 0;
}
.sites__actions__new-site {
min-height: 36px;
background: green;
display: flex;
align-items: center;
padding: 0px 8px;
}
.sites__actions__new-site > button {
margin: 0;
padding: 0;
height: 100%;
border: none;
width: 100%;
color: white;
background: none;
}
.site__container {
box-sizing: border-box;
margin: 10px 0;
width: 100%;
display: flex;
justify-content: space-between;
padding: 10px 20px;
align-items: center;
}
.site__container:hover {
background: #f7f8f8;
}
.site__info--head {
display: flex;
align-items: center;
}
.site__info--column {
margin-left: 20px;
}
.site__info--column > p,
.site__info--column > a {
margin: 0;
padding: 0;
}
.site__container:visited,
.site__container {
color: black;
text-decoration: none;
}
.site__container--preview {
width: 50px;
height: 50px;
border-radius: 50%;
}
</style>
</html>

2
utils/cache-bust/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
src/cache_buster_data.json

354
utils/cache-bust/Cargo.lock generated Normal file
View file

@ -0,0 +1,354 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "block-buffer"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324"
dependencies = [
"generic-array",
]
[[package]]
name = "cache-buster"
version = "0.2.0"
source = "git+https://github.com/realaravinth/cache-buster#7ca4545722fb99be30698a5e72c7d982a70fa11f"
dependencies = [
"data-encoding",
"derive_builder",
"mime",
"mime_guess",
"serde",
"serde_json",
"sha2",
"walkdir",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cpufeatures"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "darling"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4529658bdda7fd6769b8614be250cdcfc3aeb0ee72fe66f9e41e5e5eb73eac02"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "649c91bc01e8b1eac09fb91e8dbc7d517684ca6be8ebc75bb9cafc894f9fdb6f"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddfc69c5bfcbd2fc09a0f38451d2daf0e372e367986a83906d1b0dbc88134fb5"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "data-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ee2393c4a91429dffb4bedf19f4d6abf27d8a732c8ce4980305d782e5426d57"
[[package]]
name = "derive_builder"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d07adf7be193b71cc36b193d0f5fe60b918a3a9db4dad0449f57bcfd519704a3"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f91d4cfa921f1c05904dc3c57b4a32c38aed3340cce209f3a6fd1478babafc4"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "derive_builder_macro"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f0314b72bed045f3a68671b3c86328386762c93f82d98c65c3cb5e5f573dd68"
dependencies = [
"derive_builder_core",
"syn",
]
[[package]]
name = "digest"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "generic-array"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "itoa"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35"
[[package]]
name = "libc"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5916d2ae698f6de9bfb891ad7a8d65c09d232dc58cc4ac433c7da3b2fd84bc2b"
[[package]]
name = "mime"
version = "0.3.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d"
[[package]]
name = "mime_guess"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "pativu-cachebust-util"
version = "0.1.0"
dependencies = [
"cache-buster",
"serde",
"serde_json",
]
[[package]]
name = "proc-macro2"
version = "1.0.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9027b48e9d4c9175fa2218adf3557f91c1137021739951d4932f5f8268ac48aa"
dependencies = [
"unicode-xid",
]
[[package]]
name = "quote"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ryu"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.137"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.137"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sha2"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "syn"
version = "1.0.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a07e33e919ebcd69113d5be0e4d70c5707004ff45188910106854f38b960df4a"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]]
name = "typenum"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
[[package]]
name = "unicase"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6"
dependencies = [
"version_check",
]
[[package]]
name = "unicode-xid"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04"
[[package]]
name = "version_check"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "walkdir"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56"
dependencies = [
"same-file",
"winapi",
"winapi-util",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
dependencies = [
"winapi",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

View file

@ -0,0 +1,19 @@
[package]
name = "pativu-cachebust-util"
version = "0.1.0"
edition = "2021"
homepage = "https://git.batsense.net/realaravinth/pativu"
repository = "https://git.batsense.net/realaravinth/pativu"
documentation = "https://git.batsense.net/realaravinth/pativu"
readme = "https://git.batsense.net/realaravinth/pativu/blob/master/README.md"
license = "AGPLv3 or later version"
authors = ["realaravinth <realaravinth@batsense.net>"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
cache-buster = { version = "0.2.0", git = "https://github.com/realaravinth/cache-buster" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -0,0 +1,78 @@
/*
* Copyright (C) 2021 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::fs;
use std::path::Path;
use std::collections::HashMap;
use cache_buster::{BusterBuilder, CACHE_BUSTER_DATA_FILE, NoHashCategory};
use serde::{Serialize, Deserialize};
#[derive(Deserialize, Serialize)]
struct FileMap {
map: HashMap<String, String>,
base_dir: String,
}
fn main() {
cache_bust();
process_file_map();
}
fn cache_bust() {
// until APPLICATION_WASM gets added to mime crate
// PR: https://github.com/hyperium/mime/pull/138
// let types = vec![
// mime::IMAGE_PNG,
// mime::IMAGE_SVG,
// mime::IMAGE_JPEG,
// mime::IMAGE_GIF,
// mime::APPLICATION_JAVASCRIPT,
// mime::TEXT_CSS,
// ];
println!("[*] Cache busting");
let no_hash = vec![NoHashCategory::FileExtentions(vec!["wasm"])];
let config = BusterBuilder::default()
.source("../../static/cache/")
.result("./../../assets")
.no_hash(no_hash)
.follow_links(true)
.build()
.unwrap();
config.process().unwrap();
}
fn process_file_map() {
let contents = fs::read_to_string(CACHE_BUSTER_DATA_FILE).unwrap();
let files: FileMap = serde_json::from_str(&contents).unwrap();
let mut map = HashMap::with_capacity(files.map.len());
for (k, v) in files.map.iter() {
map.insert(k.strip_prefix("../.").unwrap().to_owned(),
v.strip_prefix("./../.").unwrap().to_owned()
);
}
let new_filemap = FileMap{
map,
base_dir: files.base_dir.strip_prefix("./../.").unwrap().to_owned(),
};
let dest = Path::new("../../").join(CACHE_BUSTER_DATA_FILE);
fs::write(&dest, serde_json::to_string(&new_filemap).unwrap()).unwrap();
}