features, cleanup and bug fixes #26
47 changed files with 2833 additions and 1151 deletions
164
Cargo.lock
generated
164
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
15
Cargo.toml
15
Cargo.toml
|
|
@ -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
|
||||
|
|
|
|||
22
Changelog.md
22
Changelog.md
|
|
@ -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
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -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)
BIN
THIRDPARTY.toml
(Stored with Git LFS)
Binary file not shown.
|
|
@ -7,3 +7,7 @@ type = 'Development'
|
|||
[email]
|
||||
from = "jobs@localhost"
|
||||
subject = "Test"
|
||||
|
||||
[[footer_links]]
|
||||
title = "Test"
|
||||
url = "https://fs-infmath.uni-kiel.de"
|
||||
|
|
|
|||
|
|
@ -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
2
dist/arch/PKGBUILD
vendored
|
|
@ -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
|
||||
|
|
|
|||
13
packages/better_toml_datetime/Cargo.toml
Normal file
13
packages/better_toml_datetime/Cargo.toml
Normal 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"
|
||||
223
packages/better_toml_datetime/src/lib.rs
Normal file
223
packages/better_toml_datetime/src/lib.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
15
packages/multipart_helper/Cargo.toml
Normal file
15
packages/multipart_helper/Cargo.toml
Normal 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"
|
||||
|
|
@ -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,
|
||||
105
src/auth.rs
105
src/auth.rs
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
102
src/error.rs
102
src/error.rs
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
91
src/job_offers/lease.rs
Normal 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
88
src/job_offers/view.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
38
src/main.rs
38
src/main.rs
|
|
@ -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())
|
||||
|
|
|
|||
257
src/route.rs
257
src/route.rs
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
293
src/route/error_handler.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
73
src/route/form_constants.rs
Normal file
73
src/route/form_constants.rs
Normal 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[]";
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
148
src/route/job_offer/delete.rs
Normal file
148
src/route/job_offer/delete.rs
Normal 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
504
src/route/job_offer/edit.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
111
src/util.rs
111
src/util.rs
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
12
templates/error/400.hb
Normal 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
9
templates/error/429.hb
Normal 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}}
|
||||
|
|
@ -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}}">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
19
templates/job_offer/delete-expired.hb
Normal file
19
templates/job_offer/delete-expired.hb
Normal 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}}
|
||||
86
templates/job_offer/edit.hb
Normal file
86
templates/job_offer/edit.hb
Normal 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}}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue