features, cleanup and bug fixes #26
23 changed files with 1369 additions and 482 deletions
initial edit dialog
also includes some refactoring that should have been 2-3 separate commits, sorry
commit
736270c709
22
Cargo.lock
generated
22
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"
|
||||
|
|
@ -1210,6 +1219,7 @@ dependencies = [
|
|||
"actix-multipart",
|
||||
"actix-session",
|
||||
"actix-web",
|
||||
"better_toml_datetime",
|
||||
"cargo-bundle-licenses",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
|
|
@ -1222,6 +1232,7 @@ dependencies = [
|
|||
"listenfd",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"multipart_helper",
|
||||
"pretty_env_logger",
|
||||
"rand",
|
||||
"serde",
|
||||
|
|
@ -1468,6 +1479,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"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
[workspace]
|
||||
members = [".", "packages/*"]
|
||||
|
||||
[package]
|
||||
name = "jobboerse"
|
||||
version = "0.1.6"
|
||||
|
|
@ -21,6 +24,7 @@ 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"
|
||||
|
|
@ -35,6 +39,7 @@ ldap3 = { version = "0.10.5", default-features = false, features = ["tls-rustls"
|
|||
listenfd = "0.5.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
|
||||
|
|
|
|||
11
packages/better_toml_datetime/Cargo.toml
Normal file
11
packages/better_toml_datetime/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "better_toml_datetime"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# 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)
|
||||
}
|
||||
}
|
||||
13
packages/multipart_helper/Cargo.toml
Normal file
13
packages/multipart_helper/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "multipart_helper"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# 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,8 +112,9 @@ 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 here, we expect it to be present
|
||||
let file_name = field
|
||||
.content_disposition()
|
||||
.get_filename()
|
||||
|
|
@ -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,
|
||||
38
src/error.rs
38
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;
|
||||
|
|
@ -43,41 +40,6 @@ impl Display for LoginRequired {
|
|||
}
|
||||
impl Error for LoginRequired {}
|
||||
|
||||
#[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 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("a required filed was missing: {field}")]
|
||||
MissingField { field: &'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),
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub(crate) enum PresentationError {
|
||||
#[error("Failed to render page template: {0}")]
|
||||
|
|
|
|||
|
|
@ -1,175 +1,38 @@
|
|||
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::ops::{Add, Deref, DerefMut};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_web::error::UrlGenerationError;
|
||||
use actix_web::{HttpRequest, Result};
|
||||
use chrono::{FixedOffset, TimeZone, Timelike};
|
||||
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, Offset};
|
||||
use tokio::sync::{RwLock, RwLockMappedWriteGuard, RwLockReadGuard, RwLockWriteGuard};
|
||||
use url::Url;
|
||||
use view::{JobOfferActions, JobOfferViewData};
|
||||
|
||||
use better_toml_datetime::Date;
|
||||
|
||||
use crate::job_offers::view::JobOfferEditData;
|
||||
use crate::{
|
||||
auth::User,
|
||||
error::PresentationError,
|
||||
job_offers::error::SaveError,
|
||||
route::JOBOFFER_DELETION_ROUTE,
|
||||
route::{JOBOFFER_ATTACHMENT_ROUTE, PREVIEW_ATTACHMENT_ROUTE},
|
||||
route::{JOBOFFER_PUBLISH_ROUTE, JOBOFFER_UNPUBLISH_ROUTE},
|
||||
util::{toml_date_to_chrono_date, toml_datetime_to_chrono_datetime},
|
||||
ServerConfig,
|
||||
};
|
||||
|
||||
pub(crate) mod error;
|
||||
|
||||
#[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 JobOfferViewData {
|
||||
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) mod lease;
|
||||
pub(crate) mod view;
|
||||
|
||||
pub(crate) fn job_data<'i, I: Iterator<Item = (&'i str, &'i JobOffer<PathBuf>)>>(
|
||||
req: &HttpRequest,
|
||||
|
|
@ -200,6 +63,23 @@ pub(crate) struct Attachment<Location> {
|
|||
pub(crate) attachment_location: Location,
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -295,57 +175,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 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 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)]
|
||||
|
|
@ -359,52 +195,6 @@ pub struct JobOffer<AttachmentLocation> {
|
|||
pub(crate) links: Vec<Link>,
|
||||
}
|
||||
|
||||
impl Hash for JobOffer<PathBuf> {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.title.hash(state);
|
||||
self.offering_party.hash(state);
|
||||
self.public_contact_info.hash(state);
|
||||
// TODO date_of_submission
|
||||
if let Some(date) = &self.date_of_submission.date {
|
||||
1.hash(state);
|
||||
date.day.hash(state);
|
||||
date.month.hash(state);
|
||||
date.year.hash(state);
|
||||
} else {
|
||||
0.hash(state);
|
||||
}
|
||||
if let Some(time) = &self.date_of_submission.time {
|
||||
1.hash(state);
|
||||
time.hour.hash(state);
|
||||
time.minute.hash(state);
|
||||
time.second.hash(state);
|
||||
time.nanosecond.hash(state);
|
||||
} else {
|
||||
0.hash(state);
|
||||
}
|
||||
if let Some(offset) = &self.date_of_submission.offset {
|
||||
match offset {
|
||||
Offset::Z => {
|
||||
1.hash(state);
|
||||
}
|
||||
Offset::Custom { hours, minutes } => {
|
||||
2.hash(state);
|
||||
hours.hash(state);
|
||||
minutes.hash(state);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0.hash(state);
|
||||
}
|
||||
self.date_of_expiry.hash(state);
|
||||
self.permanent.hash(state);
|
||||
self.contact_info.hash(state);
|
||||
self.status.hash(state);
|
||||
self.attachments.hash(state);
|
||||
self.links.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for JobOffer<PathBuf> {
|
||||
type Target = JobOfferStatus;
|
||||
|
||||
|
|
@ -527,15 +317,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<JobOfferViewData, PresentationError> {
|
||||
self.to_data(id, req, true, None, confirmation_token)
|
||||
}
|
||||
|
||||
pub(crate) fn is_expired(&self) -> bool {
|
||||
!self.permanent && {
|
||||
let expires_after = self
|
||||
|
|
@ -556,6 +337,53 @@ 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();
|
||||
|
||||
dbg!(hash);
|
||||
|
||||
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>>()?;
|
||||
|
||||
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: self.links.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -597,20 +425,9 @@ impl JobOffer<PathBuf> {
|
|||
.iter()
|
||||
.map(|attachment| {
|
||||
let location = match (is_preview && !self.is_published(), confirmation_token) {
|
||||
(false, _) => req
|
||||
.url_for(
|
||||
JOBOFFER_ATTACHMENT_ROUTE,
|
||||
&[id, attachment.file_name.as_str()],
|
||||
)?
|
||||
.to_string(),
|
||||
(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(),
|
||||
};
|
||||
|
|
@ -897,6 +714,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
|
||||
|
|
|
|||
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<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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@ use actix_web::{get, web, App, HttpServer};
|
|||
use handlebars::Handlebars;
|
||||
use listenfd::ListenFd;
|
||||
use log::{error, LevelFilter, SetLoggerError};
|
||||
// internal imports
|
||||
use job_offers::lease::SubmissionLimiter;
|
||||
use route::error_handler;
|
||||
|
||||
mod auth;
|
||||
|
|
@ -25,7 +27,7 @@ mod template;
|
|||
mod util;
|
||||
|
||||
// internal imports
|
||||
use crate::job_offers::{JobOffers, SubmissionLimiter};
|
||||
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
|
||||
|
|
|
|||
46
src/route.rs
46
src/route.rs
|
|
@ -6,7 +6,7 @@ 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::Serialize;
|
||||
use url::Url;
|
||||
|
||||
mod auth;
|
||||
|
|
@ -18,6 +18,7 @@ pub(crate) use auth::{LOGIN_ROUTE, LOGOUT_ROUTE};
|
|||
pub(crate) use job_offer::{
|
||||
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,
|
||||
|
|
@ -27,6 +28,7 @@ pub(crate) use license::{LICENSES_ROUTE, LICENSE_BUNDLE};
|
|||
use crate::error::PresentationError;
|
||||
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");
|
||||
|
|
@ -57,8 +59,7 @@ struct BaseData<'a> {
|
|||
title: Cow<'a, str>,
|
||||
short_lang: Cow<'a, str>,
|
||||
links: Vec<Link<'a>>,
|
||||
#[serde(serialize_with = "crate::route::urls_as_string")]
|
||||
styles: Vec<Url>,
|
||||
styles: Vec<SerializableUrl>,
|
||||
routes: StaticRoutes,
|
||||
banner: Option<String>,
|
||||
operation_mode: OperationMode,
|
||||
|
|
@ -68,25 +69,25 @@ struct BaseData<'a> {
|
|||
|
||||
#[derive(Serialize)]
|
||||
struct StaticRoutes {
|
||||
#[serde(serialize_with = "crate::route::url_as_string")]
|
||||
#[serde(serialize_with = "crate::util::url_as_string")]
|
||||
licenses: Url,
|
||||
#[serde(serialize_with = "crate::route::url_as_string")]
|
||||
#[serde(serialize_with = "crate::util::url_as_string")]
|
||||
login: Url,
|
||||
#[serde(serialize_with = "crate::route::url_as_string")]
|
||||
#[serde(serialize_with = "crate::util::url_as_string")]
|
||||
logout: Url,
|
||||
#[serde(serialize_with = "crate::route::url_as_string")]
|
||||
#[serde(serialize_with = "crate::util::url_as_string")]
|
||||
sync: Url,
|
||||
#[serde(serialize_with = "crate::route::url_as_string")]
|
||||
#[serde(serialize_with = "crate::util::url_as_string")]
|
||||
index: Url,
|
||||
#[serde(serialize_with = "crate::route::url_as_string")]
|
||||
#[serde(serialize_with = "crate::util::url_as_string")]
|
||||
joboffer_overview: Url,
|
||||
#[serde(serialize_with = "crate::route::url_as_string")]
|
||||
#[serde(serialize_with = "crate::util::url_as_string")]
|
||||
joboffer_create: Url,
|
||||
#[serde(serialize_with = "crate::route::url_as_string")]
|
||||
#[serde(serialize_with = "crate::util::url_as_string")]
|
||||
joboffer_summary: Url,
|
||||
#[serde(serialize_with = "crate::route::url_as_string")]
|
||||
#[serde(serialize_with = "crate::util::url_as_string")]
|
||||
joboffers_delete_expired: Url,
|
||||
#[serde(serialize_with = "crate::route::url_as_string")]
|
||||
#[serde(serialize_with = "crate::util::url_as_string")]
|
||||
joboffers_bulk_delete: Url,
|
||||
}
|
||||
|
||||
|
|
@ -118,23 +119,6 @@ impl StaticRoutes {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Link<'a> {
|
||||
title: &'a str,
|
||||
|
|
@ -146,7 +130,7 @@ fn base<'a>(
|
|||
config: &'a ServerConfig,
|
||||
title: &'a str,
|
||||
) -> Result<BaseData<'a>, UrlGenerationError> {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
use crate::auth::User;
|
||||
use crate::error::{MultipartFieldError, PresentationError};
|
||||
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,
|
||||
};
|
||||
|
|
@ -18,6 +20,7 @@ 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;
|
||||
|
|
@ -73,8 +76,10 @@ pub(crate) fn internal_server_error_handler<B>(
|
|||
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")
|
||||
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
|
||||
|
|
@ -103,44 +108,47 @@ pub(crate) fn bad_request<B>(
|
|||
SubmissionResponseError::MissingLinkOrAttachment => {
|
||||
Some("Es muss mindestens ein Link oder ein Anhang angegeben werden.")
|
||||
}
|
||||
SubmissionResponseError::Form(MultipartFieldError::Date { err:_ }) => {
|
||||
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(MultipartFieldError::ContentTooLarge {
|
||||
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(MultipartFieldError::TooManyOccurrences { field, limit }) => {
|
||||
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(MultipartFieldError::MultipartError(mpe)) => {
|
||||
SubmissionResponseError::Form(FormProcessingError::MultiPart(mpe)|FormProcessingError::Field(MultipartFieldError::Multipart(mpe))) => {
|
||||
warn!("{}", mpe);
|
||||
msg = format!("{}", mpe);
|
||||
Some(msg.as_str())
|
||||
}
|
||||
SubmissionResponseError::Form(MultipartFieldError::NotAFile { field }) => {
|
||||
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(MultipartFieldError::MissingField { field }) => {
|
||||
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(MultipartFieldError::UTF8Error(_err)) => {
|
||||
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(MultipartFieldError::InvalidAddress(reason)) => {
|
||||
SubmissionResponseError::Form(FormProcessingError::InvalidAddress(reason)) => {
|
||||
Some(match reason {
|
||||
AddressError::MissingParts => { "Unvollständige E-Mail Address" }
|
||||
AddressError::Unbalanced => { "Unausgeglichene Klammern '<' & '>' in E-Mail Address. " }
|
||||
|
|
@ -151,8 +159,7 @@ pub(crate) fn bad_request<B>(
|
|||
SubmissionResponseError::Save(_)
|
||||
| SubmissionResponseError::TooManyRequests
|
||||
| SubmissionResponseError::Render(_)
|
||||
| SubmissionResponseError::Form(MultipartFieldError::IOError(_))
|
||||
| SubmissionResponseError::Form(MultipartFieldError::Runtime(_)) => {
|
||||
| SubmissionResponseError::Form(FormProcessingError::Field(MultipartFieldError::IOError(_)))=> {
|
||||
error!(
|
||||
"Response Status Code (Bad Request) and Error appear to disagree : {}",
|
||||
err
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ use serde_json::json;
|
|||
pub(crate) mod confirmation;
|
||||
pub(crate) mod create;
|
||||
pub(crate) mod delete;
|
||||
pub(crate) mod edit;
|
||||
pub(crate) mod error;
|
||||
pub(crate) mod form_constants;
|
||||
pub(crate) mod review;
|
||||
|
||||
use crate::auth::User;
|
||||
|
|
@ -34,6 +36,8 @@ pub fn configure(service: &mut ServiceConfig) {
|
|||
.service(confirmation::reject_joboffer_post)
|
||||
.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)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use serde::Serialize;
|
|||
use serde_json::json;
|
||||
|
||||
use crate::auth::User;
|
||||
use crate::job_offers::JobOfferViewData;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -16,20 +16,21 @@ use tempfile::NamedTempFile;
|
|||
use url::Url;
|
||||
|
||||
use crate::auth::User;
|
||||
use crate::error::{LoginRequired, MultipartFieldError, PresentationError};
|
||||
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::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::SubmissionResponseError;
|
||||
use crate::route::job_offer::error::{FormProcessingError, SubmissionResponseError};
|
||||
use crate::route::job_offer::form_constants::{self, UploadLimits};
|
||||
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};
|
||||
use multipart_helper::{multi_field, multi_file, once_field};
|
||||
|
||||
pub(crate) const JOBOFFER_CREATION_ROUTE: &str = "create_offer";
|
||||
|
||||
|
|
@ -163,13 +164,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>(
|
||||
|
|
@ -224,7 +225,7 @@ pub(crate) async fn create_job_offer<'data, 'config>(
|
|||
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),
|
||||
date_of_expiry: job_offer_form.expires,
|
||||
permanent: is_permanent,
|
||||
attachments: job_offer_form.attachments,
|
||||
links: job_offer_form.links,
|
||||
|
|
@ -254,7 +255,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,
|
||||
}
|
||||
|
||||
|
|
@ -305,7 +306,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;
|
||||
|
|
@ -320,100 +321,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!(
|
||||
|
|
@ -450,45 +492,26 @@ impl JobOfferSubmitForm {
|
|||
|
||||
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 { field: "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 { field: "title" })?,
|
||||
offering_party: offering_party.ok_or(MultipartFieldError::MissingField {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
362
src/route/job_offer/edit.rs
Normal file
362
src/route/job_offer/edit.rs
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
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::job_offer::error::FormProcessingError;
|
||||
use crate::route::job_offer::form_constants;
|
||||
use crate::route::job_offer::form_constants::UploadLimits;
|
||||
use crate::route::{HTML_CONTENT, JOBOFFER_OVERVIEW_ROUTE};
|
||||
use crate::util::{parse_date, parse_datetime, process_links};
|
||||
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, once_field};
|
||||
use serde_json::json;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::Hasher;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[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 = "joboffers_delete_expired";
|
||||
|
||||
#[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 data = json!({
|
||||
"base": base,
|
||||
"job_offer": job_offer
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
dbg!(orig_hash);
|
||||
|
||||
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.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.attachments.len());
|
||||
|
||||
for (offer, form) in offer_mut_ref
|
||||
.attachments
|
||||
.iter_mut()
|
||||
.zip(form_data.attachments)
|
||||
{
|
||||
offer.title = form.title;
|
||||
offer.file_name = form.file_name;
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
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) attachments: Vec<Attachment<()>>,
|
||||
pub(crate) links: Vec<Link>,
|
||||
}
|
||||
|
||||
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_titles = Vec::new();
|
||||
let mut attachment_filenames = 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_TITLES => {
|
||||
multi_field(
|
||||
field,
|
||||
form_constants::ATTACHMENT_TITLES,
|
||||
&mut attachment_titles,
|
||||
form_constants::MAX_ATTACHMENT_TITLE_LEN,
|
||||
upload_count_limit,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
form_constants::ATTACHMENT_FILE_NAME => {
|
||||
multi_field(
|
||||
field,
|
||||
form_constants::ATTACHMENT_FILE_NAME,
|
||||
&mut attachment_filenames,
|
||||
// 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 check
|
||||
form_constants::MAX_ATTACHMENT_TITLE_LEN,
|
||||
upload_count_limit,
|
||||
)
|
||||
.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_filenames.len(), attachment_titles.len());
|
||||
|
||||
let attachments = attachment_titles
|
||||
.into_iter()
|
||||
.zip(attachment_filenames.into_iter())
|
||||
.filter_map(|(title, file_name)| {
|
||||
Some(Attachment {
|
||||
title,
|
||||
file_name,
|
||||
attachment_location: (),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
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,
|
||||
})?,
|
||||
attachments,
|
||||
links,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
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::error::{default_error_response, LoginRequired, PresentationError};
|
||||
use crate::job_offers::error::{DeleteError, SaveError, SaveResponseError};
|
||||
use crate::job_offers::JobofferLoadError;
|
||||
|
||||
|
|
@ -138,7 +141,7 @@ 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] SaveResponseError),
|
||||
#[error("{0}")]
|
||||
|
|
@ -162,21 +165,25 @@ impl From<RenderError> for SubmissionResponseError {
|
|||
impl ResponseError for SubmissionResponseError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
SubmissionResponseError::MissingLinkOrAttachment => StatusCode::BAD_REQUEST,
|
||||
SubmissionResponseError::Form(MultipartFieldError::Date { .. })
|
||||
| SubmissionResponseError::Form(MultipartFieldError::ContentTooLarge { .. })
|
||||
| SubmissionResponseError::Form(MultipartFieldError::MultipartError(_))
|
||||
| SubmissionResponseError::Form(MultipartFieldError::NotAFile { .. })
|
||||
| SubmissionResponseError::Form(MultipartFieldError::TooManyOccurrences { .. })
|
||||
| SubmissionResponseError::Form(MultipartFieldError::MissingField { .. })
|
||||
| SubmissionResponseError::Form(MultipartFieldError::UTF8Error(_))
|
||||
| SubmissionResponseError::Form(MultipartFieldError::InvalidAddress(_)) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
SubmissionResponseError::Form(MultipartFieldError::IOError(_))
|
||||
| SubmissionResponseError::Form(MultipartFieldError::Runtime(_)) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
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,
|
||||
|
|
@ -248,3 +255,27 @@ impl ResponseError for AttachmentResponseError {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
|
|
|||
65
src/route/job_offer/form_constants.rs
Normal file
65
src/route/job_offer/form_constants.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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_FILE_NAME: &str = "file_name[]";
|
||||
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) 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";
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
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";
|
||||
|
|
|
|||
79
src/util.rs
79
src/util.rs
|
|
@ -1,6 +1,9 @@
|
|||
use crate::job_offers::Link;
|
||||
use better_toml_datetime::Offset;
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, Offset as _, TimeZone, Utc};
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use toml::value::Offset;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::str::FromStr;
|
||||
use url::Url;
|
||||
|
||||
// we basically don't do any proper error handling here,
|
||||
// as we mostly expect the format conversion to be infallible
|
||||
|
|
@ -11,7 +14,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};
|
||||
|
||||
|
|
@ -59,6 +62,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> {
|
||||
|
|
@ -71,7 +75,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
|
||||
|
|
@ -84,7 +88,8 @@ pub fn toml_datetime_to_chrono_datetime(
|
|||
minute: 0,
|
||||
second: 0,
|
||||
nanosecond: 0,
|
||||
},
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
let local_datetime = chrono::NaiveDate::from_ymd(
|
||||
|
|
@ -131,3 +136,67 @@ where
|
|||
{
|
||||
<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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@
|
|||
{{/each}}
|
||||
</head>
|
||||
<body>
|
||||
{{> header}}
|
||||
<main>
|
||||
{{> @partial-block }}
|
||||
</main>
|
||||
<div>
|
||||
{{> header}}
|
||||
<main>
|
||||
{{> @partial-block }}
|
||||
</main>
|
||||
</div>
|
||||
{{> footer}}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
77
templates/job_offer/edit.hb
Normal file
77
templates/job_offer/edit.hb
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
{{#> 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[]" value="{{attachment.title}}" />
|
||||
<input type="text" name="file_name[]" value="{{attachment.file_name}}" />
|
||||
<a href="{{attachment.attachment_location}}" target="_blank">View Attachment</a>
|
||||
<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[]" value="{{link.title}}" />
|
||||
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" value="{{link.destination}}" />
|
||||
<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 (Optionale Rückdatierung)</label>
|
||||
<input class="backdate-select" id="offer-backdate" type="datetime-local" name="backdate"><br />
|
||||
|
||||
<label class="review-title" for="approval"checked>Als bereits ge-reviwed markieren</label>
|
||||
<input id="approval" class="review-checkbox" type="checkbox" name="pre_approved" value="approved" checked><br />
|
||||
|
||||
<label class="review-title" for="confirmation">Überspringe die Bestätigung für diese Stellenausschreibung</label>
|
||||
<input id="confirmation" class="confirmation-checkbox" type="checkbox" name="skip_confirmation" value="skip" checked><br />
|
||||
|
||||
<label class="infinite-title" for="permanent">Permanente Stellenausschreibung</label>
|
||||
<input id="permanent" class="infinite-checkbox" type="checkbox" name="permanent" value="permanent"><br />
|
||||
</fieldset>
|
||||
{{/if}}
|
||||
|
||||
<button class="submit-button" type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
{{/base}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue