features, cleanup and bug fixes #26

Merged
ben merged 30 commits from ben/Jobboerse:main into main 2022-06-09 17:36:55 +02:00
47 changed files with 2833 additions and 1151 deletions

164
Cargo.lock generated
View file

@ -401,6 +401,15 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
[[package]]
name = "better_toml_datetime"
version = "0.1.0"
dependencies = [
"serde",
"thiserror",
"toml",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@ -470,9 +479,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.9.1"
version = "3.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899"
checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3"
[[package]]
name = "byte-tools"
@ -503,9 +512,9 @@ dependencies = [
[[package]]
name = "camino"
version = "1.0.8"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07fd178c5af4d59e83498ef15cf3f154e1a6f9d091270cb86283c65ef44e9ef0"
checksum = "869119e97797867fd90f5e22af7d0bd274bd4635ebb9eb68c04f3f513ae6c412"
dependencies = [
"serde",
]
@ -625,9 +634,9 @@ dependencies = [
[[package]]
name = "clap"
version = "3.1.17"
version = "3.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47582c09be7c8b32c0ab3a6181825ababb713fde6fff20fc573a3870dd45c6a0"
checksum = "d2dbdf4bdacb33466e854ce889eee8dfd5729abf7ccd7664d0a2d60cd384440b"
dependencies = [
"atty",
"bitflags",
@ -642,9 +651,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "3.1.7"
version = "3.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3aab4734e083b809aaf5794e14e756d1c798d2c69c7f7de7a09a2f5214993c1"
checksum = "25320346e922cffe59c0bbc5410c8d8784509efb321488971081313cb1e1a33c"
dependencies = [
"heck 0.4.0",
"proc-macro-error",
@ -863,19 +872,17 @@ dependencies = [
[[package]]
name = "firestorm"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d6188b8804df28032815ea256b6955c9625c24da7525f387a7af02fbb8f01"
checksum = "2c5f6c2c942da57e2aaaa84b8a521489486f14e75e7fa91dab70aba913975f98"
[[package]]
name = "flate2"
version = "1.0.23"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39522e96686d38f4bc984b9198e3a0613264abaebaff2c5c918bfa6b6da09af"
checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6"
dependencies = [
"cfg-if",
"crc32fast",
"libc",
"miniz_oxide",
]
@ -1058,16 +1065,16 @@ dependencies = [
[[package]]
name = "handlebars"
version = "4.2.2"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d6a30320f094710245150395bc763ad23128d6a1ebbad7594dc4164b62c56b"
checksum = "d113a9853e5accd30f43003560b5563ffbb007e3f325e8b103fa0d0029c6e6df"
dependencies = [
"log",
"pest",
"pest_derive",
"quick-error 2.0.1",
"serde",
"serde_json",
"thiserror",
"walkdir",
]
@ -1154,7 +1161,7 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f"
dependencies = [
"quick-error 1.2.3",
"quick-error",
]
[[package]]
@ -1170,9 +1177,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "1.8.1"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee"
checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a"
dependencies = [
"autocfg",
"hashbrown",
@ -1198,18 +1205,19 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.1"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35"
checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d"
[[package]]
name = "jobboerse"
version = "0.1.6"
version = "0.2.0"
dependencies = [
"actix-files",
"actix-multipart",
"actix-session",
"actix-web",
"better_toml_datetime",
"cargo-bundle-licenses",
"chrono",
"chrono-tz",
@ -1222,6 +1230,7 @@ dependencies = [
"listenfd",
"log",
"mime_guess",
"multipart_helper",
"pretty_env_logger",
"rand",
"serde",
@ -1276,9 +1285,9 @@ dependencies = [
[[package]]
name = "ldap3"
version = "0.10.4"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52110b91cbe3a92ba70d4bd64366bfe5c8b8698516155db7041ae3dd155a4fc3"
checksum = "ef35dc747152dd47bdc6aaeb35a232f84cbc8d84ae4cb9673aea810a6570ab8f"
dependencies = [
"async-trait",
"bytes",
@ -1325,9 +1334,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5916d2ae698f6de9bfb891ad7a8d65c09d232dc58cc4ac433c7da3b2fd84bc2b"
checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836"
[[package]]
name = "libgit2-sys"
@ -1343,9 +1352,9 @@ dependencies = [
[[package]]
name = "libz-sys"
version = "1.1.6"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e7e15d7610cce1d9752e137625f14e61a28cd45929b6e12e47b50fe154ee2e"
checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf"
dependencies = [
"cc",
"libc",
@ -1361,9 +1370,9 @@ checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
[[package]]
name = "listenfd"
version = "0.5.0"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c02b14f35d9f5f082fd0b1b34aa0ef32e3354c859c721d7f3325b3f79a42ba54"
checksum = "14e4fcc00ff6731d94b70e16e71f43bda62883461f31230742e3bc6dddf12988"
dependencies = [
"libc",
"uuid",
@ -1449,9 +1458,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.5.1"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082"
checksum = "6f5c75688da582b8ffc1f1799e9db273f32133c49e048f614d22ec3256773ccc"
dependencies = [
"adler",
]
@ -1468,6 +1477,17 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "multipart_helper"
version = "0.1.0"
dependencies = [
"actix-multipart",
"futures-util",
"tempfile",
"thiserror",
"toml",
]
[[package]]
name = "nom"
version = "2.2.1"
@ -1544,9 +1564,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.10.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9"
checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225"
[[package]]
name = "opaque-debug"
@ -1568,15 +1588,15 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "os_str_bytes"
version = "6.0.0"
version = "6.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64"
checksum = "21326818e99cfe6ce1e524c2a805c189a99b5ae555a35d19f9a284b427d86afa"
[[package]]
name = "parking_lot"
version = "0.12.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58"
checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
dependencies = [
"lock_api",
"parking_lot_core",
@ -1770,11 +1790,11 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.38"
version = "1.0.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9027b48e9d4c9175fa2218adf3557f91c1137021739951d4932f5f8268ac48aa"
checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f"
dependencies = [
"unicode-xid",
"unicode-ident",
]
[[package]]
@ -1783,12 +1803,6 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.18"
@ -1845,9 +1859,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.5.5"
version = "1.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286"
checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1"
dependencies = [
"aho-corasick",
"memchr",
@ -1856,9 +1870,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.6.25"
version = "0.6.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64"
[[package]]
name = "remove_dir_all"
@ -1904,9 +1918,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.20.4"
version = "0.20.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fbfeb8d0ddb84706bc597a5574ab8912817c52a397f819e5b614e2265206921"
checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033"
dependencies = [
"log",
"ring",
@ -1937,9 +1951,9 @@ dependencies = [
[[package]]
name = "ryu"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f"
checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695"
[[package]]
name = "same-file"
@ -1952,12 +1966,12 @@ dependencies = [
[[package]]
name = "schannel"
version = "0.1.19"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f05ba609c234e60bee0d547fe94a4c7e9da733d1c962cf6e59efa4cd9c8bc75"
checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2"
dependencies = [
"lazy_static",
"winapi",
"windows-sys",
]
[[package]]
@ -2193,13 +2207,13 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
[[package]]
name = "syn"
version = "1.0.93"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04066589568b72ec65f42d65a1a52436e954b168773148893c020269563decf2"
checksum = "fbaf6116ab8924f39d52792136fb74fd60a80194cf1b1c6ffa6453eef1c3f942"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
"unicode-ident",
]
[[package]]
@ -2350,9 +2364,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.1"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0edfdeb067411dba2044da6d1cb2df793dd35add7888d73c16e3381ded401764"
checksum = "f988a1a1adc2fb21f9c12aa96441da33a1728193ae0b95d2be22dbd17fcb4e5c"
dependencies = [
"bytes",
"futures-core",
@ -2428,9 +2442,9 @@ checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c"
[[package]]
name = "uncased"
version = "0.9.6"
version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baeed7327e25054889b9bd4f975f32e5f4c5d434042d59ab6cd4142c0a76ed0"
checksum = "09b01702b0fd0b3fadcf98e098780badda8742d4f4a7676615cad90e8ac73622"
dependencies = [
"version_check",
]
@ -2456,6 +2470,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992"
[[package]]
name = "unicode-ident"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee"
[[package]]
name = "unicode-normalization"
version = "0.1.19"
@ -2507,9 +2527,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "0.8.2"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
checksum = "c6d5d669b51467dcf7b2f1a796ce0f955f05f01cafda6c19d6e95f730df29238"
[[package]]
name = "vcpkg"
@ -2723,18 +2743,18 @@ dependencies = [
[[package]]
name = "zstd"
version = "0.10.0+zstd.1.5.2"
version = "0.10.2+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b1365becbe415f3f0fcd024e2f7b45bacfb5bdd055f0dc113571394114e7bdd"
checksum = "5f4a6bd64f22b5e3e94b4e238669ff9f10815c27a5180108b849d24174a83847"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "4.1.4+zstd.1.5.2"
version = "4.1.6+zstd.1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f7cd17c9af1a4d6c24beb1cc54b17e2ef7b593dc92f19e9d9acad8b182bbaee"
checksum = "94b61c51bb270702d6167b8ce67340d2754b088d0c091b06e593aa772c3ee9bb"
dependencies = [
"libc",
"zstd-sys",

View file

@ -1,6 +1,9 @@
[workspace]
members = [".", "packages/*"]
[package]
name = "jobboerse"
version = "0.1.6"
version = "0.2.0"
edition = "2021"
rust-version = "1.58"
repository = "https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse"
@ -21,20 +24,22 @@ actix-files = "0.6.0"
actix-web = "4.0.1"
actix-session = { version = "0.6.2", features = ["cookie-session"] }
actix-multipart = "0.4.0"
better_toml_datetime = { path = "packages/better_toml_datetime" }
cargo-bundle-licenses = { version = "0.5.0", default-features = false }
chrono = { version = "0.4.19", default-features = false, features = ["std","clock"] }
chrono-tz = "0.6.1"
clap = { version = "3.1.17", features = ["derive", "env"] }
clap = { version = "3.1.18", features = ["derive", "env"] }
futures-util = "0.3.21"
handlebars = { version = "4.2.2", features = ["dir_source"] }
handlebars = { version = "4.3.0", features = ["dir_source"] }
http = "0.2.7"
lettre = { version = "0.10.0-rc.6", default-features = false, features = ["sendmail-transport", "tokio1", "builder", "serde"] }
# use rustls a native tls library rather than openssl,
# as depending on c dependencies can get annoying even when vendoring
ldap3 = { version = "0.10.4", default-features = false, features = ["tls-rustls"] }
listenfd = "0.5.0"
ldap3 = { version = "0.10.5", default-features = false, features = ["tls-rustls"] }
listenfd = "1.0.0"
log = "0.4.17"
mime_guess = "2.0.4"
multipart_helper = {path = "packages/multipart_helper"}
pretty_env_logger = "0.4.0"
rand = "0.8.5"
serde = { version = "1.0.137", features = ["derive"] }# https://doc.rust-lang.org/cargo/reference/manifest.html

View file

@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.0] (2022-06-09)
### Add
- ability to edit job offers by reviewers after submission
- ability for reviewers to filter for offer requiring review
- ability to change/remove default footer links and add new ones
- ability to highlight a single job offer
### Change
- improve error handling
- split of two small packages
- a lot of refactoring
- update/upgrade dependencies
- reviewer-only settings when not logged in are now an error rather than being silently ignored
- delete expired will now goto a preview instead of deleting directly
- change the format of the summary endpoint
- the top-level is now an object instead of a list
- the top-level list is not the entries field of the top-level object
- an additional version field is added
- also an overview field is added with the url of job offer overview
## [0.1.6] (2022-05-26)
### Change
@ -71,6 +92,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Overview Page of Dependency licenses
[Unreleased]: https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse/src/main
[0.2.0]: https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse/src/version-0.2.0
[0.1.6]: https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse/src/version-0.1.6
[0.1.5]: https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse/src/version-0.1.5
[0.1.4]: https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse/src/version-0.1.4

View file

@ -114,3 +114,18 @@ Check Formatting: `cargo fmt --check`
cargo-deny (installed separately): `cargo deny check`
cargo-msrc (installed separately): `cargo msrv --verify`
Cutting a Release
-----------------
* Update the version in the root Cargo.toml according to semver, this will be the version to-be-cut
* Update the changelog the reflection all changes since the last release unter `[Unreleased]`
* It's generally recommended to keep the Changelog upto date by adding changes to the unreleased section in the commit that introduces the change
* In the now up-to-date changelog add a new section heading for the version to-be-cut between `[Unreleasd]` and the first entry of the unreleased section
* Add a matching link definition at the bottom of a changelog
* Update the version in dist/arch/PKGBUILD to match the version to-be-cut
* run cargo test to update the version in the Cargo.lock file and check that the tests pass
* Commit & Push your changes and wait for them to be merged
* Tag the merge(d) commit as the release and push the tag
* You have Cut a new Release, Congratulations

BIN
THIRDPARTY.toml (Stored with Git LFS)

Binary file not shown.

View file

@ -7,3 +7,7 @@ type = 'Development'
[email]
from = "jobs@localhost"
subject = "Test"
[[footer_links]]
title = "Test"
url = "https://fs-infmath.uni-kiel.de"

View file

@ -33,3 +33,17 @@ type = 'Disabled' # deny all login attempts without further configuration option
# from = "jobs@example.com"
# # content of the SUBJECT header for the confirmation emails
# subject = "[Jobbörse] Please, confirm your job-offer submission."
# you can add additional footer links by adding [[footer_links]] entries
# [[footer_links]]
# title = "Example"
# url = "https://example.com"
# the default footer links Impressum, Homepage and Source Repository can be overriten by adding a matching [[footer_links]] entry
# [[footer_links]]
# title = "Homepage"
# url = "https://example.com/home"
# default footer links can also be removed by adding a matching section here without an URL
# [[footer_links]]
# title = "Source Repository"

2
dist/arch/PKGBUILD vendored
View file

@ -9,7 +9,7 @@ _reponame=Jobboerse
_pkgname="${_reponame,,}"
_features=()
pkgname="${_reponame,,}"
pkgver=0.1.6
pkgver=0.2.0
pkgrel=1
pkgdesc="FS-InfMath Job-Offer Page"
arch=('x86_64') # Other architectures may work

View file

@ -0,0 +1,13 @@
[package]
name = "better_toml_datetime"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
include = ["../../APACHE-2.0.LICENSE","../../MIT.LICENSE"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
serde = "1.0.137"
toml = "0.5.9"
thiserror = "1.0.31"

View file

@ -0,0 +1,223 @@
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display, Formatter};
use std::hash::{Hash, Hasher};
use std::ops::Deref;
use std::str::FromStr;
use thiserror;
use toml::value::DatetimeParseError;
#[derive(Serialize, Deserialize, Clone)]
#[serde(try_from = "toml::value::Datetime", into = "toml::value::Datetime")]
pub struct Date(pub toml::value::Date);
impl FromStr for Date {
type Err = DateError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
self::Datetime::from_str(s)
.map_err(|_| DateError)?
.try_into()
}
}
impl Hash for Date {
fn hash<H: Hasher>(&self, state: &mut H) {
self.0.day.hash(state);
self.0.month.hash(state);
self.0.year.hash(state);
}
}
impl std::fmt::Debug for Date {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Date")
.field("year", &self.0.year)
.field("month", &self.0.month)
.field("day", &self.0.day)
.finish()
}
}
impl From<Date> for toml::value::Datetime {
fn from(date: Date) -> Self {
toml::value::Datetime {
date: Some(date.0),
time: None,
offset: None,
}
}
}
impl From<Date> for toml::value::Date {
fn from(our_date: Date) -> Self {
our_date.0
}
}
impl From<toml::value::Date> for Date {
fn from(toml_date: toml::value::Date) -> Self {
Self(toml_date)
}
}
impl Deref for Date {
type Target = toml::value::Date;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, thiserror::Error)]
#[error("Failed to parse date")]
pub struct DateError;
impl TryFrom<toml::value::Datetime> for Date {
type Error = DateError;
fn try_from(value: toml::value::Datetime) -> std::result::Result<Self, Self::Error> {
value.date.map(Date).ok_or(DateError)
}
}
impl TryFrom<self::Datetime> for Date {
type Error = DateError;
fn try_from(value: Datetime) -> Result<Self, Self::Error> {
value.date.ok_or(DateError)
}
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(try_from = "toml::value::Datetime", into = "toml::value::Datetime")]
pub struct Time(pub toml::value::Time);
impl Deref for Time {
type Target = toml::value::Time;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Hash for Time {
fn hash<H: Hasher>(&self, state: &mut H) {
self.0.hour.hash(state);
self.0.minute.hash(state);
self.0.second.hash(state);
self.0.nanosecond.hash(state);
}
}
impl Debug for Time {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Time")
.field("hour", &self.0.hour)
.field("minute", &self.0.minute)
.field("second", &self.0.second)
.field("nanosecond", &self.0.nanosecond)
.finish()
}
}
impl From<Time> for toml::value::Datetime {
fn from(time: Time) -> Self {
toml::value::Datetime {
date: None,
time: Some(time.0),
offset: None,
}
}
}
impl From<Time> for toml::value::Time {
fn from(our_time: Time) -> Self {
our_time.0
}
}
impl From<toml::value::Time> for Time {
fn from(toml_time: toml::value::Time) -> Self {
Self(toml_time)
}
}
#[derive(Debug, thiserror::Error)]
#[error("Failed to parse date")]
pub struct TimeError;
impl TryFrom<toml::value::Datetime> for Time {
type Error = TimeError;
fn try_from(value: toml::value::Datetime) -> Result<Self, Self::Error> {
value.time.map(Time).ok_or(TimeError)
}
}
#[derive(Clone, Debug, Hash)]
pub enum Offset {
Z,
Custom { hours: i8, minutes: u8 },
}
impl From<toml::value::Offset> for Offset {
fn from(toml_offset: toml::value::Offset) -> Self {
match toml_offset {
toml::value::Offset::Z => Self::Z,
toml::value::Offset::Custom { hours, minutes } => Self::Custom { hours, minutes },
}
}
}
impl From<Offset> for toml::value::Offset {
fn from(our_offset: Offset) -> Self {
match our_offset {
Offset::Z => Self::Z,
Offset::Custom { hours, minutes } => Self::Custom { hours, minutes },
}
}
}
#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
#[serde(from = "toml::value::Datetime", into = "toml::value::Datetime")]
pub struct Datetime {
pub date: Option<Date>,
pub time: Option<Time>,
pub offset: Option<Offset>,
}
impl From<toml::value::Datetime> for Datetime {
fn from(toml_dt: toml::value::Datetime) -> Self {
Self {
date: toml_dt.date.map(Date),
time: toml_dt.time.map(Time),
offset: toml_dt.offset.map(Offset::from),
}
}
}
impl From<Datetime> for toml::value::Datetime {
fn from(our_dt: Datetime) -> Self {
Self {
date: our_dt.date.map(|elem| elem.0),
time: our_dt.time.map(|elem| elem.0),
offset: our_dt.offset.map(toml::value::Offset::from),
}
}
}
pub type DatetimeError = DatetimeParseError;
impl FromStr for Datetime {
type Err = DatetimeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
toml::value::Datetime::from_str(s).map(Self::from)
}
}
impl Display for Datetime {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
Display::fmt(&toml::value::Datetime::from(self.clone()), f)
}
}

View file

@ -0,0 +1,15 @@
[package]
name = "multipart_helper"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
include = ["../../APACHE-2.0.LICENSE", "../../MIT.LICENSE"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-multipart = "0.4.0"
futures-util = "0.3.21"
tempfile = "3.3.0"
thiserror = "1.0.31"
toml = "0.5.9"

View file

@ -1,11 +1,34 @@
use std::io::{BufWriter, Write};
use std::path::Path;
use std::string::FromUtf8Error;
use actix_multipart::Field;
use actix_multipart::{Field, MultipartError};
use futures_util::StreamExt;
use tempfile::NamedTempFile;
use crate::error::MultipartFieldError;
use crate::ServerConfig;
#[derive(Debug, thiserror::Error)]
pub enum MultipartFieldError {
/// The maximal byte size for a field has been exceeded
#[error("the field {field} limit of {max_byte_size} was exceeded")]
ContentTooLarge {
field: &'static str,
max_byte_size: usize,
},
#[error("the multipart-from data was malformed: {0}")]
Multipart(#[from] MultipartError),
/// File filed has no associated file_ame
#[error("missing filename for attachment in field {field}")]
NotAFile { field: &'static str },
/// A field that can only be specified once occurred a second time
#[error(
"the field {field} occurred more often than expected, expected at most {limit} occurrences"
)]
TooManyOccurrences { field: &'static str, limit: u8 },
#[error("{0}")]
IOError(#[from] std::io::Error),
#[error("{0}")]
UTF8Error(#[from] FromUtf8Error),
}
pub async fn once_field(
field: Field,
@ -45,13 +68,12 @@ pub async fn multi_field(
Ok(())
}
#[allow(unused)]
pub(crate) async fn once_file(
pub async fn once_file(
field: Field,
name: &'static str,
storage: &mut Option<(String, Option<NamedTempFile>)>,
max_size: usize,
config: &ServerConfig,
config: &Path,
) -> Result<(), MultipartFieldError> {
if storage.is_some() {
Err(MultipartFieldError::TooManyOccurrences {
@ -65,13 +87,13 @@ pub(crate) async fn once_file(
}
}
pub(crate) async fn multi_file(
pub async fn multi_file(
field: Field,
name: &'static str,
storage: &mut Vec<(String, Option<NamedTempFile>)>,
max_size: usize,
max_count: Option<u8>,
config: &ServerConfig,
config: &Path,
) -> Result<(), MultipartFieldError> {
if let Some(count) = max_count {
if storage.len() >= count.into() {
@ -90,13 +112,14 @@ pub(crate) async fn tmpfile_from_field(
mut field: Field,
field_name: &'static str,
limit: usize,
config: &ServerConfig,
tmp_dir: &Path,
) -> Result<(String, Option<NamedTempFile>), MultipartFieldError> {
// while it is technically valid to not get a filename, we expect it to be present
let file_name = field
.content_disposition()
.get_filename()
.map(str::to_string)
.ok_or(MultipartFieldError::NotAFile)?;
.ok_or(MultipartFieldError::NotAFile { field: field_name })?;
let mut remaining = limit;
@ -110,7 +133,7 @@ pub(crate) async fn tmpfile_from_field(
let buf = if let Some(buf) = file_buffer.as_mut() {
buf
} else {
let file = NamedTempFile::new_in(&config.config.data_storage_path)?;
let file = NamedTempFile::new_in(&tmp_dir)?;
file_buffer.insert(BufWriter::new(file))
};
remaining -= data.len();
@ -135,7 +158,7 @@ pub(crate) async fn tmpfile_from_field(
Ok((file_name, file))
}
pub async fn string_from_field(
pub(crate) async fn string_from_field(
mut field: Field,
field_name: &'static str,
limit: usize,

View file

@ -1,8 +1,6 @@
use std::collections::HashMap;
use actix_session::Session;
use ldap3::{DerefAliases, SearchOptions};
use log::{debug, warn};
use serde::Deserialize;
mod login_provider;
@ -31,7 +29,7 @@ struct LoginData {
}
impl User {
pub(crate) fn current(session: &Session) -> actix_web::Result<User, LoginRequired> {
pub(crate) fn current(session: &Session) -> Result<User, LoginRequired> {
session
.get::<String>(USER_NAME)
.map_err(|_| LoginRequired::new())
@ -53,100 +51,19 @@ impl User {
provided_password: &str,
session: &Session,
config: &ServerConfig,
) -> actix_web::Result<Option<User>, AuthenticationError> {
const USERNAME_PATTERN: &str = "%{username}";
let result = match &config.config.login_provider {
LoginProviderConfig::Ldap(ldap_config) => {
let ldap_settings =
ldap3::LdapConnSettings::new().set_starttls(ldap_config.starttls);
let (connection, mut ldap) =
ldap3::LdapConnAsync::with_settings(ldap_settings, &ldap_config.server_address)
.await?;
ldap3::drive!(connection);
let escaped_username_for_dn = ldap3::dn_escape(user_name);
let ldap_dn = ldap_config
.ldap_user_dn
.replace(USERNAME_PATTERN, &escaped_username_for_dn);
debug!("Attempting ldap simple bind for fn {}", ldap_dn);
let result = ldap.simple_bind(&ldap_dn, provided_password).await?;
match result.success() {
Ok(_) => {
let escaped_username_for_filter = ldap3::ldap_escape(user_name);
let ldap_user_filter = ldap_config
.ldap_user_filter
.replace(USERNAME_PATTERN, &escaped_username_for_filter);
debug!("Attempting ldap search: {}", ldap_user_filter);
let (search_result, _) = match ldap
.with_search_options(
SearchOptions::default().deref(DerefAliases::Never),
)
.search(&ldap_dn, ldap3::Scope::Subtree, &ldap_user_filter, ["uid"])
.await?
.success()
{
Ok(result) => result,
Err(err) => {
debug!("ldap search failed after successful bind {:?}", err);
return Err(err.into());
}
};
match search_result.len() {
1 => Some(User {
name: user_name.to_owned(),
}),
0 => {
warn!("No ldap entry fount after filter!");
None
}
_ => {
warn!("Multiple ldap entries found!");
None
}
}
}
Err(err) => {
debug!("ldap bind failed: {:?}", err);
None
}
}
}
LoginProviderConfig::Simple(config) => {
let simple_login_data = tokio::fs::read_to_string(&config.file_path).await?;
let login_data: LoginData = toml::from_str(&simple_login_data)?;
if let Some(stored_password) = login_data.users.get(user_name) {
if stored_password == provided_password {
Some(User {
name: user_name.to_owned(),
})
} else {
None
}
} else {
None
}
}
LoginProviderConfig::Disabled => None,
#[cfg(feature = "dev_mode")]
LoginProviderConfig::Development => {
warn!("Using dev-login provider!");
Some(User {
name: user_name.into(),
})
}
};
if let Some(user) = &result {
) -> Result<Option<User>, AuthenticationError> {
if let Some(user) = config
.config
.login_provider
.authenticate(user_name, provided_password)
.await?
{
session
.insert(USER_NAME, &user.name)
.map_err(AuthenticationError::SetCookie)?;
Ok(Some(user))
} else {
Ok(None)
}
Ok(result)
}
}

View file

@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use crate::auth::login_provider::ldap::LdapLoginProviderConfig;
use crate::auth::login_provider::simple::SimpleLoginProviderConfig;
use crate::auth::User;
use crate::error::AuthenticationError;
mod ldap;
mod simple;
@ -22,6 +24,33 @@ pub(crate) enum LoginProviderConfig {
Development,
}
impl LoginProviderConfig {
pub(crate) async fn authenticate(
&self,
user_name: &str,
provided_password: &str,
) -> Result<Option<User>, AuthenticationError> {
match self {
LoginProviderConfig::Ldap(ldap_config) => {
ldap_config.authenticate(user_name, provided_password).await
}
LoginProviderConfig::Simple(simple_config) => {
simple_config
.authenticate(user_name, provided_password)
.await
}
LoginProviderConfig::Disabled => Ok(None),
#[cfg(feature = "dev_mode")]
LoginProviderConfig::Development => {
log::warn!("Using dev-login provider!");
Ok(Some(User {
name: user_name.to_owned(),
}))
}
}
}
}
impl Default for LoginProviderConfig {
fn default() -> Self {
LoginProviderConfig::Simple(SimpleLoginProviderConfig::default())

View file

@ -1,3 +1,7 @@
use crate::auth::User;
use crate::error::AuthenticationError;
use ldap3::{DerefAliases, SearchOptions};
use log::{debug, warn};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
@ -11,3 +15,67 @@ pub(crate) struct LdapLoginProviderConfig {
#[serde(default = "super::default_true")]
pub(crate) starttls: bool,
}
impl LdapLoginProviderConfig {
pub(crate) async fn authenticate(
&self,
user_name: &str,
provided_password: &str,
) -> Result<Option<User>, AuthenticationError> {
const USERNAME_PATTERN: &str = "%{username}";
let ldap_settings = ldap3::LdapConnSettings::new().set_starttls(self.starttls);
let (connection, mut ldap) =
ldap3::LdapConnAsync::with_settings(ldap_settings, &self.server_address).await?;
ldap3::drive!(connection);
let escaped_username_for_dn = ldap3::dn_escape(user_name);
let ldap_dn = self
.ldap_user_dn
.replace(USERNAME_PATTERN, &escaped_username_for_dn);
debug!("Attempting ldap simple bind for fn {}", ldap_dn);
let result = ldap.simple_bind(&ldap_dn, provided_password).await?;
Ok(match result.success() {
Ok(_) => {
let escaped_username_for_filter = ldap3::ldap_escape(user_name);
let ldap_user_filter = self
.ldap_user_filter
.replace(USERNAME_PATTERN, &escaped_username_for_filter);
debug!("Attempting ldap search: {}", ldap_user_filter);
let (search_result, _) = match ldap
.with_search_options(SearchOptions::default().deref(DerefAliases::Never))
.search(&ldap_dn, ldap3::Scope::Subtree, &ldap_user_filter, ["uid"])
.await?
.success()
{
Ok(result) => result,
Err(err) => {
debug!("ldap search failed after successful bind {:?}", err);
return Err(err.into());
}
};
match search_result.len() {
1 => Some(User {
name: user_name.to_owned(),
}),
0 => {
warn!("No ldap entry fount after filter!");
None
}
_ => {
warn!("Multiple ldap entries found!");
None
}
}
}
Err(err) => {
debug!("ldap bind failed: {:?}", err);
None
}
})
}
}

View file

@ -1,5 +1,7 @@
use std::path::{Path, PathBuf};
use crate::auth::{LoginData, User};
use crate::error::AuthenticationError;
use log::warn;
use serde::{Deserialize, Serialize};
@ -30,6 +32,28 @@ impl SimpleLoginProviderConfig {
PathBuf::from("./login.toml")
}
}
pub(crate) async fn authenticate(
&self,
user_name: &str,
provided_password: &str,
) -> Result<Option<User>, AuthenticationError> {
let simple_login_data = tokio::fs::read_to_string(&self.file_path).await?;
let login_data: LoginData = toml::from_str(&simple_login_data)?;
Ok(
if let Some(stored_password) = login_data.users.get(user_name) {
if stored_password == provided_password {
Some(User {
name: user_name.to_owned(),
})
} else {
None
}
} else {
None
},
)
}
}
impl Default for SimpleLoginProviderConfig {

View file

@ -1,16 +1,13 @@
use std::error::Error;
use std::fmt::{Debug, Display, Formatter};
use std::path::PathBuf;
use std::string::FromUtf8Error;
use actix_multipart::MultipartError;
use actix_web::body::BoxBody;
use actix_web::error::UrlGenerationError;
use actix_web::http::{header, StatusCode};
use actix_web::{HttpResponse, ResponseError};
use handlebars::RenderError;
use ldap3::LdapError;
use lettre::address::AddressError;
use log::{error, warn};
use mime_guess::mime;
use thiserror::Error;
@ -31,6 +28,9 @@ impl LoginRequired {
self.return_to = Some(dest);
self
}
pub fn get_return(&self) -> Option<&Url> {
self.return_to.as_ref()
}
}
impl Display for LoginRequired {
@ -39,70 +39,12 @@ impl Display for LoginRequired {
}
}
impl Error for LoginRequired {}
impl ResponseError for LoginRequired {
fn status_code(&self) -> StatusCode {
StatusCode::UNAUTHORIZED
}
}
#[derive(Debug, thiserror::Error)]
pub enum MultipartFieldError {
/// The maximal byte size for a field has been exceeded
#[error("the field {field} limit of {max_byte_size} was exceeded")]
ContentTooLarge {
field: &'static str,
max_byte_size: usize,
},
#[error("the multipart-from data was malformed: {0}")]
MultipartError(#[from] MultipartError),
#[error("Error while parsing date: {err}")]
Date {
#[from]
err: toml::value::DatetimeParseError,
},
/// File filed has no associated file_ame
#[error("missing filename for attachment")]
NotAFile,
/// A field that can only be specified once occurred a second time
#[error(
"the field {field} occurred more often than expected, expected at most {limit} occurrences"
)]
TooManyOccurrences { field: &'static str, limit: u8 },
#[error("a required filed was missing: {0}")]
MissingField(&'static str),
#[error("{0}")]
IOError(#[from] std::io::Error),
#[error("{0}")]
UTF8Error(#[from] FromUtf8Error),
#[error("invalid contact address: {0}")]
InvalidAddress(#[from] AddressError),
#[error("A runtime Error occurred while processing the request")]
Runtime(#[from] tokio::task::JoinError),
}
impl ResponseError for MultipartFieldError {
fn status_code(&self) -> StatusCode {
match self {
MultipartFieldError::ContentTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
MultipartFieldError::IOError(_) | MultipartFieldError::Runtime(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
MultipartFieldError::Date { .. }
| MultipartFieldError::MultipartError(_)
| MultipartFieldError::NotAFile
| MultipartFieldError::TooManyOccurrences { .. }
| MultipartFieldError::UTF8Error(_)
| MultipartFieldError::MissingField(_)
| MultipartFieldError::InvalidAddress(_) => StatusCode::BAD_REQUEST,
}
}
}
#[derive(Error, Debug)]
pub(crate) enum PresentationError {
#[error("Failed to render page template")]
#[error("Failed to render page template: {0}")]
Render(#[from] RenderError),
#[error("Failed to generate URL for route")]
#[error("Failed to generate URL for route: {0}")]
Url(#[from] UrlGenerationError),
}
@ -114,10 +56,7 @@ impl ResponseError for PresentationError {
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let status_code = self.status_code();
match self {
PresentationError::Render(inner) => default_error_response(inner, status_code),
PresentationError::Url(inner) => default_error_response(inner, status_code),
}
default_error_response(self, status_code)
}
}
@ -135,7 +74,17 @@ pub(crate) fn default_error_response(
error: &impl Error,
status: StatusCode,
) -> HttpResponse<BoxBody> {
warn!("Some error occurred {}", error);
if status.is_server_error() {
error!("A Server-Side Error Occurred: {}", error)
} else if status.is_client_error() {
warn!("A Client-Side Error Occurred: {}", error)
} else {
error!(
"An error occurred, but a non-error status code was generated {}: {}",
status, error
)
}
HttpResponse::build(status)
.insert_header((header::CONTENT_TYPE, mime::TEXT_PLAIN_UTF_8))
.body(error.to_string())
@ -172,8 +121,8 @@ mod ldap_response_code {
pub const UNAVAILABLE: u32 = 52;
}
impl ResponseError for AuthenticationError {
fn status_code(&self) -> StatusCode {
impl AuthenticationError {
pub(crate) fn as_status_code(&self) -> StatusCode {
match self {
AuthenticationError::Ldap(ldap_error) => match ldap_error {
LdapError::LdapResult { result } => match result.rc {
@ -190,3 +139,16 @@ impl ResponseError for AuthenticationError {
}
}
}
#[derive(Debug, Error)]
#[error("Authentication failed: {0}")]
pub(crate) struct LoginResponseError(#[from] AuthenticationError);
impl ResponseError for LoginResponseError {
fn status_code(&self) -> StatusCode {
self.0.as_status_code()
}
fn error_response(&self) -> HttpResponse<BoxBody> {
default_error_response(self, self.status_code())
}
}

View file

@ -1,178 +1,45 @@
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap};
use std::fmt::Formatter;
use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::hash::{Hash, Hasher};
use std::io::ErrorKind;
use std::net::IpAddr;
use std::ops::{Add, Deref};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::ops::{Add, Deref, DerefMut};
use std::path::{Path, PathBuf};
use actix_web::error::UrlGenerationError;
use actix_web::{HttpRequest, Result};
use chrono::{FixedOffset, TimeZone, Timelike};
use error::{DeleteError, SaveError};
use chrono::{TimeZone, Timelike};
use error::DeleteError;
use lettre::Address;
use log::{debug, error, info, warn};
use rand::distributions::DistString;
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use tokio::sync::{Mutex, RwLock, RwLockMappedWriteGuard, RwLockReadGuard, RwLockWriteGuard};
use toml::value::Datetime;
use tempfile::{NamedTempFile, PersistError};
use tokio::sync::{RwLock, RwLockMappedWriteGuard, RwLockReadGuard, RwLockWriteGuard};
use url::Url;
use view::{JobOfferActions, JobOfferViewData};
use crate::auth::User;
use crate::error::PresentationError;
use crate::route::{JOBOFFER_ATTACHMENT_ROUTE, PREVIEW_ATTACHMENT_ROUTE};
use crate::route::{JOBOFFER_DELETION_ROUTE, JOBOFFER_PUBLISH_ROUTE, JOBOFFER_UNPUBLISH_ROUTE};
use crate::util::{toml_date_to_chrono_date, toml_datetime_to_chrono_datetime};
use crate::ServerConfig;
use better_toml_datetime::Date;
use crate::job_offers::view::JobOfferEditData;
use crate::{
auth::User,
error::PresentationError,
job_offers::error::SaveError,
route::{JOBOFFER_ATTACHMENT_ROUTE, PREVIEW_ATTACHMENT_ROUTE},
util::{toml_date_to_chrono_date, toml_datetime_to_chrono_datetime},
ServerConfig,
};
pub(crate) mod error;
pub(crate) mod lease;
pub(crate) mod view;
#[derive(Default, Debug)]
pub struct SubmissionLimiter {
map: Mutex<HashMap<IpAddr, u8>>,
}
pub(crate) struct SubmissionLease {
limiter: Option<Arc<SubmissionLimiter>>,
addr: IpAddr,
}
impl SubmissionLease {
/// Start the lease for the timeout
pub fn engage(self) {
// spawn an async task to end the lease after the timeout
tokio::spawn(async move {
const MINUTE: u64 = 60;
tokio::time::sleep(Duration::from_secs(30 * MINUTE)).await;
self.end().await
});
}
/// End the lease immediately
pub async fn end(mut self) {
if let Some(limiter) = self.limiter.take() {
end_lease(limiter, self.addr).await
} else {
warn!("Unexpectedly found a SubmissionLease without SubmissionLimiter")
}
}
}
async fn end_lease(limiter: Arc<SubmissionLimiter>, addr: IpAddr) {
use std::collections::hash_map::Entry;
// decrement the counter and remove it if it reaches zero
match limiter
.map
.lock()
.await
.entry(addr)
.and_modify(|v| *v = v.saturating_sub(1))
{
Entry::Occupied(occupied) => {
if *occupied.get() == 0 {
occupied.remove();
}
}
Entry::Vacant(_) => {
warn!("Unexpected VacentEntry while attempting limiter decrement!");
}
}
}
impl Drop for SubmissionLease {
fn drop(&mut self) {
if let Some(limiter) = self.limiter.take() {
warn!("SubmissionLeased dropped before it was engaged or ended! Ending the lease now!");
tokio::spawn(end_lease(limiter, self.addr));
}
}
}
impl SubmissionLimiter {
/// Register a pending submission returning a ticket to allow
/// validate it as executed or cancle it otherwise
pub(crate) async fn get_submission_lease(
self: Arc<Self>,
addr: IpAddr,
) -> Option<SubmissionLease> {
use std::collections::hash_map::Entry;
const LIMIT: u8 = 10;
match self.map.lock().await.entry(addr) {
Entry::Occupied(occupied) if *occupied.get() >= LIMIT => None,
entry => {
// increment the value when it already exists or increment it
entry.and_modify(|value| *value += 1).or_insert(1);
let self_clone = self.clone();
Some(SubmissionLease {
limiter: Some(self_clone),
addr,
})
}
}
}
}
#[derive(serde::Serialize)]
pub(crate) struct JobOfferData {
id: String,
status: JobOfferStatus,
offering_party: String,
contact_data: Option<String>,
published: bool,
reviewed: bool,
confirmed: bool,
preview: bool,
expiry_date: Option<String>,
expired: bool,
submission_date: String,
#[serde(skip)] // used for sorting, not necessary for serialization
precise_submission: chrono::DateTime<FixedOffset>,
title: String,
attachments: Vec<Attachment<String>>,
links: Vec<Link>,
actions: Option<JobOfferActions>,
}
#[derive(serde::Serialize)]
pub struct JobOfferActions {
publish_url: String,
unpublish_url: String,
delete_url: String,
}
impl JobOfferActions {
fn new(req: &HttpRequest, id: &JobOfferId) -> Result<Self, UrlGenerationError> {
let publish_url = req
.url_for(JOBOFFER_PUBLISH_ROUTE, &[id])
.expect("generation of publish route urls should succeed")
.to_string();
let unpublish_url = req
.url_for(JOBOFFER_UNPUBLISH_ROUTE, &[id])
.expect("generation of un-publish route urls should succeed")
.to_string();
let delete_url = req
.url_for(JOBOFFER_DELETION_ROUTE, &[id])
.expect("generation of delete route urls should succeed")
.to_string();
Ok(Self {
publish_url,
unpublish_url,
delete_url,
})
}
}
pub(crate) fn job_data(
pub(crate) fn job_data<'i, I: Iterator<Item = (&'i str, &'i JobOffer<PathBuf>)>>(
req: &HttpRequest,
offers: BorrowedJobOffers<'_>,
offers: I,
user: Option<&User>,
) -> Vec<JobOfferData> {
) -> Vec<JobOfferViewData> {
let mut data: Vec<_> = offers
.iter()
.filter_map(|(id, offer)| match offer.to_real_data(id, req, user) {
Ok(offer) => offer,
Err(err) => {
@ -185,7 +52,7 @@ pub(crate) fn job_data(
data
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
#[derive(serde::Serialize, serde::Deserialize, Debug, Hash)]
pub(crate) struct Attachment<Location> {
// The title for this attachment as displayed
pub(crate) title: String,
@ -196,20 +63,57 @@ pub(crate) struct Attachment<Location> {
pub(crate) attachment_location: Location,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
impl Attachment<NamedTempFile> {
pub(crate) fn persist(
self,
idx: usize,
folder_path: &Path,
) -> Result<Attachment<PathBuf>, PersistError> {
let attachment_location = Self::attachment_filename(idx);
let file_path = folder_path.join(&attachment_location);
self.attachment_location.persist(&file_path)?;
Ok(Attachment {
title: self.title,
file_name: self.file_name,
attachment_location,
})
}
pub(crate) fn attachment_filename(idx: usize) -> PathBuf {
PathBuf::from(format!("{idx}.attachment"))
}
}
impl Attachment<PathBuf> {
pub(crate) fn generate_link(
&self,
id: &JobOfferId,
req: &HttpRequest,
preview_token: Option<&str>,
) -> Result<Url, UrlGenerationError> {
let mut url = req.url_for(JOBOFFER_ATTACHMENT_ROUTE, &[id, self.file_name.as_str()])?;
if let Some(token) = preview_token {
url.query_pairs_mut().append_pair("token", token);
}
Ok(url)
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Hash)]
pub(crate) struct Link {
pub(crate) title: String,
pub(crate) destination: String,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Hash)]
#[serde(tag = "type")]
pub enum ConfirmationStatus {
AwaitingConfirmation { token: String },
Confirmed,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Hash)]
pub enum ReviewStatus {
/// Has yet to be reviewed
AwaitingReview,
@ -219,7 +123,7 @@ pub enum ReviewStatus {
UnPublished,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Hash)]
pub struct JobOfferStatus {
review_status: ReviewStatus,
confirmation_status: ConfirmationStatus,
@ -291,49 +195,13 @@ impl JobOfferStatus {
}
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(try_from = "toml::value::Datetime", into = "toml::value::Datetime")]
pub(crate) struct Date(pub(crate) toml::value::Date);
impl std::fmt::Debug for Date {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Date")
.field("year", &self.0.year)
.field("month", &self.0.month)
.field("day", &self.0.day)
.finish()
}
}
impl From<Date> for Datetime {
fn from(date: Date) -> Self {
Datetime {
date: Some(date.0),
time: None,
offset: None,
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("Failed to parse date")]
pub struct DateError;
impl TryFrom<Datetime> for Date {
type Error = DateError;
fn try_from(value: Datetime) -> std::result::Result<Self, Self::Error> {
value.date.map(Date).ok_or(DateError)
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
#[derive(serde::Serialize, serde::Deserialize, Debug, Hash)]
pub struct JobOffer<AttachmentLocation> {
pub(crate) title: String,
pub(crate) offering_party: String,
#[serde(skip_serializing_if = "std::ops::Not::not", default)]
pub(crate) public_contact_info: bool,
pub(crate) date_of_submission: Datetime,
pub(crate) date_of_submission: better_toml_datetime::Datetime,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub(crate) date_of_expiry: Option<Date>,
#[serde(skip_serializing_if = "std::ops::Not::not", default)]
@ -360,7 +228,7 @@ impl<A> JobOffer<A> {
self.status.is_published()
}
fn folder_path(id: &JobOfferId, config: &ServerConfig) -> PathBuf {
pub(crate) fn folder_path(id: &JobOfferId, config: &ServerConfig) -> PathBuf {
config.config.data_storage_path.join(id)
}
@ -384,16 +252,7 @@ impl JobOffer<NamedTempFile> {
.attachments
.into_iter()
.enumerate()
.map(|(idx, entry)| {
let attachment_location = PathBuf::from(format!("{idx}.attachment"));
let file_path = folder_path.join(&attachment_location);
entry.attachment_location.persist(&file_path)?;
Ok(Attachment {
title: entry.title,
file_name: entry.file_name,
attachment_location,
})
})
.map(|(idx, entry)| entry.persist(idx, &folder_path))
.collect::<Result<_, tempfile::PersistError>>()?;
// use https://github.com/rust-lang/rust/issues/86555 when stabilized
@ -439,7 +298,6 @@ impl JobOffer<PathBuf> {
let offer = match toml::ser::to_string(self) {
Ok(offer) => offer,
Err(err) => {
dbg!(&err);
return Err(err.into());
}
};
@ -469,15 +327,6 @@ impl JobOffer<PathBuf> {
Err(std::io::Error::from(ErrorKind::NotFound))
}
pub(crate) fn to_preview_data(
&self,
id: &JobOfferId,
req: &HttpRequest,
confirmation_token: Option<&str>,
) -> Result<JobOfferData, PresentationError> {
self.to_data(id, req, true, None, confirmation_token)
}
pub(crate) fn is_expired(&self) -> bool {
!self.permanent && {
let expires_after = self
@ -498,12 +347,63 @@ impl JobOffer<PathBuf> {
}
}
pub(crate) fn to_edit_data(
&self,
id: &JobOfferId,
req: &HttpRequest,
) -> Result<JobOfferEditData, PresentationError> {
let mut hasher = DefaultHasher::new();
JobOffer::<PathBuf>::hash(self, &mut hasher);
let hash = hasher.finish();
let attachments = self
.attachments
.iter()
.map(|attachment| {
Ok(Attachment {
title: attachment.title.clone(),
file_name: attachment.file_name.clone(),
attachment_location: attachment.generate_link(id, req, None)?.into(),
})
})
.collect::<Result<Vec<_>, UrlGenerationError>>()?;
let mut links: Vec<_> = self.links.iter().cloned().map(Some).collect();
if links.len() < crate::route::form_constants::LINK_FIELD_COUNT {
links.resize(crate::route::form_constants::LINK_FIELD_COUNT, None)
}
Ok(JobOfferEditData {
id: id.to_owned(),
hash,
offering_party: self.offering_party.to_owned(),
contact_data: self.contact_info.clone(),
public_contact_data: self.public_contact_info,
permanent: self.permanent,
expiry_date: self.date_of_expiry.as_deref().map(ToString::to_string),
submission_date: self.date_of_submission.to_string(),
title: self.title.clone(),
attachments,
links,
})
}
pub(crate) fn to_preview_data(
&self,
id: &JobOfferId,
req: &HttpRequest,
confirmation_token: Option<&str>,
) -> Result<JobOfferViewData, PresentationError> {
self.to_data(id, req, true, None, confirmation_token)
}
fn to_real_data(
&self,
id: &JobOfferId,
req: &HttpRequest,
user: Option<&User>,
) -> Result<Option<JobOfferData>, PresentationError> {
) -> Result<Option<JobOfferViewData>, PresentationError> {
let data = self.to_data(id, req, false, user, None)?;
if user.is_none() && (data.expired || !data.published) {
Ok(None)
@ -519,7 +419,7 @@ impl JobOffer<PathBuf> {
is_preview: bool,
user: Option<&User>,
confirmation_token: Option<&str>,
) -> Result<JobOfferData, PresentationError> {
) -> Result<JobOfferViewData, PresentationError> {
let is_authenticated = user.is_some();
let is_expired = self.is_expired();
@ -538,21 +438,10 @@ impl JobOffer<PathBuf> {
.attachments
.iter()
.map(|attachment| {
let location = match (is_preview, confirmation_token) {
(false, _) => req
.url_for(
JOBOFFER_ATTACHMENT_ROUTE,
&[id, attachment.file_name.as_str()],
)?
.to_string(),
let location = match (is_preview && !self.is_published(), confirmation_token) {
(false, _) => attachment.generate_link(id, req, None)?.to_string(),
(true, Some(token)) if self.check_confirmation_token(token) => {
// preview with valid confirmation token
let mut url = req.url_for(
JOBOFFER_ATTACHMENT_ROUTE,
&[id, attachment.file_name.as_str()],
)?;
url.query_pairs_mut().append_pair("token", token);
url.to_string()
attachment.generate_link(id, req, Some(token))?.to_string()
}
(true, _) => preview_location.to_string(),
};
@ -571,7 +460,7 @@ impl JobOffer<PathBuf> {
None
};
Ok(JobOfferData {
Ok(JobOfferViewData {
actions,
id: id.to_string(),
status: self.status.clone(),
@ -766,28 +655,6 @@ impl JobOffers {
Ok(())
}
pub(crate) async fn delete_expired(&self, config: &ServerConfig) -> Result<usize, DeleteError> {
let read_guard = self.data.read().await;
let expired_keys = self
.data
.read()
.await
.iter()
.filter_map(|(key, value)| value.is_expired().then(|| key.clone()))
.collect::<Vec<_>>();
// we need to drop the read guard before we try to acquire a write guard in delete_offer below otherwise we cause a dead-lock
drop(read_guard);
for key in &expired_keys {
// to not block readers for too long we perform individual deletes instead of a batch delete
self.delete_offer(key, true, config).await?
}
Ok(expired_keys.len())
}
}
pub(crate) struct BorrowedJobOffers<'a>(
@ -861,6 +728,11 @@ impl<'id> MutBorrowedJobOffer<'_, 'id, '_> {
Ok(())
}
pub(crate) fn get_mut(&mut self) -> &mut JobOffer<PathBuf> {
self.dirty = Dirty::Unknown;
self.data.deref_mut()
}
pub(crate) async fn try_clean(&mut self) -> Result<(), SaveError> {
if !self.is_clean() {
// we have not saved our changes yet, try once to do so

View file

@ -1,4 +1,4 @@
use crate::error::default_error_response;
use crate::error::{default_error_response, LoginRequired};
use actix_web::body::BoxBody;
use actix_web::error::UrlGenerationError;
use actix_web::{HttpResponse, ResponseError};
@ -6,24 +6,20 @@ use http::StatusCode;
#[derive(thiserror::Error, Debug)]
pub(crate) enum SaveError {
#[error("Creating a new Job Offer failed as the generated ID is already taken")]
AlreadyExists,
#[error("{0}")]
IO(#[from] std::io::Error),
#[error("{0}")]
Persist(#[from] tempfile::PersistError),
#[error("Could not serialize Job Offer: {0}")]
Serialize(#[from] toml::ser::Error),
#[error("Could not generate url for confirmation link: {0}")]
Url(#[from] UrlGenerationError),
#[error("Could not send Confirmation E-Mail: {0}")]
Email(#[from] EmailError),
#[error("The Runtime encountered an error!")]
#[error("The Runtime encountered an error!: {0}")]
Runtime(#[from] tokio::task::JoinError),
#[error("Creating a new Job Offer failed as the generated ID is already taken")]
AlreadyExists,
}
impl ResponseError for SaveError {
fn status_code(&self) -> StatusCode {
impl SaveError {
pub(crate) fn as_status_code(&self) -> StatusCode {
match self {
// add special handling of ErrorKind::StorageFull | FilesystemQuotaExceeded once those error kinds are stabilized
/*
@ -40,26 +36,40 @@ impl ResponseError for SaveError {
{
StatusCode::INSUFFICIENT_STORAGE
}*/
SaveError::Persist(_)
| SaveError::IO(_)
SaveError::IO(_)
| SaveError::Persist(_)
| SaveError::Serialize(_)
| SaveError::Url(_)
| SaveError::Runtime(_) => StatusCode::INTERNAL_SERVER_ERROR,
SaveError::AlreadyExists => StatusCode::CONFLICT,
SaveError::Email(inner) => inner.status_code(),
}
}
}
#[derive(thiserror::Error, Debug)]
pub(crate) enum SaveResponseError {
#[error("Could not generate url for confirmation link: {0}")]
Url(#[from] UrlGenerationError),
#[error("Could not save job offer: {0}")]
Save(#[from] SaveError),
#[error("Could not send Confirmation E-Mail: {0}")]
Email(#[from] EmailError),
#[error("A reviewer-only setting was specified, but no valid session was found.")]
Login(#[from] LoginRequired),
}
impl ResponseError for SaveResponseError {
fn status_code(&self) -> StatusCode {
match self {
SaveResponseError::Save(inner) => inner.as_status_code(),
SaveResponseError::Url(_) | SaveResponseError::Email(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
SaveResponseError::Login(_) => StatusCode::UNAUTHORIZED,
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let status_code = self.status_code();
match self {
SaveError::AlreadyExists => default_error_response(self, status_code),
SaveError::IO(inner) => default_error_response(inner, status_code),
SaveError::Persist(inner) => default_error_response(inner, status_code),
SaveError::Serialize(inner) => default_error_response(inner, status_code),
SaveError::Url(inner) => default_error_response(inner, status_code),
SaveError::Email(inner) => inner.error_response(),
SaveError::Runtime(inner) => default_error_response(inner, status_code),
}
default_error_response(self, status_code)
}
}
#[derive(Debug, thiserror::Error)]
@ -72,40 +82,8 @@ pub(crate) enum EmailError {
Template(#[from] handlebars::RenderError),
}
impl ResponseError for EmailError {
fn status_code(&self) -> StatusCode {
match self {
EmailError::Email(_) => StatusCode::BAD_REQUEST,
EmailError::SendMail(_) => StatusCode::INTERNAL_SERVER_ERROR,
EmailError::Template(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let status_code = self.status_code();
match self {
EmailError::Email(inner) => default_error_response(inner, status_code),
EmailError::SendMail(inner) => default_error_response(inner, status_code),
EmailError::Template(inner) => default_error_response(inner, status_code),
}
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum DeleteError {
#[error("{0}")]
IO(#[from] std::io::Error),
}
impl ResponseError for DeleteError {
fn status_code(&self) -> StatusCode {
match self {
DeleteError::IO(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let status_code = self.status_code();
match self {
DeleteError::IO(inner) => default_error_response(inner, status_code),
}
}
}

91
src/job_offers/lease.rs Normal file
View file

@ -0,0 +1,91 @@
use log::warn;
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
#[derive(Default, Debug)]
pub struct SubmissionLimiter {
map: Mutex<HashMap<IpAddr, u8>>,
}
pub(crate) struct SubmissionLease {
limiter: Option<Arc<SubmissionLimiter>>,
addr: IpAddr,
}
impl SubmissionLease {
/// Start the lease for the timeout
pub fn engage(self) {
// spawn an async task to end the lease after the timeout
tokio::spawn(async move {
const MINUTE: u64 = 60;
tokio::time::sleep(Duration::from_secs(30 * MINUTE)).await;
self.end().await
});
}
/// End the lease immediately
pub async fn end(mut self) {
if let Some(limiter) = self.limiter.take() {
end_lease(limiter, self.addr).await
} else {
warn!("Unexpectedly found a SubmissionLease without SubmissionLimiter")
}
}
}
async fn end_lease(limiter: Arc<SubmissionLimiter>, addr: IpAddr) {
use std::collections::hash_map::Entry;
// decrement the counter and remove it if it reaches zero
match limiter
.map
.lock()
.await
.entry(addr)
.and_modify(|v| *v = v.saturating_sub(1))
{
Entry::Occupied(occupied) => {
if *occupied.get() == 0 {
occupied.remove();
}
}
Entry::Vacant(_) => {
warn!("Unexpected VacentEntry while attempting limiter decrement!");
}
}
}
impl Drop for SubmissionLease {
fn drop(&mut self) {
if let Some(limiter) = self.limiter.take() {
warn!("SubmissionLeased dropped before it was engaged or ended! Ending the lease now!");
tokio::spawn(end_lease(limiter, self.addr));
}
}
}
impl SubmissionLimiter {
/// Register a pending submission returning a ticket to allow
/// validate it as executed or cancle it otherwise
pub(crate) async fn get_submission_lease(
self: Arc<Self>,
addr: IpAddr,
) -> Option<SubmissionLease> {
use std::collections::hash_map::Entry;
const LIMIT: u8 = 10;
match self.map.lock().await.entry(addr) {
Entry::Occupied(occupied) if *occupied.get() >= LIMIT => None,
entry => {
// increment the value when it already exists or increment it
entry.and_modify(|value| *value += 1).or_insert(1);
let self_clone = self.clone();
Some(SubmissionLease {
limiter: Some(self_clone),
addr,
})
}
}
}
}

88
src/job_offers/view.rs Normal file
View file

@ -0,0 +1,88 @@
use crate::job_offers::{Attachment, JobOfferId, JobOfferStatus, Link};
use crate::route::{
JOBOFFER_DELETION_ROUTE, JOBOFFER_EDIT_ROUTE, JOBOFFER_PUBLISH_ROUTE, JOBOFFER_UNPUBLISH_ROUTE,
};
use crate::util::SerializableUrl;
use actix_web::error::UrlGenerationError;
use actix_web::HttpRequest;
use chrono::FixedOffset;
use lettre::Address;
use url::Url;
#[derive(serde::Serialize)]
pub(crate) struct JobOfferEditData {
pub(crate) id: String,
pub(crate) hash: u64,
pub(crate) offering_party: String,
pub(crate) contact_data: Address,
pub(crate) public_contact_data: bool,
pub(crate) permanent: bool,
pub(crate) expiry_date: Option<String>,
pub(crate) submission_date: String,
pub(crate) title: String,
pub(crate) attachments: Vec<Attachment<SerializableUrl>>,
pub(crate) links: Vec<Option<Link>>,
}
#[derive(serde::Serialize)]
pub(crate) struct JobOfferViewData {
pub(super) id: String,
pub(super) status: JobOfferStatus,
pub(super) offering_party: String,
pub(super) contact_data: Option<String>,
pub(crate) published: bool,
pub(super) reviewed: bool,
pub(super) confirmed: bool,
pub(super) preview: bool,
pub(super) expiry_date: Option<String>,
pub(crate) expired: bool,
pub(super) submission_date: String,
#[serde(skip)] // used for sorting, not necessary for serialization
pub(super) precise_submission: chrono::DateTime<FixedOffset>,
pub(super) title: String,
pub(super) attachments: Vec<Attachment<String>>,
pub(super) links: Vec<Link>,
pub(super) actions: Option<JobOfferActions>,
}
#[derive(serde::Serialize)]
pub struct JobOfferActions {
#[serde(serialize_with = "crate::util::url_as_string")]
publish_url: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
unpublish_url: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
delete_url: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
edit_url: Url,
}
impl JobOfferActions {
pub(super) fn new(
req: &HttpRequest,
id: &JobOfferId,
) -> actix_web::Result<Self, UrlGenerationError> {
let publish_url = req
.url_for(JOBOFFER_PUBLISH_ROUTE, &[id])
.expect("generation of publish route urls should succeed");
let unpublish_url = req
.url_for(JOBOFFER_UNPUBLISH_ROUTE, &[id])
.expect("generation of un-publish route urls should succeed");
let delete_url = req
.url_for(JOBOFFER_DELETION_ROUTE, &[id])
.expect("generation of delete route urls should succeed");
let edit_url = req
.url_for(JOBOFFER_EDIT_ROUTE, &[id])
.expect("generation of delete route urls should succeed");
Ok(Self {
publish_url,
unpublish_url,
delete_url,
edit_url,
})
}
}

View file

@ -13,9 +13,10 @@ use actix_web::middleware::{ErrorHandlers, NormalizePath, TrailingSlash};
use actix_web::{get, web, App, HttpServer};
use handlebars::Handlebars;
use listenfd::ListenFd;
#[cfg(feature = "dev_mode")]
use log::info;
use log::{error, LevelFilter, SetLoggerError};
// internal imports
use job_offers::lease::SubmissionLimiter;
use route::error_handler;
mod auth;
mod error;
@ -26,9 +27,7 @@ mod template;
mod util;
// internal imports
use crate::job_offers::{JobOffers, SubmissionLimiter};
#[cfg(feature = "dev_mode")]
use crate::server_config::OperationMode;
use crate::job_offers::JobOffers;
use crate::server_config::ServerConfig;
// For reference see: https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
@ -48,7 +47,7 @@ pub fn init_logger() -> Result<(), SetLoggerError> {
}
#[actix_web::main]
async fn main() -> std::result::Result<(), error::SeverInitializationError> {
async fn main() -> Result<(), error::SeverInitializationError> {
let _ = init_logger();
let result = run().await;
@ -58,7 +57,7 @@ async fn main() -> std::result::Result<(), error::SeverInitializationError> {
result
}
async fn run() -> std::result::Result<(), error::SeverInitializationError> {
async fn run() -> Result<(), error::SeverInitializationError> {
let server_config = ServerConfig::load().await?;
let mut listen_fd = ListenFd::from_env();
@ -70,10 +69,12 @@ async fn run() -> std::result::Result<(), error::SeverInitializationError> {
let job_offers = JobOffers::init_new(&server_config).await?;
hb.set_strict_mode(true);
#[cfg(feature = "dev_mode")]
{
info!("{:?}", server_config.args);
info!("{:?}", server_config.config);
use crate::server_config::OperationMode;
log::info!("{:?}", server_config.args);
log::info!("{:?}", server_config.config);
hb.set_dev_mode(matches!(
server_config.args.mode,
OperationMode::Development
@ -106,14 +107,25 @@ async fn run() -> std::result::Result<(), error::SeverInitializationError> {
ErrorHandlers::default()
.handler(
StatusCode::INTERNAL_SERVER_ERROR,
route::internal_server_error_handler,
error_handler::internal_server_error_handler,
)
.handler(StatusCode::NOT_FOUND, route::not_found_error_handler)
.handler(StatusCode::UNAUTHORIZED, route::unauthorized_error_handler),
.handler(
StatusCode::NOT_FOUND,
error_handler::not_found_error_handler,
)
.handler(
StatusCode::UNAUTHORIZED,
error_handler::unauthorized_error_handler,
)
.handler(StatusCode::BAD_REQUEST, error_handler::bad_request)
.handler(
StatusCode::TOO_MANY_REQUESTS,
error_handler::too_many_requests,
),
)
.wrap(session_store)
.wrap(NormalizePath::new(TrailingSlash::Trim))
.wrap(logger)
.wrap(session_store)
.app_data(hb_ref.clone())
.app_data(jobs_ref.clone())
.app_data(bundle_ref.clone())

View file

@ -1,39 +1,35 @@
use std::borrow::Cow;
use actix_files::NamedFile;
use actix_session::SessionExt;
use actix_web::dev::ServiceResponse;
use actix_web::error::UrlGenerationError;
use actix_web::http::header::LOCATION;
use actix_web::http::{header, Method};
use actix_web::middleware::ErrorHandlerResponse;
use actix_web::web::{self, Data, ServiceConfig};
use actix_web::{get, HttpRequest, HttpResponse, ResponseError};
use handlebars::Handlebars;
use actix_web::http::header;
use actix_web::web::{self, ServiceConfig};
use actix_web::{get, HttpRequest, HttpResponse};
use http::HeaderValue;
use serde::{Serialize, Serializer};
use serde_json::json;
use thiserror::private::DisplayAsDisplay;
use serde::Serialize;
use url::Url;
mod auth;
pub(crate) mod error_handler;
pub(crate) mod form_constants;
mod job_offer;
mod license;
pub(crate) use auth::{LOGIN_ROUTE, LOGOUT_ROUTE};
pub(crate) use job_offer::{
action::{JOBOFFER_DELETION_ROUTE, JOBOFFER_PUBLISH_ROUTE, JOBOFFER_UNPUBLISH_ROUTE},
create::JOBOFFER_CREATION_ROUTE,
delete::{JOBOFFER_BULK_DELETE_ROUTE, JOBOFFER_DELETE_EXPIRED_ROUTE, JOBOFFER_DELETION_ROUTE},
edit::JOBOFFER_EDIT_ROUTE,
review::{JOBOFFER_PUBLISH_ROUTE, JOBOFFER_UNPUBLISH_ROUTE},
JOBOFFER_ATTACHMENT_ROUTE, JOBOFFER_OVERVIEW_ROUTE, JOBOFFER_SUMMARY_ROUTE,
JOBOFFER_SYNC_ROUTE, PREVIEW_ATTACHMENT_ROUTE,
};
pub(crate) use license::{LICENSES_ROUTE, LICENSE_BUNDLE};
use crate::auth::User;
use crate::error::PresentationError;
use crate::route::job_offer::action::JOBOFFER_DELETE_EXPIRED_ROUTE;
use crate::server_config::OperationMode;
use crate::server_config::ServerConfig;
use crate::util::SerializableUrl;
static HTML_CONTENT: HeaderValue = HeaderValue::from_static("text/html");
static JSON_CONTENT: HeaderValue = HeaderValue::from_static("application/json");
@ -59,86 +55,12 @@ async fn static_index_css() -> Result<NamedFile, actix_web::Error> {
Ok(file.use_last_modified(true))
}
#[derive(Debug, thiserror::Error)]
#[error("Some error occurred while attempting to display an error page")]
pub struct ErrorHandlerError;
impl ResponseError for ErrorHandlerError {}
pub(crate) fn generic_server_error_handler<B>(
res: ServiceResponse,
template: &str,
title: &str,
) -> Result<ErrorHandlerResponse<B>, actix_web::Error> {
let hb: &Data<Handlebars> = res.request().app_data().ok_or(ErrorHandlerError)?;
let config: &Data<ServerConfig> = res.request().app_data().ok_or(ErrorHandlerError)?;
let base = base(res.request(), config, title)?;
let session = res.get_session();
let user = User::current(&session).ok();
let data = json!({
"base": base,
"user": user,
});
let body = hb
.render(template, &data)
.map_err(PresentationError::Render)?;
let response = HttpResponse::with_body(res.status(), body)
.map_into_boxed_body()
.map_into_right_body();
let response = res.into_response(response);
Ok(ErrorHandlerResponse::Response(response))
}
pub(crate) fn internal_server_error_handler<B>(
res: ServiceResponse,
) -> Result<ErrorHandlerResponse<B>, actix_web::Error> {
generic_server_error_handler(res, "error/500", "Internal Server Error")
}
pub(crate) fn not_found_error_handler<B>(
res: ServiceResponse,
) -> Result<ErrorHandlerResponse<B>, actix_web::Error> {
generic_server_error_handler(res, "error/404", "Not Found")
}
pub(crate) fn unauthorized_error_handler<B>(
res: ServiceResponse,
) -> Result<ErrorHandlerResponse<B>, actix_web::Error> {
let mut login_url = res.request().url_for_static(LOGIN_ROUTE)?;
if res.request().method() == Method::GET {
let req_uri = res.request().uri().as_display().to_string();
login_url
.query_pairs_mut()
.append_pair("return_to", &req_uri);
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, login_url.as_str()))
.body("")
.map_into_boxed_body()
.map_into_right_body();
Ok(ErrorHandlerResponse::Response(res.into_response(response)))
} else {
// we do not want to keep the 401 Unauthorized status-code as we will not set the WWW-Authenticate header.
// which the standard requires for 401 responses
let (req, res) = res.into_parts();
let res = ServiceResponse::new(req, HttpResponse::BadRequest().body(res.into_body()));
generic_server_error_handler(res, "error/401", "Unauthorized")
}
}
#[derive(Serialize)]
struct BaseData<'a> {
title: Cow<'a, str>,
short_lang: Cow<'a, str>,
links: BaseLinks<'a>,
#[serde(serialize_with = "crate::route::urls_as_string")]
styles: Vec<Url>,
links: Vec<Link<'a>>,
styles: Vec<SerializableUrl>,
routes: StaticRoutes,
banner: Option<String>,
operation_mode: OperationMode,
@ -147,88 +69,109 @@ struct BaseData<'a> {
}
#[derive(Serialize)]
struct BaseLinks<'a> {
impress: Cow<'a, str>,
repository: Option<Cow<'a, str>>,
homepage: Option<Cow<'a, str>>,
struct StaticRoutes {
#[serde(serialize_with = "crate::util::url_as_string")]
licenses: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
login: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
logout: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
sync: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
index: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
joboffer_overview: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
joboffer_create: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
joboffer_summary: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
joboffers_delete_expired: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
joboffers_bulk_delete: Url,
}
impl StaticRoutes {
fn new(req: &HttpRequest) -> Result<Self, UrlGenerationError> {
let licenses_route = req.url_for_static(LICENSES_ROUTE)?;
let login_route = req.url_for_static(LOGIN_ROUTE)?;
let logout_route = req.url_for_static(LOGOUT_ROUTE)?;
let sync_route = req.url_for_static(JOBOFFER_SYNC_ROUTE)?;
let index_route = req.url_for_static(INDEX_ROUTE)?;
let joboffer_overview_route = req.url_for_static(JOBOFFER_OVERVIEW_ROUTE)?;
let joboffer_creation_route = req.url_for_static(JOBOFFER_CREATION_ROUTE)?;
let joboffer_summary_route = req.url_for_static(JOBOFFER_SUMMARY_ROUTE)?;
let joboffers_delete_expired_route = req.url_for_static(JOBOFFER_DELETE_EXPIRED_ROUTE)?;
let joboffers_bulk_delete_route = req.url_for_static(JOBOFFER_BULK_DELETE_ROUTE)?;
Ok(StaticRoutes {
licenses: licenses_route,
login: login_route,
logout: logout_route,
sync: sync_route,
index: index_route,
joboffer_overview: joboffer_overview_route,
joboffer_create: joboffer_creation_route,
joboffer_summary: joboffer_summary_route,
joboffers_delete_expired: joboffers_delete_expired_route,
joboffers_bulk_delete: joboffers_bulk_delete_route,
})
}
}
#[derive(Serialize)]
struct StaticRoutes {
#[serde(serialize_with = "crate::route::url_as_string")]
licenses: Url,
#[serde(serialize_with = "crate::route::url_as_string")]
login: Url,
#[serde(serialize_with = "crate::route::url_as_string")]
logout: Url,
#[serde(serialize_with = "crate::route::url_as_string")]
sync: Url,
#[serde(serialize_with = "crate::route::url_as_string")]
index: Url,
#[serde(serialize_with = "crate::route::url_as_string")]
joboffer_overview: Url,
#[serde(serialize_with = "crate::route::url_as_string")]
joboffer_create: Url,
#[serde(serialize_with = "crate::route::url_as_string")]
joboffer_summary: Url,
#[serde(serialize_with = "crate::route::url_as_string")]
joboffers_delete_expired: Url,
}
fn url_as_string<S>(url: &Url, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(url.as_str())
}
fn urls_as_string<S>(urls: &[Url], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
urls.iter()
.map(|elem| elem.as_str())
.collect::<Vec<_>>()
.serialize(serializer)
struct Link<'a> {
title: &'a str,
url: &'a str,
}
fn base<'a>(
req: &HttpRequest,
config: &ServerConfig,
config: &'a ServerConfig,
title: &'a str,
) -> Result<BaseData<'a>, UrlGenerationError> {
let licenses_route = req.url_for_static(LICENSES_ROUTE)?;
let login_route = req.url_for_static(LOGIN_ROUTE)?;
let logout_route = req.url_for_static(LOGOUT_ROUTE)?;
let sync_route = req.url_for_static(JOBOFFER_SYNC_ROUTE)?;
let index_route = req.url_for_static(INDEX_ROUTE)?;
let joboffer_overview_route = req.url_for_static(JOBOFFER_OVERVIEW_ROUTE)?;
let joboffer_creation_route = req.url_for_static(JOBOFFER_CREATION_ROUTE)?;
let joboffer_summary_route = req.url_for_static(JOBOFFER_SUMMARY_ROUTE)?;
let joboffers_delete_expired_route = req.url_for_static(JOBOFFER_DELETE_EXPIRED_ROUTE)?;
let index_css = req.url_for_static(INDEX_CSS_ROUTE)?;
let index_css = req.url_for_static(INDEX_CSS_ROUTE)?.into();
let mut default_links = vec![(
"Impressum",
Some("https://www.fs-infmath.uni-kiel.de/wiki/Fachschaften_Informatik_%26_Mathematik:Impressum"),
),
(
"Homepage",
crate::PROJECT_HOMEPAGE
),
(
"Source Repository",
crate::PROJECT_REPO,
)
];
#[allow(clippy::needless_collect)]
// the collect is necessary to end the borrow of borrows default_links by the map closure
let links = config
.config
.footer_links
.iter()
.map(|elem| {
default_links.retain(|&(title, _)| title != elem.title);
(elem.title.as_str(), elem.url.as_deref())
})
.collect::<Vec<_>>();
let links: Vec<_> = default_links
.into_iter()
.chain(links.into_iter())
.filter_map(|(title, url)| url.map(|url| Link { title, url }))
.collect();
let dev_available = cfg!(feature = "dev_mode");
let data = BaseData {
title: title.into(),
short_lang: "de".into(),
styles: vec![index_css],
links: BaseLinks {
impress: "https://www.fs-infmath.uni-kiel.de/wiki/Fachschaften_Informatik_%26_Mathematik:Impressum".into(),
repository: crate::PROJECT_REPO.map(Into::into),
homepage: crate::PROJECT_HOMEPAGE.map(Into::into)
},
routes: StaticRoutes {
licenses: licenses_route,
login: login_route,
logout: logout_route,
sync: sync_route,
index:index_route,
joboffer_overview: joboffer_overview_route,
joboffer_create: joboffer_creation_route,
joboffer_summary: joboffer_summary_route,
joboffers_delete_expired: joboffers_delete_expired_route
},
links,
routes: StaticRoutes::new(req)?,
banner: config.config.banner.clone(),
operation_mode: config.args.mode.clone(),
dev_build: dev_available,

View file

@ -3,10 +3,10 @@ use actix_web::web::ServiceConfig;
use actix_web::{get, http, post, web, HttpRequest, HttpResponse, Responder};
use handlebars::Handlebars;
use log::{error, trace};
use serde::{Deserialize, Deserializer, Serialize};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::error::{AuthenticationError, PresentationError};
use crate::error::{LoginResponseError, PresentationError};
use crate::route::HTML_CONTENT;
use crate::{template, ServerConfig};
@ -28,17 +28,10 @@ pub(crate) const LOGIN_ROUTE: &str = "login";
#[derive(Serialize, Deserialize)]
pub(crate) struct LoginQuery {
return_to: Option<String>,
#[serde(deserialize_with = "boolean_from_flag", default)]
#[serde(deserialize_with = "crate::util::boolean_from_flag", default)]
retry: bool,
}
fn boolean_from_flag<'de, D>(des: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
<Option<String> as Deserialize>::deserialize(des).map(|option| option.is_some())
}
#[get("/login", name = "login")]
pub(crate) async fn login_get(
req: HttpRequest,
@ -80,7 +73,7 @@ pub(crate) async fn login_post(
query: web::Query<LoginQuery>,
config: web::Data<ServerConfig>,
session: Session,
) -> Result<HttpResponse, AuthenticationError> {
) -> Result<HttpResponse, LoginResponseError> {
let result = crate::auth::User::login(&form.username, &form.password, &session, &config).await;
match result {
@ -106,7 +99,7 @@ pub(crate) async fn login_post(
}
Err(err) => {
error!("failed to perform authentication for login: {}", err);
Err(err)
Err(err.into())
}
}
}

293
src/route/error_handler.rs Normal file
View file

@ -0,0 +1,293 @@
use crate::auth::User;
use crate::error::PresentationError;
use crate::job_offers::error::SaveResponseError;
use crate::route::LOGIN_ROUTE;
use crate::{route, ServerConfig};
use crate::route::job_offer::edit::EditResponseError;
use crate::route::job_offer::error::FormProcessingError;
use crate::route::job_offer::error::{
ConfirmationResponseError, DeletionResponseError, SubmissionResponseError, SyncResponseError,
};
use actix_session::SessionExt;
use actix_web::dev::ServiceResponse;
use actix_web::error::UrlGenerationError;
use actix_web::middleware::ErrorHandlerResponse;
use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, ResponseError};
use handlebars::Handlebars;
use http::header::LOCATION;
use http::Method;
use lettre::address::AddressError;
use log::{error, warn};
use multipart_helper::MultipartFieldError;
use serde_json::json;
use thiserror::private::DisplayAsDisplay;
use url::Url;
#[derive(Debug, thiserror::Error)]
#[error("Some error occurred while attempting to display an error page")]
pub struct ErrorHandlerResponseError;
impl ResponseError for ErrorHandlerResponseError {}
pub(crate) fn generic_server_error_handler<B>(
res: ServiceResponse,
template: &str,
title: &str,
msg: Option<&str>,
) -> Result<ErrorHandlerResponse<B>, actix_web::Error> {
let hb: &Data<Handlebars> = res.request().app_data().ok_or(ErrorHandlerResponseError)?;
let config: &Data<ServerConfig> = res.request().app_data().ok_or(ErrorHandlerResponseError)?;
let base = route::base(res.request(), config, title)?;
let session = res.get_session();
let user = User::current(&session).ok();
let data = json!({
"base": base,
"user": user,
"msg": msg,
});
let body = hb
.render(template, &data)
.map_err(PresentationError::Render)?;
let response = HttpResponse::with_body(res.status(), body)
.map_into_boxed_body()
.map_into_right_body();
let response = res.into_response(response);
Ok(ErrorHandlerResponse::Response(response))
}
pub(crate) fn internal_server_error_handler<B>(
res: ServiceResponse,
) -> Result<ErrorHandlerResponse<B>, actix_web::Error> {
if let Some(err) = res.response().error() {
if let Some(err) = err.as_error::<SaveResponseError>() {
error!("Internal Server Error due to SaveError: {}", err)
} else if let Some(err) = err.as_error::<DeletionResponseError>() {
error!("Internal Server Error due to DeleteError: {}", err)
} else if let Some(err) = err.as_error::<ErrorHandlerResponseError>() {
error!("Internal Server Error due to ErrorHandlerError: {}", err)
} else if let Some(err) = err.as_error::<PresentationError>() {
error!("Internal Server Error due to PresentationError: {}", err)
} else if let Some(err) = err.as_error::<SubmissionResponseError>() {
error!("Internal Server Error due to SubmissionError: {}", err)
} else if let Some(err) = err.as_error::<EditResponseError>() {
error!("Internal Server Error due to EditResponseError: {}", err)
} else {
error!("Unknown Error Type for Internal Server Error: {}", err)
}
}
// we only generate a generic error page as we don't want to (accidentally) leak interna
generic_server_error_handler(res, "error/500", "Internal Server Error", None)
}
pub(crate) fn not_found_error_handler<B>(
res: ServiceResponse,
) -> Result<ErrorHandlerResponse<B>, actix_web::Error> {
generic_server_error_handler(res, "error/404", "Not Found", None)
}
pub(crate) fn too_many_requests<B>(
res: ServiceResponse,
) -> Result<ErrorHandlerResponse<B>, actix_web::Error> {
generic_server_error_handler(res, "error/429", "Too Many Requests", None)
}
pub(crate) fn bad_request<B>(
res: ServiceResponse,
) -> Result<ErrorHandlerResponse<B>, actix_web::Error> {
let msg;
let msg = if let Some(err) = res.response().error() {
if let Some(err) = err.as_error::<SubmissionResponseError>() {
match err {
SubmissionResponseError::MissingLinkOrAttachment => {
Some("Es muss mindestens ein Link oder ein Anhang angegeben werden.")
}
SubmissionResponseError::Form(FormProcessingError::InvalidHash) => {
Some("Der Hash entspricht nicht dem erwarteten Format!")
}
SubmissionResponseError::Form(FormProcessingError::Date { err:_ }|FormProcessingError::Datetime {err:_}) => {
Some("Eine Datumsangabe entsprach nicht dem erwarteten Format.")
}
SubmissionResponseError::Form(FormProcessingError::Field(MultipartFieldError::ContentTooLarge {
field,
max_byte_size,
})) => {
msg = format!(
"Der Inhalt des Feldes mit ID {field} war zu lang, maximal {max_byte_size} Bytes sind gestated."
);
Some(msg.as_str())
}
SubmissionResponseError::Form(FormProcessingError::Field(MultipartFieldError::TooManyOccurrences { field, limit })) => {
msg = format!(
"Das Feld mit der ID {field} wurde zu oft vorhanden, erlaubt sind {limit} vorkommen."
);
Some(msg.as_str())
}
SubmissionResponseError::Form(FormProcessingError::MultiPart(mpe)|FormProcessingError::Field(MultipartFieldError::Multipart(mpe))) => {
warn!("{}", mpe);
msg = format!("{}", mpe);
Some(msg.as_str())
}
SubmissionResponseError::Form(FormProcessingError::Field(MultipartFieldError::NotAFile { field })) => {
msg = format!(
"Das Feld mit der ID {field} erwartet eine Datei, aber der zugehörige ContentDisposition-Header enthielt keinen Dateinamen."
);
Some(msg.as_str())
}
SubmissionResponseError::Form(FormProcessingError::MissingField { field }) => {
msg =
format!("Das Feld mit der ID {field} fehlt obwohl es nicht optional ist.");
Some(msg.as_str())
}
SubmissionResponseError::Form(FormProcessingError::Field(MultipartFieldError::UTF8Error(_err))) => {
Some("Ein Feld das im UTF-8 format erwartet wurde konnte nicht als UTF-8 geparst werden.")
}
SubmissionResponseError::Form(FormProcessingError::InvalidAddress(reason)) => {
Some(match reason {
AddressError::MissingParts => { "Unvollständige E-Mail Address" }
AddressError::Unbalanced => { "Unausgeglichene Klammern '<' & '>' in E-Mail Address. " }
AddressError::InvalidUser => { "Der Local/User Teil (vor dem @) der E-Mail Address ist ungültig." }
AddressError::InvalidDomain => { "Der Host/Domain Teil (nach dem @) der E-Mail Address ist ungültig." }
})
}
SubmissionResponseError::Save(_)
| SubmissionResponseError::TooManyRequests
| SubmissionResponseError::Render(_)
| SubmissionResponseError::Form(FormProcessingError::Field(MultipartFieldError::IOError(_)))=> {
error!(
"Response Status Code (Bad Request) and Error appear to disagree : {}",
err
);
None
}
}
} else if let Some(err) = err.as_error::<ConfirmationResponseError>() {
match err {
ConfirmationResponseError::InvalidRequest => {
Some("Die Stellenanzeige erwartet keine Bestätigung, die Stellenanzeigen ID ist ungültig oder der Bestätigungstoken is ungültig.")
}
ConfirmationResponseError::Save(_)
| ConfirmationResponseError::Delete(_)
| ConfirmationResponseError::SuccessRenderError(_)
| ConfirmationResponseError::RenderError(_)
| ConfirmationResponseError::Url(_)
| ConfirmationResponseError::Presentation(_) => {
error!(
"Response Status Code (Bad Request) and Error appear to disagree : {}",
err
);
None
}
}
} else if let Some(err) = err.as_error::<DeletionResponseError>() {
match err {
DeletionResponseError::Presentation(err) => {
warn!("Failed to present deletion response: {}", err);
Some("Failed to generate response.")
}
DeletionResponseError::Delete(err) => {
warn!("Couldn't delete Job Offer: {}", err);
Some("Could not delete a Job Offer")
}
DeletionResponseError::Login(_) => {
error!(
"Response Status Code (Bad Request) and Error appear to disagree : {}",
err
);
None
}
}
} else {
warn!("Bad Request Error of unknown type!: {}", err);
None
}
} else {
warn!("Bad Request does not have an associated error!");
None
};
generic_server_error_handler(res, "error/400", "Bad Request", msg)
}
fn login_url_with_return(req: &HttpRequest, return_to: &str) -> Result<Url, UrlGenerationError> {
let mut login_url = req.url_for_static(LOGIN_ROUTE)?;
login_url
.query_pairs_mut()
.append_pair("return_to", return_to);
Ok(login_url)
}
pub(crate) fn unauthorized_error_handler<B>(
res: ServiceResponse,
) -> Result<ErrorHandlerResponse<B>, actix_web::Error> {
if let Some(return_url) = res
.response()
.error()
.and_then(|err| {
err.as_error::<SaveResponseError>()
.and_then(|elem| match elem {
SaveResponseError::Login(login) => Some(login),
_ => None,
})
.or_else(|| {
err.as_error::<DeletionResponseError>()
.and_then(|elem| match elem {
DeletionResponseError::Login(login) => Some(login),
_ => None,
})
})
.or_else(|| {
err.as_error::<SyncResponseError>()
.and_then(|elem| match elem {
SyncResponseError::Login(login) => Some(login),
_ => None,
})
})
})
.and_then(|err| err.get_return())
{
// we have a LoginRequired error type with a return_to URL set, redirect the user to the login page with the
// return_to url parameter set
let login_url = login_url_with_return(res.request(), return_url.as_str())?;
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, login_url.as_str()))
.body("")
.map_into_boxed_body()
.map_into_right_body();
Ok(ErrorHandlerResponse::Response(res.into_response(response)))
} else if res.request().method() == Method::GET {
// for a get request we can just return the user to the originally requested page after login,
// so redirect them to the login page and set the return_to url parameter to the target of the current request
let req_uri = res.request().uri().as_display().to_string();
let login_url = login_url_with_return(res.request(), &req_uri)?;
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, login_url.as_str()))
.body("")
.map_into_boxed_body()
.map_into_right_body();
Ok(ErrorHandlerResponse::Response(res.into_response(response)))
} else {
// we have neither a known return path nor is the current request of method get, which we could retry after login
// we do not want to keep the 401 Unauthorized status-code as we will not set the WWW-Authenticate header
// and the standard requires 401 responses to carry that header.
let (req, res) = res.into_parts();
let res = ServiceResponse::new(req, HttpResponse::BadRequest().body(res.into_body()));
generic_server_error_handler(res, "error/401", "Unauthorized", None)
}
}

View file

@ -0,0 +1,73 @@
use crate::auth::User;
pub(crate) const MB: usize = 1024 * 1024;
pub(crate) const SKIP: &str = "skip";
pub(crate) const VISIBLE: &str = "visible";
pub(crate) const APPROVED: &str = "approved";
pub(crate) const PERMANENT: &str = "permanent";
// for html date inputs
pub(crate) const DATE_FORMAT: &str = "YYYY-MM-DD";
// for html datetime-local inputs
pub(crate) const DATETIME_FORMAT: &str = "YYYY-MM-DDThh:mm";
pub(crate) const TITLE: &str = "title";
pub(crate) const OFFERING_PARTY: &str = "offering_party";
pub(crate) const OFFERING_CONTACT: &str = "offer-contact";
pub(crate) const OFFERING_CONTACT_VISIBILITY: &str = "offer-contact-visible";
pub(crate) const EXPIRY_DATE: &str = "offer_expiry";
pub(crate) const PRE_APPROVED: &str = "pre_approved";
pub(crate) const SKIP_CONFIRMATION: &str = "skip_confirmation";
pub(crate) const PERMANENT_FIELD: &str = "permanent";
pub(crate) const ATTACHMENT_TITLES: &str = "file_title[]";
pub(crate) const ATTACHMENT_FILES: &str = "file[]";
pub(crate) const LINK_TITLES: &str = "link_title[]";
pub(crate) const LINK_URLS: &str = "link_url[]";
pub(crate) const BACKDATE: &str = "backdate";
// hash is a u64 so this should be plenty to fit all possible u64 values
pub(crate) const MAX_HASH_LEN: usize = 200;
pub(crate) const MAX_TITLE_LEN: usize = 512;
pub(crate) const MAX_OFFERING_PARTY_LEN: usize = 512;
/// Technically the local part can be 64 octets and the domain part can be 254 octets
/// resulting in 320 octets including the `@` see RFC 2821
/// Apparently somewhere in the RFC the MAIl and RCPT commands have a limit on addresses of 256 octets (RFC 2821) including a pair of angle brackets <>,
/// I could not find that my self, but a tweets length of email address should be sufficient either way.
pub(crate) const MAX_CONTACT_LEN: usize = 254;
pub(crate) const MAX_LINK_TITLE_LEN: usize = 512;
pub(crate) const MAX_LINK_URL_LEN: usize = 2048;
pub(crate) const MAX_ATTACHMENT_TITLE_LEN: usize = 128;
pub(crate) const MAX_LINK_COUNT: Option<u8> = Some(20);
pub(crate) const ATTACHMENT_FIELD_COUNT: usize = 4;
pub(crate) const LINK_FIELD_COUNT: usize = 4;
pub(crate) struct UploadLimits {
pub(crate) size: usize,
pub(crate) count: Option<u8>,
}
pub(crate) const fn upload_limits(user: Option<&User>) -> UploadLimits {
if user.is_some() {
UploadLimits {
size: 20 * MB,
count: None,
}
} else {
UploadLimits {
size: 5 * MB,
count: Some(8),
}
}
}
pub(crate) const HASH: &str = "hash";
pub(crate) const ATTACHMENT_FILENAME_EDIT_FIELD: &str = "file_name_edit[]";
pub(crate) const ATTACHMENT_TITLE_EDIT_FIELD: &str = "file_title_edit[]";
pub(crate) const ATTACHMENT_FILE_REPLACE_FIELD: &str = "file_replace[]";
pub(crate) const DELETE_ATTACHMENT_FIELD: &'static str = "delete_attachment[]";

View file

@ -3,24 +3,28 @@ use actix_session::Session;
use actix_web::{
get, http::header, post, web, web::ServiceConfig, HttpRequest, HttpResponse, Responder, Result,
};
use error::AttachmentError;
use error::AttachmentResponseError;
use handlebars::Handlebars;
use http::header::CONTENT_TYPE;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::json;
pub(crate) mod action;
pub(crate) mod confirmation;
pub(crate) mod create;
pub(crate) mod delete;
pub(crate) mod edit;
pub(crate) mod error;
pub(crate) mod review;
use crate::auth::User;
use crate::error::PresentationError;
use crate::job_offers::view::JobOfferViewData;
use crate::job_offers::JobOffers;
use crate::route::job_offer::error::SyncError;
use crate::route::job_offer::error::SyncResponseError;
use crate::route::{HTML_CONTENT, JSON_CONTENT};
use crate::server_config::ServerConfig;
use crate::{auth, template};
use crate::template;
use crate::util::SerializableUrl;
pub fn configure(service: &mut ServiceConfig) {
service
@ -31,10 +35,13 @@ pub fn configure(service: &mut ServiceConfig) {
.service(confirmation::confirm_joboffer_get)
.service(confirmation::confirm_joboffer_post)
.service(confirmation::reject_joboffer_post)
.service(action::delete_joboffer)
.service(action::delete_expired_joboffers)
.service(action::review_joboffer)
.service(action::unpublish_joboffer)
.service(delete::delete_joboffer)
.service(delete::bulk_delete)
.service(edit::edit_joboffer_get)
.service(edit::edit_joboffer_post)
.service(delete::delete_expired_joboffers)
.service(review::review_joboffer)
.service(review::unpublish_joboffer)
.service(job_offer_attachment)
.service(preview_attachment)
.service(sync);
@ -50,11 +57,11 @@ pub(crate) async fn index(
hb: web::Data<Handlebars<'_>>,
offers: web::Data<JobOffers>,
) -> Result<HttpResponse, PresentationError> {
let user = auth::User::current(&session).ok();
let user = User::current(&session).ok();
let job_offers = {
let guard = offers.get_offers().await;
crate::job_offers::job_data(&req, guard, user.as_ref())
crate::job_offers::job_data(&req, guard.iter(), user.as_ref())
};
let base_data = super::base(&req, &config, "Joboffers")?;
@ -67,7 +74,7 @@ pub(crate) async fn index(
let rendered = hb.render(template::JOBOFFEL_OVERVIEW, &data)?;
Ok(HttpResponse::Ok()
.insert_header((http::header::CONTENT_TYPE, HTML_CONTENT.clone()))
.insert_header((CONTENT_TYPE, HTML_CONTENT.clone()))
.body(rendered))
}
@ -78,22 +85,28 @@ pub(crate) struct MaybeToken {
pub(crate) const JOBOFFER_ATTACHMENT_ROUTE: &str = "job_offer_attachment";
#[derive(Deserialize)]
pub(crate) struct AttachmentPathData {
id: String,
attachment: String,
}
#[get("/{id}/attachment/{attachment:.*}", name = "job_offer_attachment")]
pub(crate) async fn job_offer_attachment(
req: HttpRequest,
path: web::Path<(String, String)>,
path: web::Path<AttachmentPathData>,
query: web::Query<MaybeToken>,
config: web::Data<ServerConfig>,
session: Session,
offers: web::Data<JobOffers>,
) -> Result<impl Responder, AttachmentError> {
let id = &path.0;
let attachment_name = path.1.as_str();
) -> Result<impl Responder, AttachmentResponseError> {
let id = &path.id;
let attachment_name = path.attachment.as_str();
let offer = offers
.get_offer(id)
.await
.ok_or(AttachmentError::OfferNotFound)?;
.ok_or(AttachmentResponseError::OfferNotFound)?;
if !offer.is_published()
&& !query
@ -126,7 +139,7 @@ pub(crate) async fn preview_attachment(
session: Session,
hb: web::Data<Handlebars<'_>>,
) -> Result<HttpResponse, PresentationError> {
let user = auth::User::current(&session).ok();
let user = User::current(&session).ok();
let data = json!({
"base": super::base(&req,&config, "Joboffers")?,
@ -136,7 +149,7 @@ pub(crate) async fn preview_attachment(
let body = hb.render(template::JOBOFFER_ATTACHMENT_PREVIEW, &data)?;
Ok(HttpResponse::Ok()
.insert_header((http::header::CONTENT_TYPE, HTML_CONTENT.clone()))
.insert_header((CONTENT_TYPE, HTML_CONTENT.clone()))
.body(body))
}
@ -148,12 +161,25 @@ pub(crate) async fn summary(
session: Session,
offers: web::Data<JobOffers>,
) -> Result<HttpResponse, actix_web::Error> {
let user = auth::User::current(&session).ok();
let user = User::current(&session).ok();
let previews = {
let guard = offers.get_offers().await;
crate::job_offers::job_data(&req, guard, user.as_ref())
crate::job_offers::job_data(&req, guard.iter(), user.as_ref())
};
let data = json!(previews);
#[derive(Serialize)]
struct SummaryData {
version: &'static str,
entries: Vec<JobOfferViewData>,
overview: SerializableUrl,
}
let data = json!(SummaryData {
version: "1",
entries: previews,
overview: SerializableUrl(req.url_for_static(JOBOFFER_OVERVIEW_ROUTE)?)
});
Ok(HttpResponse::Ok()
.insert_header((CONTENT_TYPE, JSON_CONTENT.clone()))
.body(data.to_string()))
@ -167,10 +193,10 @@ pub(crate) async fn sync(
session: Session,
config: web::Data<ServerConfig>,
offers: web::Data<JobOffers>,
) -> Result<HttpResponse, SyncError> {
) -> Result<HttpResponse, SyncResponseError> {
// TODO return the user to a page where they are asked to confirm syncing
// aka. the get variant of this route
auth::User::current(&session)?;
User::current(&session)?;
offers.sync(&config).await?;
let dest = req
.url_for_static(JOBOFFER_OVERVIEW_ROUTE)

View file

@ -5,9 +5,9 @@ use serde::Serialize;
use serde_json::json;
use crate::auth::User;
use crate::job_offers::JobOfferData;
use crate::route::job_offer::error::ConfirmationError;
use crate::route::job_offer::error::ConfirmationError::SuccessRenderError;
use crate::job_offers::view::JobOfferViewData;
use crate::route::job_offer::error::ConfirmationResponseError;
use crate::route::job_offer::error::ConfirmationResponseError::SuccessRenderError;
use crate::route::HTML_CONTENT;
use crate::{get, template, JobOffers, ServerConfig};
@ -18,9 +18,9 @@ struct ConfirmActions {
}
#[derive(Serialize)]
struct ConfirmJobOfferData {
struct ConfirmJobOfferViewData {
#[serde(flatten)]
preview: JobOfferData,
preview: JobOfferViewData,
actions: ConfirmActions,
is_reviewed: bool,
}
@ -35,17 +35,17 @@ pub(crate) async fn confirm_joboffer_get(
config: web::Data<ServerConfig>,
offers: web::Data<JobOffers>,
path: web::Path<(String, String)>,
) -> actix_web::Result<HttpResponse, ConfirmationError> {
) -> actix_web::Result<HttpResponse, ConfirmationResponseError> {
let id: &String = &path.0;
let req_token = &path.1;
if let Some(job_offer) = offers.get_offer(id).await {
if !job_offer.check_confirmation_token(req_token) {
Err(ConfirmationError::InvalidRequest)
Err(ConfirmationResponseError::InvalidRequest)
} else {
let user = User::current(&session).ok();
let job_offer = ConfirmJobOfferData {
let job_offer = ConfirmJobOfferViewData {
preview: job_offer.to_preview_data(id, &req, Some(req_token))?,
actions: ConfirmActions {
confirm_url: req
@ -66,14 +66,14 @@ pub(crate) async fn confirm_joboffer_get(
let body = hb
.render(template::JOBOFFER_CONFIRM_SUBMISSION, &data)
.map_err(ConfirmationError::RenderError)?;
.map_err(ConfirmationResponseError::RenderError)?;
Ok(HttpResponse::Ok()
.insert_header((http::header::CONTENT_TYPE, HTML_CONTENT.clone()))
.body(body))
}
} else {
Err(ConfirmationError::InvalidRequest)
Err(ConfirmationResponseError::InvalidRequest)
}
}
@ -88,7 +88,7 @@ pub(crate) async fn confirm_joboffer_post(
path: web::Path<(String, String)>,
offers: web::Data<JobOffers>,
config: web::Data<ServerConfig>,
) -> actix_web::Result<HttpResponse, ConfirmationError> {
) -> actix_web::Result<HttpResponse, ConfirmationResponseError> {
let id = &path.0;
let req_token = &path.1;
@ -113,10 +113,10 @@ pub(crate) async fn confirm_joboffer_post(
.insert_header((http::header::CONTENT_TYPE, HTML_CONTENT.clone()))
.body(body))
}
Err(()) => Err(ConfirmationError::InvalidRequest),
Err(()) => Err(ConfirmationResponseError::InvalidRequest),
}
} else {
Err(ConfirmationError::InvalidRequest)
Err(ConfirmationResponseError::InvalidRequest)
}
}
@ -130,7 +130,7 @@ pub(crate) async fn reject_joboffer_post(
hb: web::Data<Handlebars<'_>>,
offers: web::Data<JobOffers>,
config: web::Data<ServerConfig>,
) -> actix_web::Result<HttpResponse, ConfirmationError> {
) -> actix_web::Result<HttpResponse, ConfirmationResponseError> {
let id = &path.0;
let req_token = &path.1;
@ -154,9 +154,9 @@ pub(crate) async fn reject_joboffer_post(
.insert_header((http::header::CONTENT_TYPE, HTML_CONTENT.clone()))
.body(body))
} else {
Err(ConfirmationError::InvalidRequest)
Err(ConfirmationResponseError::InvalidRequest)
}
} else {
Err(ConfirmationError::InvalidRequest)
Err(ConfirmationResponseError::InvalidRequest)
}
}

View file

@ -11,28 +11,36 @@ use lettre::message::{Mailbox, SinglePart};
use lettre::{Address, AsyncTransport};
use log::{debug, error, warn};
use rand::distributions::DistString;
use serde::Serialize;
use serde_json::json;
use tempfile::NamedTempFile;
use url::Url;
use crate::auth::User;
use crate::error::{MultipartFieldError, PresentationError};
use crate::job_offers::error::{EmailError, SaveError};
use crate::error::{LoginRequired, PresentationError};
use crate::job_offers::error::{EmailError, SaveResponseError};
use crate::job_offers::lease::SubmissionLimiter;
use crate::job_offers::{
Attachment, ConfirmationStatus, JobOffer, JobOfferStatus, JobOffers, Link, MutBorrowedJobOffer,
ReviewStatus,
};
use crate::route::form_constants::{self, UploadLimits};
use crate::route::job_offer::confirmation::JOBOFFER_CONFIRM_ROUTE;
use crate::route::job_offer::create::multipart_form::{multi_field, multi_file, once_field};
use crate::route::job_offer::error::SubmissionError;
use crate::route::job_offer::error::{FormProcessingError, SubmissionResponseError};
use crate::route::HTML_CONTENT;
use crate::server_config::{EmailConfig, ServerConfig};
use crate::{template, SubmissionLimiter};
mod multipart_form;
use crate::template;
use crate::util::{parse_date, parse_datetime, process_links, process_new_attachments};
use multipart_helper::{multi_field, multi_file, once_field};
pub(crate) const JOBOFFER_CREATION_ROUTE: &str = "create_offer";
#[derive(Serialize)]
struct SubmissionFormRenderData {
attachments: Vec<()>,
links: Vec<()>,
}
#[get("/new", name = "create_offer")]
pub(crate) async fn create_joboffer_get(
req: HttpRequest,
@ -42,9 +50,15 @@ pub(crate) async fn create_joboffer_get(
) -> Result<HttpResponse, PresentationError> {
let user = User::current(&session).ok();
let form_data = SubmissionFormRenderData {
attachments: vec![(); form_constants::ATTACHMENT_FIELD_COUNT],
links: vec![(); form_constants::LINK_FIELD_COUNT],
};
let data = json!({
"base": crate::route::base(&req, &config,"Create Joboffer")?,
"user": user,
"base": crate::route::base(&req, &config,"Create Joboffer")?,
"user": user,
"form": form_data,
});
let rendered = hb.render(template::JOBOFFER_CREATE, &data)?;
@ -62,28 +76,31 @@ pub(crate) async fn create_joboffer_post(
limiter: web::Data<SubmissionLimiter>,
hb: web::Data<Handlebars<'_>>,
multipart: Multipart,
) -> Result<HttpResponse, SubmissionError> {
) -> Result<HttpResponse, SubmissionResponseError> {
let user = User::current(&session).ok();
debug!("getting lease for new submission");
let submission_lease = {
let con_inf = req.connection_info();
// we expect to be run behind a reverse proxy so we can trust the Forwarded/X-Forwarded-For header if they are set
let forwarded_for = con_inf
.realip_remote_addr()
.expect("should be able to determine a remote address");
limiter
.into_inner()
.get_submission_lease(
IpAddr::from_str(forwarded_for).expect("remote address is a valid ip address"),
)
.await
let ip = {
// we expect to be run behind a reverse proxy so we can trust the Forwarded/X-Forwarded-For header if they are set
let con_inf = req.connection_info();
let forwarded_for = {
con_inf
.realip_remote_addr()
.expect("should be able to determine a remote address")
};
IpAddr::from_str(forwarded_for).expect("remote address should be a valid ip address")
};
limiter.into_inner().get_submission_lease(ip).await
};
if submission_lease.is_none() && user.is_none() {
debug!("failed to get a lease too many requests");
// the user is not authenticated and has reached the quota for un-authenticated users
return Err(SubmissionError::TooManyRequests);
return Err(SubmissionResponseError::TooManyRequests);
}
debug!("got lease, starting to process submission");
@ -109,7 +126,7 @@ pub(crate) async fn create_joboffer_post(
// submission failed end the lease immediately
lease.end().await
}
return Err(SubmissionError::MissingLinkOrAttachment);
return Err(SubmissionResponseError::MissingLinkOrAttachment);
}
debug!("validation successful, saving submitted job offer");
@ -160,13 +177,13 @@ pub(crate) struct JobOfferSubmitForm {
pub(crate) offering_party: String,
pub(crate) contact: Address,
pub(crate) public_contact: bool,
pub(crate) expires: Option<toml::value::Date>,
pub(crate) expires: Option<better_toml_datetime::Date>,
pub(crate) attachments: Vec<Attachment<NamedTempFile>>,
pub(crate) links: Vec<Link>,
pub(crate) pre_approved: bool,
pub(crate) skip_confirmation: bool,
pub(crate) permanent: bool,
pub(crate) backdate: Option<toml::value::Datetime>,
pub(crate) backdate: Option<better_toml_datetime::Datetime>,
}
pub(crate) async fn create_job_offer<'data, 'config>(
@ -176,42 +193,56 @@ pub(crate) async fn create_job_offer<'data, 'config>(
offers: &'data JobOffers,
config: &'config ServerConfig,
user: Option<&User>,
) -> Result<MutBorrowedJobOffer<'data, 'static, 'config>, SaveError> {
) -> Result<MutBorrowedJobOffer<'data, 'static, 'config>, SaveResponseError> {
let now_date = crate::util::now();
let token: String =
rand::distributions::Alphanumeric.sample_string(&mut rand::thread_rng(), 64);
if user.is_none()
&& (job_offer_form.backdate.is_some()
|| job_offer_form.skip_confirmation
|| job_offer_form.pre_approved
|| job_offer_form.permanent)
{
// a reviewer-only option is set, but user is not logged-in as a reviewer
// maybe a session just expired or some one is messing with the form
return Err(SaveResponseError::Login(LoginRequired::new()));
}
let submission_datetime = user
.and(job_offer_form.backdate)
.unwrap_or_else(|| crate::util::chrono_datetime_to_toml_datetime(&now_date));
let is_permanent = user.is_some() && job_offer_form.permanent;
let review_status = if user.is_some() && job_offer_form.pre_approved {
ReviewStatus::Reviewed
} else {
ReviewStatus::AwaitingReview
};
let skip_confirmation = user.is_some() && job_offer_form.skip_confirmation;
let confirmation_status = if skip_confirmation {
ConfirmationStatus::Confirmed
} else {
ConfirmationStatus::AwaitingConfirmation {
token: token.clone(),
}
};
let job_offer = JobOffer {
title: job_offer_form.title,
offering_party: job_offer_form.offering_party,
public_contact_info: job_offer_form.public_contact,
contact_info: job_offer_form.contact.to_owned(),
date_of_submission: submission_datetime,
date_of_expiry: job_offer_form.expires.map(crate::job_offers::Date),
permanent: user.is_some() && job_offer_form.permanent,
date_of_expiry: job_offer_form.expires,
permanent: is_permanent,
attachments: job_offer_form.attachments,
links: job_offer_form.links,
status: JobOfferStatus::new(
if user.is_some() && job_offer_form.pre_approved {
ReviewStatus::Reviewed
} else {
ReviewStatus::AwaitingReview
},
if skip_confirmation {
ConfirmationStatus::Confirmed
} else {
ConfirmationStatus::AwaitingConfirmation {
token: token.clone(),
}
},
),
status: JobOfferStatus::new(review_status, confirmation_status),
};
let created_offer = offers.create_new_offer(now_date, job_offer, config).await?;
@ -237,7 +268,7 @@ pub(crate) async fn create_job_offer<'data, 'config>(
#[derive(serde::Serialize)]
struct EmailData {
#[serde(serialize_with = "crate::route::url_as_string")]
#[serde(serialize_with = "crate::util::url_as_string")]
confirmation_link: Url,
}
@ -249,16 +280,13 @@ async fn send_confirmation_email(
) -> Result<(), EmailError> {
let to_mailbox = Mailbox::new(None, contact_address);
let email_body = hb
.render(template::EMAIL_PLAIN, &email_data)
.map_err(EmailError::from)?;
let email_body = hb.render(template::EMAIL_PLAIN, &email_data)?;
let message = lettre::Message::builder()
.from(email_config.from.to_owned())
.to(to_mailbox)
.subject(&email_config.subject)
.singlepart(SinglePart::plain(email_body))
.map_err(EmailError::from)?;
.singlepart(SinglePart::plain(email_body))?;
lettre::AsyncSendmailTransport::new().send(message).await?;
let message = lettre::Message::builder()
@ -291,7 +319,7 @@ impl JobOfferSubmitForm {
mut multipart: Multipart,
user: Option<&User>,
config: &ServerConfig,
) -> Result<Self, MultipartFieldError> {
) -> Result<Self, FormProcessingError> {
let mut title = None;
let mut offering_party = None;
let mut contact = None;
@ -306,100 +334,141 @@ impl JobOfferSubmitForm {
let mut skip_confirmation = None;
let mut permanent = None;
const MB: usize = 1024 * 1024;
const SKIP: &str = "skip";
const VISIBLE: &str = "visible";
const APPROVED: &str = "approved";
const PERMANENT: &str = "permanent";
const DATE_FORMAT: &str = "YYYY-MM-DD";
const DATETIME_FORMAT: &str = "YYYY-MM-DDThh:mm";
let (upload_size_limit, upload_count_limit) = if user.is_some() {
(20 * MB, None)
} else {
(5 * MB, Some(8))
};
let UploadLimits {
size: upload_size_limit,
count: upload_count_limit,
} = form_constants::upload_limits(user);
while let Some(item) = multipart.next().await {
let field = item?;
const TITLE: &str = "title";
const OFFERING_PARTY: &str = "offering_party";
const OFFERING_CONTACT: &str = "offer-contact";
const OFFERING_CONTACT_VISIBILITY: &str = "offer-contact-visible";
const EXPIRY_DATE: &str = "offer_expiry";
const PRE_APPROVED: &str = "pre_approved";
const SKIP_CONFIRMATION: &str = "skip_confirmation";
const PERMANENT_FIELD: &str = "permanent";
const ATTACHMENT_TITLES: &str = "file_title[]";
const ATTACHMENT_FILES: &str = "file[]";
const LINK_TITLES: &str = "link_title[]";
const LINK_URLS: &str = "link_url[]";
const BACKDATE: &str = "backdate";
match field.name() {
TITLE => once_field(field, TITLE, &mut title, 512).await?,
OFFERING_PARTY => {
once_field(field, OFFERING_PARTY, &mut offering_party, 512).await?
}
// Technically the local part can be 64 octets and the domain part can be 254 octets
// resulting in 320 octets including the `@` see RFC 2821
// Apparently somewhere in the RFC the MAIl and RCPT commands have a limit on addresses of 256 octets (RFC 2821) including a pair of angle brackets <>,
// I could not find that my self, but a tweets length of email address should be sufficient either way.
OFFERING_CONTACT => once_field(field, OFFERING_CONTACT, &mut contact, 254).await?,
OFFERING_CONTACT_VISIBILITY => {
form_constants::TITLE => {
once_field(
field,
OFFERING_CONTACT_VISIBILITY,
&mut public_contact,
VISIBLE.len(),
form_constants::TITLE,
&mut title,
form_constants::MAX_TITLE_LEN,
)
.await?
}
EXPIRY_DATE => {
once_field(field, EXPIRY_DATE, &mut expires, DATE_FORMAT.len()).await?
form_constants::OFFERING_PARTY => {
once_field(
field,
form_constants::OFFERING_PARTY,
&mut offering_party,
form_constants::MAX_OFFERING_PARTY_LEN,
)
.await?
}
PRE_APPROVED => {
once_field(field, PRE_APPROVED, &mut pre_approved, APPROVED.len()).await?
form_constants::OFFERING_CONTACT => {
once_field(
field,
form_constants::OFFERING_CONTACT,
&mut contact,
form_constants::MAX_CONTACT_LEN,
)
.await?
}
SKIP_CONFIRMATION => {
once_field(field, SKIP_CONFIRMATION, &mut skip_confirmation, SKIP.len()).await?
form_constants::OFFERING_CONTACT_VISIBILITY => {
once_field(
field,
form_constants::OFFERING_CONTACT_VISIBILITY,
&mut public_contact,
form_constants::VISIBLE.len(),
)
.await?
}
PERMANENT_FIELD => {
once_field(field, PERMANENT_FIELD, &mut permanent, PERMANENT.len()).await?
form_constants::EXPIRY_DATE => {
once_field(
field,
form_constants::EXPIRY_DATE,
&mut expires,
form_constants::DATE_FORMAT.len(),
)
.await?
}
ATTACHMENT_TITLES => {
form_constants::PRE_APPROVED => {
once_field(
field,
form_constants::PRE_APPROVED,
&mut pre_approved,
form_constants::APPROVED.len(),
)
.await?
}
form_constants::SKIP_CONFIRMATION => {
once_field(
field,
form_constants::SKIP_CONFIRMATION,
&mut skip_confirmation,
form_constants::SKIP.len(),
)
.await?
}
form_constants::PERMANENT_FIELD => {
once_field(
field,
form_constants::PERMANENT_FIELD,
&mut permanent,
form_constants::PERMANENT.len(),
)
.await?
}
form_constants::ATTACHMENT_TITLES => {
multi_field(
field,
ATTACHMENT_TITLES,
form_constants::ATTACHMENT_TITLES,
&mut attachment_titles,
128,
form_constants::MAX_ATTACHMENT_TITLE_LEN,
upload_count_limit,
)
.await?
}
ATTACHMENT_FILES => {
form_constants::ATTACHMENT_FILES => {
let result = multi_file(
field,
ATTACHMENT_FILES,
form_constants::ATTACHMENT_FILES,
&mut attachment_datas,
upload_size_limit,
upload_count_limit,
config,
&config.config.data_storage_path,
)
.await;
if let Err(err) = result {
error!("Failed to process file upload {:?}", err);
return Err(err);
return Err(err.into());
}
}
LINK_TITLES => {
multi_field(field, LINK_TITLES, &mut link_titles, 512, Some(20)).await?
form_constants::LINK_TITLES => {
multi_field(
field,
form_constants::LINK_TITLES,
&mut link_titles,
form_constants::MAX_LINK_TITLE_LEN,
form_constants::MAX_LINK_COUNT,
)
.await?
}
LINK_URLS => multi_field(field, LINK_URLS, &mut link_urls, 2048, Some(20)).await?,
BACKDATE => {
once_field(field, BACKDATE, &mut backdate, DATETIME_FORMAT.len()).await?;
form_constants::LINK_URLS => {
multi_field(
field,
form_constants::LINK_URLS,
&mut link_urls,
form_constants::MAX_LINK_URL_LEN,
form_constants::MAX_LINK_COUNT,
)
.await?
}
form_constants::BACKDATE => {
once_field(
field,
form_constants::BACKDATE,
&mut backdate,
form_constants::DATETIME_FORMAT.len(),
)
.await?;
}
name => {
warn!(
@ -413,67 +482,30 @@ impl JobOfferSubmitForm {
assert_eq!(attachment_titles.len(), attachment_datas.len());
let attachments = attachment_titles
.into_iter()
.zip(attachment_datas.into_iter())
.filter_map(|(title, (file_name, attachment_location))| {
attachment_location
.map(|attachment_location| (title, file_name, attachment_location))
})
.enumerate()
.map(
|(idx, (title, file_name, attachment_location))| Attachment {
title: if title.is_empty() {
format!("Attachment {}: {}", idx + 1, file_name)
} else {
title
},
file_name,
attachment_location,
},
)
.collect();
let attachments = process_new_attachments(attachment_titles, attachment_datas);
assert_eq!(link_titles.len(), link_urls.len());
let links = link_titles
.into_iter()
.zip(link_urls.into_iter())
.filter(|(_, url)| !url.is_empty())
.map(|(title, url)| Link {
title: if title.is_empty() { url.clone() } else { title },
destination: url,
})
.collect();
let links = process_links(link_titles, link_urls);
let contact = contact.ok_or(MultipartFieldError::MissingField("contact"))?;
let contact = contact.ok_or(FormProcessingError::MissingField { field: "contact" })?;
let expiry_date = match expires.as_deref() {
None | Some("") => None,
Some(expiry) => toml::value::Datetime::from_str(expiry)?.date,
};
let backdate_date = match backdate.as_deref() {
None | Some("") => None,
Some(backdate) => {
// toml currently requires time to include seconds, but the html field does not include them, so we just add them here
let backdate_with_sec = format!("{}:00", backdate);
Some(toml::value::Datetime::from_str(&backdate_with_sec)?)
}
};
let expiry_date = parse_date(expires.as_deref())?;
let backdate_date = parse_datetime(backdate.as_deref())?;
Ok(JobOfferSubmitForm {
title: title.ok_or(MultipartFieldError::MissingField("title"))?,
offering_party: offering_party
.ok_or(MultipartFieldError::MissingField("offering_party"))?,
title: title.ok_or(FormProcessingError::MissingField { field: "title" })?,
offering_party: offering_party.ok_or(FormProcessingError::MissingField {
field: "offering_party",
})?,
contact: Address::from_str(&contact)?,
public_contact: public_contact.as_deref() == Some(VISIBLE),
public_contact: public_contact.as_deref() == Some(form_constants::VISIBLE),
expires: expiry_date,
permanent: permanent.as_deref() == Some(PERMANENT),
permanent: permanent.as_deref() == Some(form_constants::PERMANENT),
attachments,
links,
pre_approved: pre_approved.as_deref() == Some(APPROVED),
skip_confirmation: skip_confirmation.as_deref() == Some(SKIP),
pre_approved: pre_approved.as_deref() == Some(form_constants::APPROVED),
skip_confirmation: skip_confirmation.as_deref() == Some(form_constants::SKIP),
backdate: backdate_date,
})
}

View file

@ -0,0 +1,148 @@
use crate::auth::User;
use crate::error::PresentationError;
use crate::route::job_offer::error::DeletionResponseError;
use crate::route::{HTML_CONTENT, JOBOFFER_OVERVIEW_ROUTE};
use crate::{auth, get, template, JobOffers, ServerConfig};
use actix_session::Session;
use actix_web::{post, web, HttpRequest, HttpResponse};
use handlebars::Handlebars;
use log::debug;
use serde::{Deserialize, Deserializer};
use serde_json::json;
pub(crate) const JOBOFFER_DELETION_ROUTE: &str = "joboffer_delete";
#[post("/{id}/delete", name = "joboffer_delete")]
pub(crate) async fn delete_joboffer(
req: HttpRequest,
path: web::Path<String>,
offers: web::Data<JobOffers>,
config: web::Data<ServerConfig>,
session: Session,
) -> actix_web::Result<HttpResponse, DeletionResponseError> {
// TODO return the user to a page where they are asked to confirm deletion
// aka. the get variant of this route
let _user = auth::User::current(&session)?;
let id = path.into_inner();
offers.delete_offer(&id, false, &config).await?;
let dest = req
.url_for_static(JOBOFFER_OVERVIEW_ROUTE)
.expect("overview route should exist");
Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, dest.to_string()))
.finish())
}
pub(crate) const JOBOFFER_DELETE_EXPIRED_ROUTE: &str = "joboffers_delete_expired";
#[get("/delete_expired", name = "joboffers_delete_expired")]
pub(crate) async fn delete_expired_joboffers(
req: HttpRequest,
hb: web::Data<Handlebars<'_>>,
offers: web::Data<JobOffers>,
config: web::Data<ServerConfig>,
session: Session,
) -> actix_web::Result<HttpResponse, DeletionResponseError> {
let user = User::current(&session)?;
let offers_guard = offers.get_offers().await;
let job_offers = crate::job_offers::job_data(
&req,
offers_guard.iter().filter(|(_, offer)| offer.is_expired()),
Some(&user),
);
let base =
crate::route::base(&req, &config, "Delete Expired").map_err(PresentationError::Url)?;
let data = json! {{
"base": base,
"expired_job_offers": job_offers,
}};
let rendered = hb
.render(template::JOBOFFER_DELETE_EXPIRED, &data)
.map_err(PresentationError::Render)?;
Ok(HttpResponse::Ok()
.insert_header((http::header::CONTENT_TYPE, HTML_CONTENT.clone()))
.body(rendered))
}
pub(crate) struct BulkDeleteData {
only_expired: bool,
ids: Vec<String>,
}
impl<'de> Deserialize<'de> for BulkDeleteData {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
let key_value_pairs = <Vec<(String, String)>>::deserialize(deserializer)?;
let mut only_expired = None;
let mut ids = Vec::new();
for (key, value) in key_value_pairs {
match key.as_str() {
"id[]" => ids.push(value),
"only_expired" => {
if only_expired.is_some() {
return Err(D::Error::duplicate_field("only_expired"));
}
match value.as_str() {
"true" => only_expired = Some(true),
"false" => only_expired = Some(false),
value => {
return Err(D::Error::invalid_value(
serde::de::Unexpected::Other(value),
&"either 'true' or 'false'",
));
}
}
}
field => return Err(D::Error::unknown_field(field, &["id[]", "only_expired"])),
}
}
if let Some(only_expired) = only_expired {
Ok(BulkDeleteData { only_expired, ids })
} else {
Err(D::Error::missing_field("only_expired"))
}
}
}
pub(crate) const JOBOFFER_BULK_DELETE_ROUTE: &str = "joboffers_bulk_delete";
#[post("/bulk_delete", name = "joboffers_bulk_delete")]
pub(crate) async fn bulk_delete(
req: HttpRequest,
offers: web::Data<JobOffers>,
config: web::Data<ServerConfig>,
form: web::Form<BulkDeleteData>,
session: Session,
) -> actix_web::Result<HttpResponse, DeletionResponseError> {
debug!("Received bulk deletion request!");
let _user = User::current(&session)?;
for id in &form.ids {
offers.delete_offer(id, form.only_expired, &config).await?;
}
let dest = req
.url_for_static(JOBOFFER_OVERVIEW_ROUTE)
.expect("overview route should exist");
Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, dest.to_string()))
.finish())
}

504
src/route/job_offer/edit.rs Normal file
View file

@ -0,0 +1,504 @@
use crate::auth::User;
use crate::error::{LoginRequired, PresentationError};
use crate::job_offers::error::SaveError;
use crate::job_offers::{Attachment, JobOffer, Link};
use crate::route::form_constants::{self, UploadLimits};
use crate::route::job_offer::error::FormProcessingError;
use crate::route::{HTML_CONTENT, JOBOFFER_OVERVIEW_ROUTE};
use crate::util::{parse_date, parse_datetime, process_links, process_new_attachments};
use crate::{template, JobOffers, ServerConfig};
use actix_multipart::Multipart;
use actix_session::Session;
use actix_web::{get, post, web, HttpRequest, HttpResponse, ResponseError};
use better_toml_datetime::{Date, Datetime};
use futures_util::StreamExt;
use handlebars::Handlebars;
use lettre::Address;
use log::warn;
use multipart_helper::{multi_field, multi_file, once_field};
use serde_json::json;
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
use std::path::PathBuf;
use tempfile::NamedTempFile;
#[derive(Debug, thiserror::Error)]
pub(crate) enum EditResponseError {
#[error("No Job Offer with the requested Id found")]
NotFound,
#[error("A Concurrent Change prevented this change from being applied")]
ConflictingChange,
#[error("login required")]
Login(#[from] LoginRequired),
#[error("{0}")]
Presentation(#[from] PresentationError),
#[error("{0}")]
Form(#[from] FormProcessingError),
#[error("{0}")]
Save(#[from] SaveError),
}
impl ResponseError for EditResponseError {}
pub(crate) const JOBOFFER_EDIT_ROUTE: &str = "joboffer_edit";
#[get("/{id}/edit", name = "joboffer_edit")]
pub(crate) async fn edit_joboffer_get(
req: HttpRequest,
path: web::Path<String>,
hb: web::Data<Handlebars<'_>>,
offers: web::Data<JobOffers>,
config: web::Data<ServerConfig>,
session: Session,
) -> actix_web::Result<HttpResponse, EditResponseError> {
let user = User::current(&session)?;
let id = &*path;
let job_offer = offers
.get_offer(id)
.await
.ok_or(EditResponseError::NotFound)?
.to_edit_data(id, &req)?;
let base =
crate::route::base(&req, &config, "Edit Job Offer").map_err(PresentationError::Url)?;
let additional_slots =
form_constants::ATTACHMENT_FIELD_COUNT.saturating_sub(job_offer.attachments.len());
let data = json!({
"base": base,
"user": user,
"job_offer": job_offer,
"form" : {
"remaining_attachments": vec![(); additional_slots]
}
});
let body = hb
.render(template::JOBOFFER_EDIT, &data)
.map_err(PresentationError::Render)?;
Ok(HttpResponse::Ok()
.insert_header((http::header::CONTENT_TYPE, HTML_CONTENT.clone()))
.body(body))
}
#[post("/{id}/edit", name = "joboffer_edit")]
pub(crate) async fn edit_joboffer_post(
req: HttpRequest,
path: web::Path<String>,
offers: web::Data<JobOffers>,
config: web::Data<ServerConfig>,
multipart: Multipart,
session: Session,
) -> actix_web::Result<HttpResponse, EditResponseError> {
let user = User::current(&session)?;
let id = &*path;
let form_data = JobOfferEditForm::from_multipart_form(multipart, Some(&user), &config).await?;
let mut offer = offers
.get_offer_mut(id, &config)
.await
.ok_or(EditResponseError::NotFound)?;
use std::hash::Hash;
let mut orig_hasher = DefaultHasher::new();
JobOffer::<PathBuf>::hash(&offer, &mut orig_hasher);
let orig_hash = orig_hasher.finish();
if orig_hash != form_data.hash {
return Err(EditResponseError::ConflictingChange);
}
let offer_mut_ref = offer.get_mut();
offer_mut_ref.title = form_data.title;
offer_mut_ref.permanent = form_data.permanent;
offer_mut_ref.offering_party = form_data.offering_party;
offer_mut_ref.contact_info = form_data.contact_data;
offer_mut_ref.public_contact_info = form_data.public_contact_data;
if let Some(back_date) = form_data.backdate {
offer_mut_ref.date_of_submission = back_date;
}
offer_mut_ref.date_of_expiry = form_data.expiry_date;
offer_mut_ref.links = form_data.links;
// currently adding/removing attachments is not supported
assert_eq!(
offer_mut_ref.attachments.len(),
form_data.attachment_edits.len()
);
let job_offer_folder = JobOffer::<PathBuf>::folder_path(id, &config);
let tmp = std::mem::take(&mut offer_mut_ref.attachments)
.into_iter()
.zip(form_data.attachment_edits)
.filter_map(|(mut offer, edit)| match edit {
AttachmentEdit::Delete => {
// TODO error handling
let _todo = std::fs::remove_file(job_offer_folder.join(offer.attachment_location));
None
}
AttachmentEdit::Edit {
title,
file_name,
file,
} => {
offer.title = title;
offer.file_name = file_name;
if let Some(tmp_file) = file {
// TODO error handling
let _todo =
tmp_file.persist(&job_offer_folder.join(&offer.attachment_location));
}
Some(offer)
}
});
offer_mut_ref.attachments.extend(tmp);
let existing_files: Vec<_> = offer_mut_ref
.attachments
.iter()
.map(|attachment| attachment.attachment_location.clone())
.collect();
let mut idx = 0;
let mut new_attachments = form_data
.attachments_new
.into_iter()
.map(|attachment| {
// deleting attachment might mean the attachment at index 0 uses the file 2.attachment instead of 0.attachment
// so we cannot simply use the index this will get inserted at for the file name as that might already be in use
while existing_files.contains(&Attachment::attachment_filename(idx)) {
idx += 1;
}
attachment
.persist(idx, &job_offer_folder)
.map_err(SaveError::Persist)
})
.collect::<Result<_, _>>()?;
offer_mut_ref.attachments.append(&mut new_attachments);
offer.try_clean().await?;
let dest = req
.url_for_static(JOBOFFER_OVERVIEW_ROUTE)
.expect("overview route should exist");
Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, dest.to_string()))
.finish())
}
pub(crate) struct JobOfferEditForm {
pub(crate) hash: u64,
pub(crate) offering_party: String,
pub(crate) contact_data: Address,
pub(crate) public_contact_data: bool,
pub(crate) permanent: bool,
pub(crate) expiry_date: Option<Date>,
pub(crate) backdate: Option<Datetime>,
pub(crate) title: String,
pub(crate) attachment_edits: Vec<AttachmentEdit>,
pub(crate) attachments_new: Vec<Attachment<NamedTempFile>>,
pub(crate) links: Vec<Link>,
}
pub enum AttachmentEdit {
Delete,
Edit {
title: String,
file_name: String,
file: Option<NamedTempFile>,
},
}
impl JobOfferEditForm {
/// Convert the Multipart struct representing multipart form-data
/// into structured form data
async fn from_multipart_form(
mut multipart: Multipart,
user: Option<&User>,
config: &ServerConfig,
) -> Result<Self, FormProcessingError> {
let mut hash = None;
let mut offering_party = None;
let mut contact_data = None;
let mut title = None;
let mut permanent = None;
let mut public_contact_data = None;
let mut back_date = None;
let mut expiry_date = None;
let mut attachment_title_edits = Vec::new();
let mut attachment_filename_edits = Vec::new();
let mut attachment_file_replace = Vec::new();
let mut delete_attachment = Vec::new();
let mut attachment_titles_new = Vec::new();
let mut attachment_files_new = Vec::new();
let mut link_titles = Vec::new();
let mut link_urls = Vec::new();
let UploadLimits {
size: upload_size_limit,
count: upload_count_limit,
} = form_constants::upload_limits(user);
while let Some(item) = multipart.next().await {
let field = item?;
match field.name() {
form_constants::HASH => {
once_field(
field,
form_constants::HASH,
&mut hash,
form_constants::MAX_HASH_LEN,
)
.await?;
}
form_constants::TITLE => {
once_field(
field,
form_constants::TITLE,
&mut title,
form_constants::MAX_TITLE_LEN,
)
.await?;
}
form_constants::OFFERING_PARTY => {
once_field(
field,
form_constants::OFFERING_PARTY,
&mut offering_party,
form_constants::MAX_OFFERING_PARTY_LEN,
)
.await?
}
form_constants::OFFERING_CONTACT => {
once_field(
field,
form_constants::OFFERING_CONTACT,
&mut contact_data,
form_constants::MAX_CONTACT_LEN,
)
.await?
}
form_constants::OFFERING_CONTACT_VISIBILITY => {
once_field(
field,
form_constants::OFFERING_CONTACT_VISIBILITY,
&mut public_contact_data,
form_constants::VISIBLE.len(),
)
.await?
}
form_constants::PERMANENT_FIELD => {
once_field(
field,
form_constants::PERMANENT_FIELD,
&mut permanent,
form_constants::PERMANENT.len(),
)
.await?
}
form_constants::ATTACHMENT_TITLE_EDIT_FIELD => {
multi_field(
field,
form_constants::ATTACHMENT_TITLE_EDIT_FIELD,
&mut attachment_title_edits,
form_constants::MAX_ATTACHMENT_TITLE_LEN,
upload_count_limit,
)
.await?
}
form_constants::ATTACHMENT_FILENAME_EDIT_FIELD => {
multi_field(
field,
form_constants::ATTACHMENT_FILENAME_EDIT_FIELD,
&mut attachment_filename_edits,
// the submission form does not enforce a limite here as there we get it via a header and
// that is already in memory when we could perform the check
form_constants::MAX_ATTACHMENT_TITLE_LEN,
upload_count_limit,
)
.await?
}
form_constants::ATTACHMENT_FILE_REPLACE_FIELD => {
multi_file(
field,
form_constants::ATTACHMENT_FILE_REPLACE_FIELD,
&mut attachment_file_replace,
upload_size_limit,
upload_count_limit,
&config.config.data_storage_path,
)
.await?
}
form_constants::DELETE_ATTACHMENT_FIELD => {
multi_field(
field,
form_constants::DELETE_ATTACHMENT_FIELD,
&mut delete_attachment,
2,
upload_count_limit,
)
.await?
}
form_constants::ATTACHMENT_TITLES => {
multi_field(
field,
form_constants::ATTACHMENT_TITLES,
&mut attachment_titles_new,
form_constants::MAX_ATTACHMENT_TITLE_LEN,
upload_count_limit,
)
.await?
}
form_constants::ATTACHMENT_FILES => {
multi_file(
field,
form_constants::ATTACHMENT_TITLES,
&mut attachment_files_new,
upload_size_limit,
upload_count_limit,
&config.config.data_storage_path,
)
.await?
}
form_constants::LINK_TITLES => {
multi_field(
field,
form_constants::LINK_TITLES,
&mut link_titles,
form_constants::MAX_LINK_TITLE_LEN,
form_constants::MAX_LINK_COUNT,
)
.await?
}
form_constants::LINK_URLS => {
multi_field(
field,
form_constants::LINK_URLS,
&mut link_urls,
form_constants::MAX_LINK_URL_LEN,
form_constants::MAX_LINK_COUNT,
)
.await?
}
form_constants::BACKDATE => {
once_field(
field,
form_constants::BACKDATE,
&mut back_date,
form_constants::DATETIME_FORMAT.len(),
)
.await?
}
form_constants::EXPIRY_DATE => {
once_field(
field,
form_constants::EXPIRY_DATE,
&mut expiry_date,
form_constants::DATE_FORMAT.len(),
)
.await?
}
name => {
warn!(
"Unknown field `{}` in multipart form: {}",
name,
field.content_type()
);
}
}
}
let expiry_date = parse_date(expiry_date.as_deref())?;
let backdate = parse_datetime(back_date.as_deref())?;
assert_eq!(
attachment_filename_edits.len(),
attachment_title_edits.len()
);
// if not set we should still get a field with empty content
assert_eq!(attachment_title_edits.len(), attachment_file_replace.len());
// we only get a value if delete is checked
assert!(delete_attachment.len() <= attachment_filename_edits.len());
let attachment_edits = attachment_title_edits
.into_iter()
.zip(attachment_filename_edits.into_iter())
.zip(attachment_file_replace.into_iter())
.enumerate()
.map(
|(idx, ((title, specified_file_name), (upload_file_name, file)))| {
if delete_attachment.contains(&idx.to_string()) {
// prefer deletion over everything else
// maybe we should warn when a file has both been replaced and marked for deletion
AttachmentEdit::Delete
} else {
// prefer the user specified file_name over the uploads file_name
let file_name = if specified_file_name.is_empty() {
// unless the user specified an empty string
upload_file_name
} else {
specified_file_name
};
AttachmentEdit::Edit {
title,
file_name,
file,
}
}
},
)
.collect();
assert_eq!(attachment_titles_new.len(), attachment_files_new.len());
let attachments_new = process_new_attachments(attachment_titles_new, attachment_files_new);
assert_eq!(link_titles.len(), link_urls.len());
let links = process_links(link_titles, link_urls);
Ok(Self {
hash: hash
.ok_or(FormProcessingError::MissingField {
field: form_constants::HASH,
})?
.parse()
.map_err(|_| FormProcessingError::InvalidHash)?,
offering_party: offering_party.ok_or(FormProcessingError::MissingField {
field: form_constants::OFFERING_PARTY,
})?,
contact_data: contact_data
.ok_or(FormProcessingError::MissingField {
field: form_constants::OFFERING_CONTACT,
})?
.parse()?,
public_contact_data: public_contact_data.is_some(),
permanent: permanent.is_some(),
expiry_date,
backdate,
title: title.ok_or(FormProcessingError::MissingField {
field: form_constants::TITLE,
})?,
attachment_edits,
attachments_new,
links,
})
}
}

View file

@ -1,19 +1,22 @@
use std::io::ErrorKind;
use actix_multipart::MultipartError;
use actix_web::body::BoxBody;
use actix_web::error::UrlGenerationError;
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use handlebars::RenderError;
use lettre::address::AddressError;
use log::warn;
use multipart_helper::MultipartFieldError;
use thiserror::Error;
use crate::error::{default_error_response, LoginRequired, MultipartFieldError, PresentationError};
use crate::job_offers::error::{DeleteError, SaveError};
use crate::error::{default_error_response, LoginRequired, PresentationError};
use crate::job_offers::error::{DeleteError, SaveError, SaveResponseError};
use crate::job_offers::JobofferLoadError;
#[derive(Debug, Error)]
pub(crate) enum ConfirmationError {
pub(crate) enum ConfirmationResponseError {
/// The referenced job offer does not exists,
/// or does not await confirmation,
/// or the token was invalid
@ -33,33 +36,37 @@ pub(crate) enum ConfirmationError {
Presentation(#[from] PresentationError),
}
impl ResponseError for ConfirmationError {
impl ResponseError for ConfirmationResponseError {
fn status_code(&self) -> StatusCode {
match self {
ConfirmationError::RenderError(_) => StatusCode::INTERNAL_SERVER_ERROR,
ConfirmationError::SuccessRenderError(_) => StatusCode::CREATED,
ConfirmationError::InvalidRequest => StatusCode::BAD_REQUEST,
ConfirmationError::Save(inner) => inner.status_code(),
ConfirmationError::Delete(inner) => inner.status_code(),
ConfirmationError::Url(inner) => inner.status_code(),
ConfirmationError::Presentation(inner) => inner.status_code(),
ConfirmationResponseError::RenderError(_) => StatusCode::INTERNAL_SERVER_ERROR,
ConfirmationResponseError::SuccessRenderError(_) => StatusCode::CREATED,
ConfirmationResponseError::InvalidRequest => StatusCode::BAD_REQUEST,
ConfirmationResponseError::Save(inner) => inner.as_status_code(),
ConfirmationResponseError::Delete(DeleteError::IO(_)) => {
StatusCode::INTERNAL_SERVER_ERROR
}
ConfirmationResponseError::Url(inner) => inner.status_code(),
ConfirmationResponseError::Presentation(inner) => inner.status_code(),
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let status_code = self.status_code();
match self {
ConfirmationError::SuccessRenderError(inner) => {
ConfirmationResponseError::SuccessRenderError(inner) => {
warn!("Failed to render successful submission response {}", inner);
HttpResponse::build(status_code).body("The submission was successful, but an error occurred while generating this response.")
}
ConfirmationError::InvalidRequest => {
ConfirmationResponseError::InvalidRequest => {
HttpResponse::build(status_code).body("Invalid Request")
} // TODO more detail
ConfirmationError::Save(inner) => inner.error_response(),
ConfirmationError::Delete(inner) => inner.error_response(),
ConfirmationError::Url(inner) => inner.error_response(),
error @ (ConfirmationError::RenderError(_) | ConfirmationError::Presentation(_)) => {
ConfirmationResponseError::Save(_) | ConfirmationResponseError::Delete(_) => {
default_error_response(self, status_code)
}
ConfirmationResponseError::Url(inner) => inner.error_response(),
error @ (ConfirmationResponseError::RenderError(_)
| ConfirmationResponseError::Presentation(_)) => {
default_error_response(error, status_code)
}
}
@ -67,30 +74,30 @@ impl ResponseError for ConfirmationError {
}
#[derive(Debug, Error)]
pub(crate) enum DeletionError {
pub(crate) enum DeletionResponseError {
#[error("Could not delete Job Offer: {0}")]
Delete(#[from] DeleteError),
#[error("Login required to perform job Offer deletion: {0}")]
Login(#[from] LoginRequired),
#[error("{0}")]
Presentation(#[from] PresentationError),
}
impl ResponseError for DeletionError {
impl ResponseError for DeletionResponseError {
fn status_code(&self) -> StatusCode {
match self {
DeletionError::Delete(inner) => inner.status_code(),
DeletionError::Login(inner) => inner.status_code(),
DeletionResponseError::Delete(DeleteError::IO(_))
| DeletionResponseError::Presentation(_) => StatusCode::INTERNAL_SERVER_ERROR,
DeletionResponseError::Login(_) => StatusCode::UNAUTHORIZED,
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {
match self {
DeletionError::Delete(inner) => inner.error_response(),
DeletionError::Login(inner) => inner.error_response(),
}
default_error_response(self, self.status_code())
}
}
#[derive(Debug, Error)]
pub(crate) enum StateChangeError {
pub(crate) enum StateChangeResponseError {
#[error("Could not save changes to Job Offer: {0}")]
Save(#[from] SaveError),
#[error("Login required to perform job Offer deletion: {0}")]
@ -99,104 +106,124 @@ pub(crate) enum StateChangeError {
Presentation(#[from] PresentationError),
}
impl From<RenderError> for StateChangeError {
impl From<RenderError> for StateChangeResponseError {
fn from(render: RenderError) -> Self {
Self::Presentation(PresentationError::Render(render))
}
}
impl From<UrlGenerationError> for StateChangeError {
impl From<UrlGenerationError> for StateChangeResponseError {
fn from(url_gen: UrlGenerationError) -> Self {
Self::Presentation(PresentationError::Url(url_gen))
}
}
impl ResponseError for StateChangeError {
impl ResponseError for StateChangeResponseError {
fn status_code(&self) -> StatusCode {
match self {
StateChangeError::Save(inner) => inner.status_code(),
StateChangeError::Login(inner) => inner.status_code(),
StateChangeError::Presentation(inner) => inner.status_code(),
StateChangeResponseError::Save(inner) => inner.as_status_code(),
StateChangeResponseError::Login(_inner) => StatusCode::UNAUTHORIZED,
StateChangeResponseError::Presentation(inner) => inner.status_code(),
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {
match self {
StateChangeError::Save(inner) => inner.error_response(),
StateChangeError::Login(inner) => inner.error_response(),
StateChangeError::Presentation(inner) => inner.error_response(),
StateChangeResponseError::Login(_) | StateChangeResponseError::Save(_) => {
default_error_response(self, self.status_code())
}
StateChangeResponseError::Presentation(inner) => inner.error_response(),
}
}
}
#[derive(Debug, Error)]
pub(crate) enum SubmissionError {
pub(crate) enum SubmissionResponseError {
#[error("At least one link or attachment is required!")]
MissingLinkOrAttachment,
#[error("An error occurred while processing the form submission: {0}")]
Form(#[from] MultipartFieldError),
Form(#[from] FormProcessingError),
#[error("Failed to save the submission: {0}")]
Save(#[from] SaveError),
Save(#[from] SaveResponseError),
#[error("{0}")]
Render(#[from] PresentationError),
#[error("Too many requests!")]
TooManyRequests,
}
impl From<UrlGenerationError> for SubmissionError {
impl From<UrlGenerationError> for SubmissionResponseError {
fn from(url: UrlGenerationError) -> Self {
Self::Render(PresentationError::Url(url))
}
}
impl From<RenderError> for SubmissionError {
impl From<RenderError> for SubmissionResponseError {
fn from(render: RenderError) -> Self {
Self::Render(PresentationError::Render(render))
}
}
impl ResponseError for SubmissionError {
impl ResponseError for SubmissionResponseError {
fn status_code(&self) -> StatusCode {
match self {
SubmissionError::MissingLinkOrAttachment => StatusCode::BAD_REQUEST,
SubmissionError::Form(inner) => inner.status_code(),
SubmissionError::Save(inner) => inner.status_code(),
SubmissionError::Render(inner) => inner.status_code(),
SubmissionError::TooManyRequests => StatusCode::TOO_MANY_REQUESTS,
SubmissionResponseError::MissingLinkOrAttachment
| SubmissionResponseError::Form(
FormProcessingError::Date { .. }
| FormProcessingError::Datetime { .. }
| FormProcessingError::MultiPart(_)
| FormProcessingError::MissingField { .. }
| FormProcessingError::InvalidAddress(_)
| FormProcessingError::InvalidHash
| FormProcessingError::Field(
MultipartFieldError::ContentTooLarge { .. }
| MultipartFieldError::NotAFile { .. }
| MultipartFieldError::Multipart(_)
| MultipartFieldError::TooManyOccurrences { .. }
| MultipartFieldError::UTF8Error(_),
),
) => StatusCode::BAD_REQUEST,
SubmissionResponseError::Form(FormProcessingError::Field(
MultipartFieldError::IOError(_),
)) => StatusCode::INTERNAL_SERVER_ERROR,
SubmissionResponseError::Save(inner) => inner.status_code(),
SubmissionResponseError::Render(inner) => inner.status_code(),
SubmissionResponseError::TooManyRequests => StatusCode::TOO_MANY_REQUESTS,
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let status_code = self.status_code();
match self {
SubmissionError::MissingLinkOrAttachment => default_error_response(self, status_code),
SubmissionError::Form(inner) => inner.error_response(),
SubmissionError::Save(inner) => inner.error_response(),
SubmissionError::Render(inner) => inner.error_response(),
SubmissionError::TooManyRequests => default_error_response(self, status_code),
SubmissionResponseError::Save(inner) => inner.error_response(),
SubmissionResponseError::Render(inner) => inner.error_response(),
SubmissionResponseError::MissingLinkOrAttachment
| SubmissionResponseError::Form(_)
| SubmissionResponseError::TooManyRequests => default_error_response(self, status_code),
}
}
}
#[derive(Debug, Error)]
pub(crate) enum SyncError {
pub(crate) enum SyncResponseError {
#[error("{0}")]
LoginRequired(#[from] LoginRequired),
Login(#[from] LoginRequired),
#[error("{0}")]
Load(#[from] JobofferLoadError),
#[error("{0}")]
Presentation(#[from] PresentationError),
}
impl ResponseError for SyncError {
impl ResponseError for SyncResponseError {
fn status_code(&self) -> StatusCode {
match self {
SyncError::LoginRequired(_) => StatusCode::FORBIDDEN,
SyncError::Load(_) | SyncError::Presentation(_) => StatusCode::INTERNAL_SERVER_ERROR,
SyncResponseError::Login(_) => StatusCode::UNAUTHORIZED,
SyncResponseError::Load(_) | SyncResponseError::Presentation(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum AttachmentError {
pub(crate) enum AttachmentResponseError {
#[error("Job Offer not found!")]
OfferNotFound,
#[error("Viewing this Job Offers Attachments requires Authentication, as the offer is not yet published: {0}")]
@ -205,24 +232,50 @@ pub(crate) enum AttachmentError {
IO(#[from] std::io::Error),
}
impl ResponseError for AttachmentError {
impl ResponseError for AttachmentResponseError {
fn status_code(&self) -> StatusCode {
match self {
AttachmentError::OfferNotFound => StatusCode::NOT_FOUND,
AttachmentError::IO(error) if error.kind() == ErrorKind::NotFound => {
AttachmentResponseError::OfferNotFound => StatusCode::NOT_FOUND,
AttachmentResponseError::IO(error) if error.kind() == ErrorKind::NotFound => {
StatusCode::NOT_FOUND
}
AttachmentError::LoginRequired(inner) => inner.status_code(),
AttachmentError::IO(_) => StatusCode::INTERNAL_SERVER_ERROR,
AttachmentResponseError::LoginRequired(_) => StatusCode::UNAUTHORIZED,
AttachmentResponseError::IO(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let status_code = self.status_code();
match self {
AttachmentError::OfferNotFound => default_error_response(self, status_code),
AttachmentError::LoginRequired(inner) => inner.error_response(),
AttachmentError::IO(inner) => default_error_response(inner, status_code),
AttachmentResponseError::OfferNotFound => default_error_response(self, status_code),
AttachmentResponseError::LoginRequired(_inner) => {
default_error_response(self, status_code)
}
AttachmentResponseError::IO(inner) => default_error_response(inner, status_code),
}
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum FormProcessingError {
#[error("TODO")] // TODO
Field(#[from] MultipartFieldError),
#[error("the multipart-from data was malformed: {0}")]
MultiPart(#[from] MultipartError),
#[error("a required filed was missing: {field}")]
MissingField { field: &'static str },
#[error("Error while parsing date: {err}")]
Date {
#[from]
err: better_toml_datetime::DateError,
},
#[error("Error while parsing datetime: {err}")]
Datetime {
#[from]
err: better_toml_datetime::DatetimeError,
},
#[error("invalid contact address: {0}")]
InvalidAddress(#[from] AddressError),
#[error("invalid hash")]
InvalidHash,
}

View file

@ -1,61 +1,10 @@
use actix_session::Session;
use actix_web::{http, post, web, HttpRequest, HttpResponse};
use crate::route::job_offer::error::{DeletionError, StateChangeError};
use crate::route::job_offer::error::StateChangeResponseError;
use crate::route::JOBOFFER_OVERVIEW_ROUTE;
use crate::{auth, JobOffers, ServerConfig};
pub(crate) const JOBOFFER_DELETION_ROUTE: &str = "joboffer_delete";
#[post("/{id}/delete", name = "joboffer_delete")]
pub(crate) async fn delete_joboffer(
req: HttpRequest,
path: web::Path<String>,
offers: web::Data<JobOffers>,
config: web::Data<ServerConfig>,
session: Session,
) -> actix_web::Result<HttpResponse, DeletionError> {
// TODO return the user to a page where they are asked to confirm deletion
// aka. the get variant of this route
let _user = auth::User::current(&session)?;
let id = path.into_inner();
offers.delete_offer(&id, false, &config).await?;
let dest = req
.url_for_static(JOBOFFER_OVERVIEW_ROUTE)
.expect("overview route should exist");
Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, dest.to_string()))
.finish())
}
pub(crate) const JOBOFFER_DELETE_EXPIRED_ROUTE: &str = "joboffers_delete_expired";
#[post("/delete_expired", name = "joboffers_delete_expired")]
pub(crate) async fn delete_expired_joboffers(
req: HttpRequest,
offers: web::Data<JobOffers>,
config: web::Data<ServerConfig>,
session: Session,
) -> actix_web::Result<HttpResponse, DeletionError> {
// TODO return the user to a page where they are asked to confirm deletion
// aka. the get variant of this route
let _user = auth::User::current(&session)?;
offers.delete_expired(&config).await?;
let dest = req
.url_for_static(JOBOFFER_OVERVIEW_ROUTE)
.expect("overview route should exist");
Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, dest.to_string()))
.finish())
}
pub(crate) const JOBOFFER_PUBLISH_ROUTE: &str = "review_offer";
#[post("/{id}/review", name = "review_offer")]
@ -65,7 +14,7 @@ pub(crate) async fn review_joboffer(
session: Session,
config: web::Data<ServerConfig>,
offers: web::Data<JobOffers>,
) -> actix_web::Result<HttpResponse, StateChangeError> {
) -> actix_web::Result<HttpResponse, StateChangeResponseError> {
// TODO return the user to a page where they are asked to confirm publishing
// aka. the get variant of this route
let _user = auth::User::current(&session)?;
@ -93,7 +42,7 @@ pub(crate) async fn unpublish_joboffer(
session: Session,
config: web::Data<ServerConfig>,
offers: web::Data<JobOffers>,
) -> actix_web::Result<HttpResponse, StateChangeError> {
) -> actix_web::Result<HttpResponse, StateChangeResponseError> {
// TODO return the user to a page where they are asked to confirm un-publishing
// aka. the get variant of this route
let _user = auth::User::current(&session)?;

View file

@ -31,11 +31,20 @@ pub(crate) struct ProgramConfig {
pub(crate) data_storage_path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub(crate) banner: Option<String>,
#[serde(default)]
pub(crate) footer_links: Vec<Link>,
pub(crate) login_provider: LoginProviderConfig,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub(crate) email: Option<EmailConfig>,
}
#[derive(Serialize, Deserialize, Debug)]
pub(crate) struct Link {
pub(crate) title: String,
#[serde(default)]
pub(crate) url: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub(crate) struct EmailConfig {
pub(crate) from: Mailbox,

View file

@ -4,3 +4,10 @@ async fn load_dist_config() {
.await
.expect("should be able to load config/dist-config.toml ");
}
#[test]
fn default_config_toml_serializable() {
let default_config = super::ProgramConfig::default();
toml::to_string_pretty(&default_config)
.expect("successful serialization of default program config");
}

View file

@ -1,11 +1,13 @@
pub(crate) const JOBOFFEL_OVERVIEW: &str = "job_offer/overview";
pub(crate) const JOBOFFER_CREATE: &str = "job_offer/create";
pub(crate) const JOBOFFER_EDIT: &str = "job_offer/edit";
pub(crate) const JOBOFFER_CREATE_SUCCESS: &str = "job_offer/create-success";
pub(crate) const JOBOFFER_ATTACHMENT_PREVIEW: &str = "job_offer/attachement-preview-note";
pub(crate) const JOBOFFER_CONFIRM_SUBMISSION: &str = "job_offer/submission-confirm";
pub(crate) const JOBOFFER_CONFIRM_SUBMISSION_SUCCESS: &str = "job_offer/submission-confirm-success";
pub(crate) const JOBOFFER_REJECT_SUBMISSION_SUCCESS: &str = "job_offer/submission-rejected-success";
pub(crate) const JOBOFFER_DELETE_EXPIRED: &str = "job_offer/delete-expired";
pub(crate) const AUTH_LOGIN: &str = "auth/login";
pub(crate) const LICENCES: &str = "licenses";
pub const EMAIL_PLAIN: &'static str = "email/plaintext";
pub const EMAIL_PLAIN: &str = "email/plaintext";

View file

@ -1,5 +1,10 @@
use crate::job_offers::{Attachment, Link};
use better_toml_datetime::Offset;
use chrono::{DateTime, FixedOffset, NaiveDate, Offset as _, TimeZone, Utc};
use toml::value::Offset;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::str::FromStr;
use tempfile::NamedTempFile;
use url::Url;
// we basically don't do any proper error handling here,
// as we mostly expect the format conversion to be infallible
@ -10,7 +15,7 @@ pub fn now() -> DateTime<chrono_tz::Tz> {
pub fn chrono_datetime_to_toml_datetime<Tz: chrono::TimeZone>(
datetime: &chrono::DateTime<Tz>,
) -> toml::value::Datetime {
) -> better_toml_datetime::Datetime {
use chrono::Timelike;
use chrono::{Datelike, Offset};
@ -58,6 +63,7 @@ pub fn chrono_datetime_to_toml_datetime<Tz: chrono::TimeZone>(
),
}),
}
.into()
}
pub fn toml_date_to_chrono_date(date: &toml::value::Date) -> chrono::Date<FixedOffset> {
@ -70,7 +76,7 @@ pub fn toml_date_to_chrono_date(date: &toml::value::Date) -> chrono::Date<FixedO
}
pub fn toml_datetime_to_chrono_datetime(
datetime: &toml::value::Datetime,
datetime: &better_toml_datetime::Datetime,
) -> chrono::DateTime<FixedOffset> {
let toml_date = datetime
.date
@ -83,7 +89,8 @@ pub fn toml_datetime_to_chrono_datetime(
minute: 0,
second: 0,
nanosecond: 0,
},
}
.into(),
);
let local_datetime = chrono::NaiveDate::from_ymd(
@ -123,3 +130,99 @@ pub fn toml_datetime_to_chrono_datetime(
offset.from_local_datetime(&local_datetime).unwrap()
}
pub(crate) fn boolean_from_flag<'de, D>(des: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
<Option<String> as Deserialize>::deserialize(des).map(|option| option.is_some())
}
pub(crate) fn url_as_string<S>(url: &Url, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(url.as_str())
}
#[derive(Serialize)]
pub(crate) struct SerializableUrl(
#[serde(serialize_with = "crate::util::url_as_string")] pub url::Url,
);
impl From<Url> for SerializableUrl {
fn from(url: Url) -> Self {
Self(url)
}
}
impl From<SerializableUrl> for Url {
fn from(SerializableUrl(url): SerializableUrl) -> Self {
url
}
}
pub(crate) fn parse_date(
input: Option<&str>,
) -> Result<Option<better_toml_datetime::Date>, <better_toml_datetime::Date as FromStr>::Err> {
Ok(match input {
None | Some("") => None,
Some(expiry) => toml::value::Datetime::from_str(expiry)
.map_err(|_err| better_toml_datetime::DateError)?
.date
.map(better_toml_datetime::Date::from),
})
}
pub(crate) fn parse_datetime(
input: Option<&str>,
) -> Result<Option<better_toml_datetime::Datetime>, <better_toml_datetime::Datetime as FromStr>::Err>
{
Ok(match input {
None | Some("") => None,
Some(backdate) => {
// toml currently requires time to include seconds, but the html field does not include them, so we just add them here
let backdate_with_sec = format!("{}:00", backdate);
Some(better_toml_datetime::Datetime::from_str(
&backdate_with_sec,
)?)
}
})
}
pub(crate) fn process_links(titles: Vec<String>, urls: Vec<String>) -> Vec<Link> {
titles
.into_iter()
.zip(urls.into_iter())
.filter(|(_, url)| !url.is_empty())
.map(|(title, url)| Link {
title: if title.is_empty() { url.clone() } else { title },
destination: url,
})
.collect()
}
pub(crate) fn process_new_attachments(
titles: Vec<String>,
files: Vec<(String, Option<NamedTempFile>)>,
) -> Vec<Attachment<NamedTempFile>> {
titles
.into_iter()
.zip(files.into_iter())
.filter_map(|(title, (file_name, attachment_location))| {
attachment_location.map(|attachment_location| (title, file_name, attachment_location))
})
.enumerate()
.map(
|(idx, (title, file_name, attachment_location))| Attachment {
title: if title.is_empty() {
format!("Attachment {}: {}", idx + 1, file_name)
} else {
title
},
file_name,
attachment_location,
},
)
.collect()
}

View file

@ -1,5 +1,6 @@
:root {
--header-footer-color: AliceBlue;
--filter-bar-color: Aquamarine;
}
.header, .footer, .header-banner {
@ -17,7 +18,7 @@
background: orange;
}
.header {
.header{
position: sticky;
top: 0;
left:0;
@ -49,7 +50,7 @@ body main {
margin: 10px;
}
main {
.header-main {
flex-grow:1;
}
@ -67,6 +68,10 @@ main {
font-style: italic;
}
.bold-text {
font-weight: bold;
}
.login-form {
display: grid;
grid-gap: 5px;
@ -219,10 +224,14 @@ main {
border-width: 2px;
}
.joboffer-index-entry-content.AwaitingReview, .joboffer-index-entry-content.UnPublished {
.joboffer-index-entry.AwaitingReview, .joboffer-index-entry.UnPublished {
background: pink;
}
.joboffer-index-entry:target {
background: LemonChiffon;
}
.column, .submission-preview {
flex-direction:column
}
@ -293,6 +302,33 @@ input.modal-open-check:not(:checked) + .modal-confirm-box > .modal-submit-button
display: none;
}
:checked.hideable-toggle ~ .hideable-target {
.joboffer-filters {
display: flex;
align-items: center;
position: sticky;
background: var(--filter-bar-color);
top: 120px;
box-sizing: border-box;
left:0;
margin-bottom: 5px;
padding: 5px;
width: 100%;
box-shadow: 0px 0px 10px 0px gray;
}
.joboffer-filters > .filter-label[for="awaiting-review"] {
border: solid;
padding: 5px;
border-color: gray;
border-radius: 20px;
border-width: 2px;
}
#awaiting-review:checked ~ .joboffer-filters > .filter-label[for="awaiting-review"] {
background: cyan;
}
#awaiting-review:checked ~ .joboffer-index > .joboffer-index-entry:not(.AwaitingReview) {
display: none;
}

View file

@ -8,10 +8,12 @@
{{/each}}
</head>
<body>
{{> header}}
<main>
{{> @partial-block }}
</main>
<div class="header-main">
{{> header}}
<main>
{{> @partial-block }}
</main>
</div>
{{> footer}}
</body>
</html>

12
templates/error/400.hb Normal file
View file

@ -0,0 +1,12 @@
{{#> base}}
<div class="centered">
<div>
Die Aktion konnte nicht durchgeführt werden. <br />
{{#if msg}}
{{msg}}
{{else}}
Es scheint ein un-kategorisiertes Problem mit ihrer Anfrage zu bestehen.
{{/if}}
</div>
</div>
{{/base}}

9
templates/error/429.hb Normal file
View file

@ -0,0 +1,9 @@
{{#> base}}
<div class="centered">
<div>
Die Aktion konnte nicht durchgeführt werden. <br />
Es wurden zu viele Anfragen in zu kurzer Zeit gestellt. <br />
Bitte warten etwas bevor sie weitere Anfragen stellen.
</div>
</div>
{{/base}}

View file

@ -1,21 +1,12 @@
<footer class="footer">
<nav class="footer-inner">
<a class="footer-element" href="{{base.links.impress}}"><h3 class="inline">Impressum</h3></a>
{{#if base.links.homepage}}
<span>|</span><a class="footer-element" href="{{base.links.homepage}}"><h3 class="inline">Homepage</h3></a>
{{/if}}
{{#if base.links.repository}}
<span>|</span><a class="footer-element" href="{{base.links.repository}}"><h3 class="inline">Source Repository</h3></a>
{{/if}}
{{#each base.links as |link|}}
{{#unless @first}}<span>|</span>{{/unless}}<a class="footer-element" href="{{link.url}}"><h3 class="inline">{{link.title}}</h3></a>
{{/each}}
<span>|</span><a class="footer-element" href="{{base.routes.licenses}}"><h3 class="inline">Third-party Licenses</h3></a>
<span>|</span><a class="footer-element" href="{{base.routes.joboffer_create}}"><h3 class="inline">Stellenanzeige Einreichen</h3></a>
{{#if user}}
<span>|</span><label class="footer-element" for="submit-delete-expired" tabindex="0">
<h3 class="inline" title="Delete all expired Job Offers">Delete Expired</h3>
<form class="hidden" method="post" action="{{base.routes.joboffers_delete_expired}}">
<input type="submit" id="submit-delete-expired" class="hidden">
</form>
</label>
<span>|</span><a class="footer-element" href="{{base.routes.joboffers_delete_expired}}" title="Delete all expired Job Offers"><h3 class="inline">Delete Expired</h3></a>
<span>|</span><label class="footer-element" for="submit-sync" tabindex="0">
<h3 class="inline" title="Reload Joboffer Metadata from Disk">Re-Sync</h3>
<form class="hidden" method="post" action="{{base.routes.sync}}">

View file

@ -18,38 +18,28 @@
<fieldset class="attachment-area">
<legend>Anhänge</legend>
<input type="text" autocomplete="on" name="file_title[]" placeholder="Title"/>
<input type="file" accept=".pdf" name="file[]"/>
<br />
<hr />
<input type="text" autocomplete="on" name="file_title[]" placeholder="Title"/>
<input type="file" accept=".pdf" name="file[]"/>
<br />
<hr />
<input type="text" autocomplete="on" name="file_title[]" placeholder="Title"/>
<input type="file" accept=".pdf" name="file[]"/>
<br />
<hr />
<input type="text" autocomplete="on" name="file_title[]" placeholder="Title"/>
<input type="file" accept=".pdf" name="file[]"/>
{{#each form.attachments }}
{{#unless @first}}
<hr />
{{/unless}}
<div>
<input type="text" autocomplete="on" name="file_title[]" placeholder="Title"/>
<input type="file" accept=".pdf" name="file[]"/>
<div/>
{{/each}}
</fieldset>
<fieldset class="link-area">
<legend>Links<sup>2</sup></legend>
<input type="text" autocomplete="on" name="link_title[]" placeholder="Online-Stellenausschreibung" />
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" placeholder="{{base.routes.index}}" />
<br />
<hr />
<input type="text" autocomplete="on" name="link_title[]" placeholder="Online-Stellenausschreibung" />
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" placeholder="{{base.routes.index}}" />
<br />
<hr />
<input type="text" autocomplete="on" name="link_title[]" placeholder="Online-Stellenausschreibung" />
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" placeholder="{{base.routes.index}}" />
<br />
<hr />
<input type="text" autocomplete="on" name="link_title[]" placeholder="Online-Stellenausschreibung" />
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" placeholder="{{base.routes.index}}" />
{{#each form.links }}
{{#unless @first}}
<hr />
{{/unless}}
<div>
<input type="text" autocomplete="on" name="link_title[]" placeholder="Online-Stellenausschreibung" />
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" placeholder="{{@root.base.routes.index}}" />
<div/>
{{/each}}
</fieldset>
<div class="notes">

View file

@ -0,0 +1,19 @@
{{#> base}}
<form method="post" action="{{base.routes.joboffers_bulk_delete}}">
<div class="joboffer-index">
{{#each expired_job_offers as |job_offer|}}
<label for="check-{{job_offer.id}}">
<input id="check-{{job_offer.id}}" type="checkbox" name="id[]" value="{{job_offer.id}}" checked="checked">
{{> job_offer/overview-entry job_offer=job_offer base=../base user=../user}}
</label>
{{else}}
There are no expired job offers to delete!
{{/each}}
</div>
{{#if expired_job_offers }}
<input type="hidden" name="only_expired" value="true">
<hr />
<button type="submit">Delete selected Job Offers</button>
{{/if}}
</form>
{{/base}}

View file

@ -0,0 +1,86 @@
{{#> base}}
<div class="centered">
<form class="submission-form" method="post" enctype="multipart/form-data">
<input type="hidden" name="hash" value="{{job_offer.hash}}" />
<label class="offer-title-title" for="offer-title">Title</label>
<input class="offer-title-field" id="offer-title" type="text" name="title" autocomplete="on" autofocus value="{{job_offer.title}}" required><br />
<label class="submitter-title" for="offer-offering-party">Anbieter</label>
<input class="submitter-field" id="offer-offering-party" type="text" name="offering_party" autocomplete="organization" value="{{job_offer.offering_party}}" required><br />
<label class="contact-title" for="contact-data">Kontakt E-Mail Address<sup>1</sup></label>
<input class="contact-field" id="contact-data" type="email" autocomplete="email" name="offer-contact" value="{{job_offer.contact_data}}" required>
<label class="publish-contact-title" for="publish-contact-data">Kontakt Adresse Öffentlich</label>
<input class="publish-contact-checkbox" id="publish-contact-data" type="checkbox" name="offer-contact-visible" value="visible" {{#if job_offer.public_contact_data}}checked="checked"{{/if}}><br />
<label class="expiry-title" for="offer-expiry">Gültig bis (Wenn nicht angegeben, 6 Monate nach Einsendung)</label>
<input class="expiry-select" id="offer-expiry" type="date" name="offer_expiry" {{#if job_offer.expiry_date }}value="{{job_offer.expiry_date}}"{{/if}}><br />
<fieldset class="attachment-area">
<legend>Anhänge</legend>
{{#each job_offer.attachments as |attachment| }}
<div>
{{#unless @first}}
<hr />
{{/unless}}
<input type="text" autocomplete="on" name="file_title_edit[]" value="{{attachment.title}}" />
<input type="text" name="file_name_edit[]" value="{{attachment.file_name}}" />
| <a href="{{attachment.attachment_location}}" target="_blank">View Attachment</a>
| <label for="delete-{{@index}}">Delete Attachment</label> <input id="delete-{{@index}}" type="checkbox" name="delete_attachment[]" value="{{@index}}" />
| <label for="replace-{{@index}}">Replace Attachment</label> <input id="replace-{{@index}}" type="file" accept=".pdf" name="file_replace[]" title="Replace Attachment" />
<div/>
{{/each}}
{{#each form.remaining_attachments }}
{{#unless @first}}
<hr />
{{else}}
{{#if ../job_offer.attachments }}
<hr />
{{/if}}
{{/unless}}
<div>
<input type="text" autocomplete="on" name="file_title[]" placeholder="Title"/>
<input type="file" accept=".pdf" name="file[]"/>
<div/>
{{/each}}
</fieldset>
<fieldset class="link-area">
<legend>Links<sup>2</sup></legend>
{{#each job_offer.links as |link| }}
{{#unless @first}}
<hr />
{{/unless}}
<div>
<input type="text" autocomplete="on" name="link_title[]" {{#if link}}value="{{link.title}}"{{/if}} />
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" {{#if link}}value="{{link.destination}}"{{/if}} />
<div/>
{{/each}}
</fieldset>
<div class="notes">
Hinweis: Es muss mindestens ein Anhang oder ein Link angegeben werden.
</div>
<div class="footnotes">
1: Die Kontakt Address wird für eine Bestätigungsmail und eventuelle Rückfragen benötigt, sie kann optional auch öffentlich als Teil des Stellenausschreibungseintrags angezeigt werden.<br />
2: Link URLs müssen mit "https://" beginnen
</div>
{{#if user }}
<fieldset class="advanced-options">
<legend>Erweiterte Optionen für Reviewer</legend>
<label class="backdate-title" for="offer-backdate">Eingegangen (Optionales Umdatieren)</label>
<input class="backdate-select" id="offer-backdate" type="datetime-local" name="backdate"><br />
<label class="infinite-title" for="permanent">Permanente Stellenausschreibung</label>
<input id="permanent" class="infinite-checkbox" type="checkbox" name="permanent" value="permanent" {{#if job_offer.permanent}}checked="checked"{{/if}}><br />
</fieldset>
{{/if}}
<button class="submit-button" type="submit">Submit</button>
</form>
</div>
{{/base}}

View file

@ -1,10 +1,10 @@
<section class="joboffer-index-entry">
<section id="joboffer-{{job_offer.id}}" class="joboffer-index-entry {{#if user}}{{job_offer.status.review_status}}{{/if}}">
{{!
the job offers should be filtered in the rust code,
so that either there is a user or the offers are limited to published ones
}}
<div class="joboffer-index-entry-content {{#if user}}{{job_offer.status}}{{/if}}">
<div class="joboffer-index-entry-content">
<h2 class="joboffer-title centered centered-text">{{job_offer.title}}</h2>
<div class="joboffer-offering-party centered centered-text italic-text">
{{job_offer.offering_party}}
@ -61,45 +61,48 @@
{{/if}}
</div>
<div>
{{#if user }}
{{# unless job_offer.is_preview }}
<hr />
<div>ID: {{job_offer.id}}</div>
<div>Review Status: <span class="{{#unless job_offer.reviewed}}unreviewed{{/unless}}">{{job_offer.status.review_status}}</span></div>
<div>Confirmation Status: <span class="{{#unless job_offer.confirmed}}unconfirmed{{/unless}}">{{job_offer.status.confirmation_status.type}}</span></div>
{{/unless}}
{{#if user }}
{{# unless job_offer.is_preview }}
<hr />
<div>ID: {{job_offer.id}}</div>
<div>Review Status: <span class="{{#unless job_offer.reviewed}}unreviewed{{/unless}}">{{job_offer.status.review_status}}</span></div>
<div>Confirmation Status: <span class="{{#unless job_offer.confirmed}}unconfirmed{{/unless}}">{{job_offer.status.confirmation_status.type}}</span></div>
{{/unless}}
{{#if job_offer.actions }}
<hr />
{{#if job_offer.reviewed}}
{{#if job_offer.actions.unpublish_url }}
{{#> confirm-modal}}
{{#*inline "id"}}{{job_offer.id}}{{/inline}}
{{#*inline "kind"}}unpublish{{/inline}}
{{#*inline "action"}}{{#if job_offer.published}}Un-Publish{{else}}Retract Review{{/if}}{{/inline}}
{{#*inline "formaction"}}{{job_offer.actions.unpublish_url}}{{/inline}}
{{/confirm-modal}}
{{#if job_offer.actions }}
<hr />
{{#if job_offer.reviewed}}
{{#if job_offer.actions.unpublish_url }}
{{#> confirm-modal}}
{{#*inline "id"}}{{job_offer.id}}{{/inline}}
{{#*inline "kind"}}unpublish{{/inline}}
{{#*inline "action"}}{{#if job_offer.published}}Un-Publish{{else}}Retract Review{{/if}}{{/inline}}
{{#*inline "formaction"}}{{job_offer.actions.unpublish_url}}{{/inline}}
{{/confirm-modal}}
{{/if}}
{{else}}
{{#if job_offer.actions.publish_url }}
{{#> confirm-modal}}
{{#*inline "id"}}{{job_offer.id}}{{/inline}}
{{#*inline "kind"}}publish{{/inline}}
{{#*inline "action"}}{{#if job_offer.confirmed}}Publish{{else}}Review{{/if}}{{/inline}}
{{#*inline "formaction"}}{{job_offer.actions.publish_url}}{{/inline}}
{{/confirm-modal}}
{{/if}}
{{/if}}
{{else}}
{{#if job_offer.actions.publish_url }}
{{#if job_offer.actions.delete_url }}
{{#> confirm-modal}}
{{#*inline "id"}}{{job_offer.id}}{{/inline}}
{{#*inline "kind"}}publish{{/inline}}
{{#*inline "action"}}{{#if job_offer.confirmed}}Publish{{else}}Review{{/if}}{{/inline}}
{{#*inline "formaction"}}{{job_offer.actions.publish_url}}{{/inline}}
{{#*inline "kind"}}delete{{/inline}}
{{#*inline "action"}}Delete{{/inline}}
{{#*inline "formaction"}}{{job_offer.actions.delete_url}}{{/inline}}
{{/confirm-modal}}
{{/if}}
{{/if}}
{{#if job_offer.actions.delete_url }}
{{#> confirm-modal}}
{{#*inline "id"}}{{job_offer.id}}{{/inline}}
{{#*inline "kind"}}delete{{/inline}}
{{#*inline "action"}}Delete{{/inline}}
{{#*inline "formaction"}}{{job_offer.actions.delete_url}}{{/inline}}
{{/confirm-modal}}
{{/if}}
<a href="{{job_offer.actions.edit_url}}">Bearbeiten</a>
{{/if}}
{{/if}}
</div>
</section>

View file

@ -1,4 +1,8 @@
{{#> base}}
{{#if user}}
<input id="awaiting-review" class="hidden" type="checkbox">
<div class="joboffer-filters .shadow"><span class="bold-text">Filter:</span> <label class="filter-label" for="awaiting-review">Awaiting Review</label></div>
{{/if}}
<div class="joboffer-index">
{{#each job_offers as |job_offer|}}
{{> job_offer/overview-entry job_offer=job_offer base=../base user=../user}}