features, cleanup and bug fixes #26

Merged
ben merged 30 commits from ben/Jobboerse:main into main 2022-06-09 17:36:55 +02:00
23 changed files with 1369 additions and 482 deletions
Showing only changes of commit 736270c709 - Show all commits

initial edit dialog

also includes some refactoring that should have been 2-3 separate commits, sorry
Bennet Bleßmann 2022-06-01 23:21:01 +02:00 committed by Bennet Bleßmann
Signed by: ben
GPG key ID: 3BE1A1A3CBC3CF99

22
Cargo.lock generated
View file

@ -401,6 +401,15 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
[[package]]
name = "better_toml_datetime"
version = "0.1.0"
dependencies = [
"serde",
"thiserror",
"toml",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@ -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"

View file

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

View 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"

View file

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

View file

@ -0,0 +1,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"

View file

@ -1,11 +1,34 @@
use std::io::{BufWriter, Write};
use std::path::Path;
use std::string::FromUtf8Error;
use actix_multipart::Field;
use actix_multipart::{Field, MultipartError};
use futures_util::StreamExt;
use tempfile::NamedTempFile;
use crate::error::MultipartFieldError;
use crate::ServerConfig;
#[derive(Debug, thiserror::Error)]
pub enum MultipartFieldError {
/// The maximal byte size for a field has been exceeded
#[error("the field {field} limit of {max_byte_size} was exceeded")]
ContentTooLarge {
field: &'static str,
max_byte_size: usize,
},
#[error("the multipart-from data was malformed: {0}")]
Multipart(#[from] MultipartError),
/// File filed has no associated file_ame
#[error("missing filename for attachment in field {field}")]
NotAFile { field: &'static str },
/// A field that can only be specified once occurred a second time
#[error(
"the field {field} occurred more often than expected, expected at most {limit} occurrences"
)]
TooManyOccurrences { field: &'static str, limit: u8 },
#[error("{0}")]
IOError(#[from] std::io::Error),
#[error("{0}")]
UTF8Error(#[from] FromUtf8Error),
}
pub async fn once_field(
field: Field,
@ -45,13 +68,12 @@ pub async fn multi_field(
Ok(())
}
#[allow(unused)]
pub(crate) async fn once_file(
pub async fn once_file(
field: Field,
name: &'static str,
storage: &mut Option<(String, Option<NamedTempFile>)>,
max_size: usize,
config: &ServerConfig,
config: &Path,
) -> Result<(), MultipartFieldError> {
if storage.is_some() {
Err(MultipartFieldError::TooManyOccurrences {
@ -65,13 +87,13 @@ pub(crate) async fn once_file(
}
}
pub(crate) async fn multi_file(
pub async fn multi_file(
field: Field,
name: &'static str,
storage: &mut Vec<(String, Option<NamedTempFile>)>,
max_size: usize,
max_count: Option<u8>,
config: &ServerConfig,
config: &Path,
) -> Result<(), MultipartFieldError> {
if let Some(count) = max_count {
if storage.len() >= count.into() {
@ -90,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,

View file

@ -1,16 +1,13 @@
use std::error::Error;
use std::fmt::{Debug, Display, Formatter};
use std::path::PathBuf;
use std::string::FromUtf8Error;
use actix_multipart::MultipartError;
use actix_web::body::BoxBody;
use actix_web::error::UrlGenerationError;
use actix_web::http::{header, StatusCode};
use actix_web::{HttpResponse, ResponseError};
use handlebars::RenderError;
use ldap3::LdapError;
use lettre::address::AddressError;
use log::{error, warn};
use mime_guess::mime;
use thiserror::Error;
@ -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}")]

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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,
})
}
}

View file

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

View 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";

View file

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

View file

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

View file

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

View 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}}