Compare commits

...

4 commits

14 changed files with 512 additions and 125 deletions

13
TODO.md
View file

@ -32,6 +32,18 @@ A lot of modules in hyaenidae-server have circular dependencies, and a few are m
lines long, each. These should be separated where possible into smaller modules which create a
dependency DAG
## Validation
Right now, a lot of the validation happens in multiple places in the codebase, which is not great. I
think there should be a Single Way we do validation in order to keep ourselves sane in the future
when we're trying to debug stuff
Tasks:
- Enable validation on the [Thing]Changes structs
- ReactChanges
- Distinguish between local & federated when validation maxium lengths
- Make a global maximum length that things get truncated to if they're too long
- Expose validation failures in the user interface
## Pagination
Tasks:
- Enable pagination for notifications
@ -74,6 +86,7 @@ Tasks:
- Enable viewing of closed reports
## Reacts
Rethink whether we actually want to do reacts. It may be that just having Likes is fine
Reacts are similar to "likes" on other platforms, but do not get added to a collection for a given
profile. Reacts only notify the author of the item being reacted to.

View file

@ -47,7 +47,7 @@ impl Action for CreateComment {
changes.body_source(body_source);
}
let comment = changes.save()?;
let comment = changes.save()??;
if let Some(apub_id) = &self.note_apub_id {
ctx.apub.comment(apub_id, comment.id())?;
@ -124,7 +124,7 @@ impl Action for UpdateComment {
changes.body_source(body_source);
}
let comment = if changes.any_changes() {
changes.save()?
changes.save()??
} else {
comment
};

View file

@ -14,10 +14,11 @@ impl Action for CreateProfile {
self.owner_source.clone(),
self.handle.clone(),
self.domain.clone(),
self.published,
);
)?;
changes.login_required(self.login_required);
changes
.login_required(self.login_required)
.published(self.published);
if let Some(display_name) = &self.display_name {
changes.display_name(display_name);
}
@ -34,7 +35,7 @@ impl Action for CreateProfile {
changes.updated(updated);
}
let profile = changes.save()?;
let profile = changes.save()??;
if let Some(apub) = &self.apub {
ctx.apub.profile(&apub.person_apub_id, profile.id())?;
@ -110,7 +111,7 @@ impl Action for UpdateProfile {
}
let profile = if changes.any_changes() {
changes.save()?
changes.save()??
} else {
profile
};

View file

@ -17,7 +17,7 @@ impl Action for CreateServer {
if let Some(description) = &self.description {
changes.description(description);
}
let server = changes.save()?;
let server = changes.save()??;
if let Some(apub) = &self.apub {
ctx.apub.server(&apub.application_apub_id, server.id())?;
@ -66,7 +66,7 @@ impl Action for UpdateServer {
changes.description_source(description_source);
}
let server = if changes.any_changes() {
changes.save()?
changes.save()??
} else {
server
};

View file

@ -32,7 +32,7 @@ impl Action for CreateSubmission {
changes.add_file(*file);
}
let submission = changes.save()?;
let submission = changes.save()??;
let submission_id = submission.id();
if let Some(apub_id) = &self.note_apub_id {
@ -150,7 +150,7 @@ impl Action for UpdateSubmission {
}
let submission = if changes.any_changes() {
changes.save()?
changes.save()??
} else {
submission
};

View file

@ -132,8 +132,11 @@ impl Outbound for ProfileCreated {
shared_inbox: Some(shared_inbox),
..Endpoints::default()
})
.set_preferred_username(profile.handle())
.set_published(profile.published().into());
.set_preferred_username(profile.handle());
if let Some(published) = profile.published() {
person.set_published(published.into());
}
if let Some(display_name) = profile.display_name() {
person.set_name(display_name);
@ -235,9 +238,11 @@ impl Outbound for ProfileUpdated {
person
.set_id(person_id.clone())
.set_preferred_username(profile.handle())
.set_published(profile.published().into());
.set_preferred_username(profile.handle());
if let Some(published) = profile.published() {
person.set_published(published.into());
}
if let Some(outbox) = endpoints.outbox {
person.set_outbox(outbox);
}

View file

@ -24,7 +24,7 @@ mod viewer;
use apub::ApubIds;
use pictrs::ImageInfo;
use store::OwnerSource;
use store::{OwnerSource, ValidationError};
use viewer::Viewer;
#[derive(Debug, thiserror::Error)]
@ -44,6 +44,9 @@ pub enum Error {
#[error("Blocked")]
Blocked,
#[error("Validations failed")]
Validate(Vec<ValidationError>),
#[error("Error deleting file: {0}")]
DeleteFile(#[from] pictrs::DeleteError),
@ -60,6 +63,22 @@ pub enum Error {
Apub(#[from] apub::StoreError),
}
impl From<Vec<ValidationError>> for Error {
fn from(v: Vec<ValidationError>) -> Self {
Self::Validate(v)
}
}
impl Error {
pub fn validation_errors(&self) -> Option<&[ValidationError]> {
if let Self::Validate(errors) = &self {
Some(errors.as_slice())
} else {
None
}
}
}
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)]
pub enum OnBehalfOf {
Profile(Uuid),
@ -116,6 +135,21 @@ impl<T> Required<T> for Option<T> {
}
}
#[derive(Clone, Debug)]
pub struct ContentConfig {
pub max_bio_length: usize,
pub max_display_name_length: usize,
pub max_handle_length: usize,
pub max_domain_length: usize,
pub max_submission_title_length: usize,
pub max_submission_body_length: usize,
pub max_post_title_length: usize,
pub max_post_body_length: usize,
pub max_comment_length: usize,
pub max_server_title_length: usize,
pub max_server_body_length: usize,
}
#[derive(Clone, Copy, Debug)]
enum KeyOwner {
Server(Uuid),
@ -129,6 +163,7 @@ pub struct State {
pub pictrs: pictrs::State,
pub spawner: Arc<dyn Spawner + Send + Sync>,
pub url_for: Arc<dyn UrlFor + Send + Sync>,
pub content_config: ContentConfig,
pub arbiter: Arbiter,
_db: Db,
}
@ -140,15 +175,17 @@ impl State {
apub_info: impl ApubIds + Send + Sync + 'static,
spawner: impl Spawner + Send + Sync + 'static,
url_for: impl UrlFor + Send + Sync + 'static,
content_config: ContentConfig,
arbiter: Arbiter,
db: Db,
) -> Result<Arc<Self>, sled::Error> {
Ok(Arc::new(State {
store: store::Store::build(&db)?,
store: store::Store::build(content_config.max_handle_length, &db)?,
apub: apub::Store::build(apub_info, &db)?,
pictrs: pictrs::State::new(pictrs_upstream, image_info),
spawner: Arc::new(spawner),
url_for: Arc::new(url_for),
content_config,
arbiter,
_db: db,
}))

View file

@ -1,4 +1,4 @@
use super::{StoreError, Undo};
use super::{StoreError, Undo, ValidationError, ValidationErrorKind};
use crate::State;
use chrono::{DateTime, Utc};
use hyaenidae_content::{bbcode, html};
@ -39,6 +39,7 @@ pub struct CommentChanges<'a> {
body_source: Option<String>,
published: Option<DateTime<Utc>>,
updated: Option<DateTime<Utc>>,
errors: Vec<ValidationError>,
}
#[derive(Clone, Debug)]
@ -115,7 +116,8 @@ impl Comment {
body: None,
body_source: None,
published: self.published,
updated: None,
updated: self.updated,
errors: vec![],
}
}
@ -142,11 +144,29 @@ impl<'a> CommentChanges<'a> {
body_source: None,
published: None,
updated: None,
errors: vec![],
}
}
pub(crate) fn body(&mut self, body: &str) -> &mut Self {
self.body = Some(html(body));
let body = html(body.trim());
if body.len() > self.state.content_config.max_comment_length {
self.errors.push(ValidationError {
field: String::from("body"),
kind: ValidationErrorKind::TooLong {
maximum: self.state.content_config.max_comment_length,
proposed: body.len(),
},
});
} else if body.is_empty() {
self.errors.push(ValidationError {
field: String::from("body"),
kind: ValidationErrorKind::Empty,
});
} else {
self.body = Some(body);
}
self
}
@ -163,8 +183,18 @@ impl<'a> CommentChanges<'a> {
}
pub(crate) fn updated(&mut self, updated: DateTime<Utc>) -> &mut Self {
if self.published.is_some() {
self.updated = Some(updated);
if let Some(published) = self.published {
if self.updated.unwrap_or(published) > updated {
self.errors.push(ValidationError {
field: String::from("updated"),
kind: ValidationErrorKind::Outdated {
current: self.updated.unwrap_or(published),
proposed: updated,
},
});
} else {
self.updated = Some(updated);
}
}
self
}
@ -173,8 +203,12 @@ impl<'a> CommentChanges<'a> {
self.body.is_some() || self.published.is_some() || self.updated.is_some()
}
pub(crate) fn save(self) -> Result<Comment, StoreError> {
self.state.store.comments.save(&self)
pub(crate) fn save(self) -> Result<Result<Comment, Vec<ValidationError>>, StoreError> {
if self.errors.is_empty() {
self.state.store.comments.save(&self).map(Ok)
} else {
Ok(Err(self.errors))
}
}
}
@ -319,16 +353,6 @@ impl Store {
let mut stored_comment: StoredComment = serde_json::from_slice(&stored_comment_ivec)?;
if let Some(updated) = changes.updated {
if let Some(previously_updated) =
stored_comment.updated.or_else(|| stored_comment.published)
{
if updated < previously_updated {
return Err(StoreError::Outdated);
}
}
}
if let Some(body) = &changes.body {
stored_comment.body = body.clone();
}

View file

@ -23,6 +23,31 @@ pub use submission::{Store as SubmissionStore, Submission, SubmissionChanges, Vi
pub use term_search::TermSearch;
pub use view::Store as ViewStore;
#[derive(Clone, Debug, thiserror::Error)]
pub enum ValidationErrorKind {
#[error("Provided string is too long, must be shorter than {maximum}")]
TooLong { maximum: usize, proposed: usize },
#[error("Provided string must be present")]
Empty,
#[error("Provided changes {proposed} are older than current data {current}")]
Outdated {
current: DateTime<Utc>,
proposed: DateTime<Utc>,
},
#[error("Tried to save with a file that doesn't exist: {0}")]
MissingFile(Uuid),
}
#[derive(Clone, Debug, thiserror::Error)]
#[error("Failed to validate {field}: {kind}")]
pub struct ValidationError {
field: String,
kind: ValidationErrorKind,
}
#[derive(Clone)]
pub struct Store {
pub profiles: profile::Store,
@ -36,9 +61,9 @@ pub struct Store {
}
impl Store {
pub fn build(db: &Db) -> Result<Store, sled::Error> {
pub fn build(max_handle_length: usize, db: &Db) -> Result<Store, sled::Error> {
Ok(Store {
profiles: profile::Store::build(db)?,
profiles: profile::Store::build(max_handle_length, db)?,
files: file::Store::build(db)?,
submissions: submission::Store::build(db)?,
comments: comment::Store::build(db)?,

View file

@ -1,4 +1,4 @@
use super::{StoreError, TermSearch, Undo};
use super::{StoreError, TermSearch, Undo, ValidationError, ValidationErrorKind};
use crate::State;
use chrono::{DateTime, Utc};
use hyaenidae_content::{bbcode, html, strip};
@ -12,7 +12,6 @@ enum ProfileChangesKind {
source: OwnerSource,
domain: String,
handle: String,
published: DateTime<Utc>,
},
Update {
id: Uuid,
@ -30,7 +29,9 @@ pub struct ProfileChanges<'a> {
login_required: Option<bool>,
icon: Option<Uuid>,
banner: Option<Uuid>,
published: Option<DateTime<Utc>>,
updated: Option<DateTime<Utc>>,
errors: Vec<ValidationError>,
}
#[derive(Clone, Copy, Debug)]
@ -51,11 +52,12 @@ pub struct Profile {
description_source: Option<String>,
icon: Option<Uuid>,
banner: Option<Uuid>,
published: DateTime<Utc>,
published: Option<DateTime<Utc>>,
updated: Option<DateTime<Utc>>,
login_required: bool,
suspended: bool,
}
#[derive(Clone, Debug)]
pub struct Store {
profile_tree: Tree,
@ -78,22 +80,34 @@ struct StoredProfile {
owner_source: StoredOwnerSource,
handle: String,
domain: String,
#[serde(skip_serializing_if = "Option::is_none")]
display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
display_name_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
banner: Option<Uuid>,
published: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
published: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
updated: Option<DateTime<Utc>>,
login_required: bool,
created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
suspended_at: Option<DateTime<Utc>>,
}
@ -104,29 +118,86 @@ impl<'a> ProfileChanges<'a> {
source: OwnerSource,
domain: String,
handle: String,
published: DateTime<Utc>,
) -> Self {
ProfileChanges {
state,
kind: ProfileChangesKind::Create {
source,
domain,
handle,
published,
},
display_name: None,
display_name_source: None,
description: None,
description_source: None,
login_required: None,
icon: None,
banner: None,
updated: None,
) -> Result<Self, Vec<ValidationError>> {
let handle = strip(handle.trim());
let domain = strip(domain.trim());
let mut errors = vec![];
if handle.len() > state.content_config.max_handle_length {
errors.push(ValidationError {
field: String::from("handle"),
kind: ValidationErrorKind::TooLong {
maximum: state.content_config.max_handle_length,
proposed: handle.len(),
},
});
} else if handle.is_empty() {
errors.push(ValidationError {
field: String::from("handle"),
kind: ValidationErrorKind::Empty,
});
}
if domain.len() > state.content_config.max_domain_length {
errors.push(ValidationError {
field: String::from("domain"),
kind: ValidationErrorKind::TooLong {
maximum: state.content_config.max_domain_length,
proposed: domain.len(),
},
});
} else if domain.is_empty() {
errors.push(ValidationError {
field: String::from("domain"),
kind: ValidationErrorKind::Empty,
});
}
if errors.is_empty() {
Ok(ProfileChanges {
state,
kind: ProfileChangesKind::Create {
source,
domain,
handle,
},
display_name: None,
display_name_source: None,
description: None,
description_source: None,
login_required: None,
icon: None,
banner: None,
published: None,
updated: None,
errors: vec![],
})
} else {
Err(errors)
}
}
pub(crate) fn display_name(&mut self, display_name: &str) -> &mut Self {
self.display_name = Some(strip(display_name));
let display_name = strip(display_name.trim());
if display_name.len() > self.state.content_config.max_display_name_length {
self.errors.push(ValidationError {
field: String::from("display_name"),
kind: ValidationErrorKind::TooLong {
maximum: self.state.content_config.max_display_name_length,
proposed: display_name.len(),
},
});
} else if display_name.is_empty() {
self.errors.push(ValidationError {
field: String::from("display_name"),
kind: ValidationErrorKind::Empty,
});
} else {
self.display_name = Some(display_name);
}
self
}
@ -136,7 +207,25 @@ impl<'a> ProfileChanges<'a> {
}
pub(crate) fn description(&mut self, description: &str) -> &mut Self {
self.description = Some(html(description));
let description = html(description.trim());
if description.len() > self.state.content_config.max_bio_length {
self.errors.push(ValidationError {
field: String::from("description"),
kind: ValidationErrorKind::TooLong {
maximum: self.state.content_config.max_bio_length,
proposed: description.len(),
},
});
} else if description.is_empty() {
self.errors.push(ValidationError {
field: String::from("description"),
kind: ValidationErrorKind::Empty,
});
} else {
self.description = Some(description);
}
self
}
@ -160,8 +249,27 @@ impl<'a> ProfileChanges<'a> {
self
}
pub(crate) fn published(&mut self, published: DateTime<Utc>) -> &mut Self {
if self.published.is_none() {
self.published = Some(published);
}
self
}
pub(crate) fn updated(&mut self, updated: DateTime<Utc>) -> &mut Self {
self.updated = Some(updated);
if let Some(published) = self.published {
if self.updated.unwrap_or(published) > updated {
self.errors.push(ValidationError {
field: String::from("updated"),
kind: ValidationErrorKind::Outdated {
current: self.updated.unwrap_or(published),
proposed: updated,
},
});
} else {
self.updated = Some(updated);
}
}
self
}
@ -171,11 +279,14 @@ impl<'a> ProfileChanges<'a> {
|| self.login_required.is_some()
|| self.icon.is_some()
|| self.banner.is_some()
|| self.updated.is_some()
}
pub(crate) fn save(self) -> Result<Profile, StoreError> {
self.state.store.profiles.save(&self)
pub(crate) fn save(self) -> Result<Result<Profile, Vec<ValidationError>>, StoreError> {
if self.errors.is_empty() {
self.state.store.profiles.save(&self).map(Ok)
} else {
Ok(Err(self.errors))
}
}
}
@ -197,7 +308,9 @@ impl Profile {
login_required: None,
icon: None,
banner: None,
updated: None,
published: self.published,
updated: self.updated,
errors: vec![],
}
}
@ -248,7 +361,7 @@ impl Profile {
self.banner
}
pub fn published(&self) -> DateTime<Utc> {
pub fn published(&self) -> Option<DateTime<Utc>> {
self.published
}
@ -266,14 +379,14 @@ impl Profile {
}
impl Store {
pub(super) fn build(db: &Db) -> Result<Self, sled::Error> {
pub(super) fn build(max_handle_length: usize, db: &Db) -> Result<Self, sled::Error> {
Ok(Store {
profile_tree: db.open_tree("profiles/profiles")?,
handle_tree: db.open_tree("profiles/profiles/handles")?,
owner_created_tree: db.open_tree("/profiles/profiles/owner/created")?,
created_tree: db.open_tree("/profiles/profiles/created")?,
count_tree: db.open_tree("/profiles/profiles/count")?,
handle_index: TermSearch::build("handle", 20, db)?,
handle_index: TermSearch::build("handle", max_handle_length, db)?,
})
}
@ -283,9 +396,8 @@ impl Store {
source: OwnerSource,
handle: String,
domain: String,
published: DateTime<Utc>,
) -> ProfileChanges<'a> {
ProfileChanges::new(state, source, domain, handle, published)
) -> Result<ProfileChanges<'a>, Vec<ValidationError>> {
ProfileChanges::new(state, source, domain, handle)
}
fn save<'a>(&self, changes: &ProfileChanges<'a>) -> Result<Profile, StoreError> {
@ -294,9 +406,8 @@ impl Store {
source,
handle,
domain,
published,
} => {
let id = self.do_create(*source, handle, domain, *published)?;
let id = self.do_create(*source, handle, domain)?;
self.do_update(id, changes)
}
ProfileChangesKind::Update { id } => self.do_update(*id, changes),
@ -308,7 +419,6 @@ impl Store {
source: OwnerSource,
handle: &str,
domain: &str,
published: DateTime<Utc>,
) -> Result<Uuid, StoreError> {
let handle_lower = handle.to_lowercase();
@ -338,7 +448,7 @@ impl Store {
description_source: None,
icon: None,
banner: None,
published,
published: None,
updated: None,
login_required: true,
created_at: now,
@ -428,13 +538,6 @@ impl Store {
let mut stored_profile: StoredProfile = serde_json::from_slice(&stored_profile_ivec)?;
if let Some(updated) = profile_changes.updated {
let previously_updated = stored_profile.updated.unwrap_or(stored_profile.published);
if updated < previously_updated {
return Err(StoreError::Outdated);
}
}
if let Some(login_required) = profile_changes.login_required {
stored_profile.login_required = login_required;
}
@ -456,6 +559,9 @@ impl Store {
if let Some(banner) = profile_changes.banner {
stored_profile.banner = Some(banner);
}
if let Some(published) = profile_changes.published {
stored_profile.published = Some(published);
}
if let Some(updated) = profile_changes.updated {
stored_profile.updated = Some(updated);
}

View file

@ -1,4 +1,7 @@
use crate::{store::StoreError, State};
use crate::{
store::{StoreError, ValidationError, ValidationErrorKind},
State,
};
use chrono::{DateTime, Utc};
use hyaenidae_content::{bbcode, html, strip};
use sled::{Db, Transactional, Tree};
@ -33,6 +36,7 @@ pub struct ServerChanges<'a> {
description_source: Option<String>,
published: Option<DateTime<Utc>>,
updated: Option<DateTime<Utc>>,
errors: Vec<ValidationError>,
}
#[derive(Clone)]
@ -94,7 +98,8 @@ impl Server {
description: None,
description_source: None,
published: self.published,
updated: None,
updated: self.updated,
errors: vec![],
}
}
}
@ -110,11 +115,30 @@ impl<'a> ServerChanges<'a> {
description_source: None,
published: None,
updated: None,
errors: vec![],
}
}
pub(crate) fn title(&mut self, title: &str) -> &mut Self {
self.title = Some(strip(title));
let title = strip(title.trim());
if title.len() > self.state.content_config.max_server_title_length {
self.errors.push(ValidationError {
field: String::from("title"),
kind: ValidationErrorKind::TooLong {
maximum: self.state.content_config.max_server_title_length,
proposed: title.len(),
},
});
} else if title.is_empty() {
self.errors.push(ValidationError {
field: String::from("title"),
kind: ValidationErrorKind::Empty,
});
} else {
self.title = Some(title);
}
self
}
@ -124,7 +148,25 @@ impl<'a> ServerChanges<'a> {
}
pub(crate) fn description(&mut self, description: &str) -> &mut Self {
self.description = Some(html(description));
let description = html(description.trim());
if description.len() > self.state.content_config.max_server_body_length {
self.errors.push(ValidationError {
field: String::from("description"),
kind: ValidationErrorKind::TooLong {
maximum: self.state.content_config.max_server_body_length,
proposed: description.len(),
},
});
} else if description.is_empty() {
self.errors.push(ValidationError {
field: String::from("description"),
kind: ValidationErrorKind::Empty,
});
} else {
self.description = Some(description);
}
self
}
@ -141,18 +183,32 @@ impl<'a> ServerChanges<'a> {
}
pub(crate) fn updated(&mut self, updated: DateTime<Utc>) -> &mut Self {
if self.published.is_some() {
self.updated = Some(updated);
if let Some(published) = self.published {
if self.updated.unwrap_or(published) > updated {
self.errors.push(ValidationError {
field: String::from("updated"),
kind: ValidationErrorKind::Outdated {
current: self.updated.unwrap_or(published),
proposed: updated,
},
});
} else {
self.updated = Some(updated);
}
}
self
}
pub(crate) fn any_changes(&self) -> bool {
self.title.is_some() || self.description.is_some()
self.title.is_some() || self.description.is_some() || self.updated.is_some()
}
pub(crate) fn save(self) -> Result<Server, StoreError> {
self.state.store.servers.save(&self)
pub(crate) fn save(self) -> Result<Result<Server, Vec<ValidationError>>, StoreError> {
if self.errors.is_empty() {
self.state.store.servers.save(&self).map(Ok)
} else {
Ok(Err(self.errors))
}
}
}
@ -263,20 +319,6 @@ impl Store {
None => return Err(StoreError::Missing),
};
let stored_updated = self.id_updated.get(id.as_bytes())?.and_then(date_from_ivec);
let stored_published = self
.id_published
.get(id.as_bytes())?
.and_then(date_from_ivec);
if let Some(updated) = changes.updated {
if let Some(previously_updated) = stored_updated.or_else(|| stored_published) {
if updated < previously_updated {
return Err(StoreError::Outdated);
}
}
}
if let Some(title) = &changes.title {
self.id_title.insert(id.as_bytes(), title.as_bytes())?;
}

View file

@ -1,4 +1,4 @@
use super::{StoreError, Undo};
use super::{StoreError, Undo, ValidationError, ValidationErrorKind};
use crate::State;
use chrono::{DateTime, Utc};
use hyaenidae_content::{bbcode, html, strip};
@ -52,6 +52,7 @@ pub struct SubmissionChanges<'a> {
sensitive: Option<bool>,
original_files: Vec<Uuid>,
files: Vec<Uuid>,
errors: Vec<ValidationError>,
}
#[derive(Clone, Debug)]
@ -101,12 +102,13 @@ impl Submission {
description_source: None,
visibility: None,
published: self.published,
updated: None,
updated: self.updated,
local_only: None,
logged_in_only: None,
sensitive: None,
original_files: self.files.clone(),
files: self.files.clone(),
errors: vec![],
}
}
@ -192,11 +194,29 @@ impl<'a> SubmissionChanges<'a> {
sensitive: None,
original_files: vec![],
files: vec![],
errors: vec![],
}
}
pub(crate) fn title(&mut self, title: &str) -> &mut Self {
self.title = Some(strip(title));
let title = strip(title.trim());
if title.len() > self.state.content_config.max_submission_title_length {
self.errors.push(ValidationError {
field: String::from("title"),
kind: ValidationErrorKind::TooLong {
maximum: self.state.content_config.max_submission_title_length,
proposed: title.len(),
},
});
} else if title.is_empty() {
self.errors.push(ValidationError {
field: String::from("title"),
kind: ValidationErrorKind::Empty,
});
} else {
self.title = Some(title);
}
self
}
@ -206,7 +226,24 @@ impl<'a> SubmissionChanges<'a> {
}
pub(crate) fn description(&mut self, description: &str) -> &mut Self {
self.description = Some(html(description));
let description = html(description.trim());
if description.len() > self.state.content_config.max_submission_body_length {
self.errors.push(ValidationError {
field: String::from("description"),
kind: ValidationErrorKind::TooLong {
maximum: self.state.content_config.max_submission_body_length,
proposed: description.len(),
},
});
} else if description.is_empty() {
self.errors.push(ValidationError {
field: String::from("description"),
kind: ValidationErrorKind::Empty,
});
} else {
self.description = Some(description);
}
self
}
@ -230,8 +267,18 @@ impl<'a> SubmissionChanges<'a> {
}
pub(crate) fn updated(&mut self, time: DateTime<Utc>) -> &mut Self {
if self.published.is_some() {
self.updated = Some(time);
if let Some(published) = self.published {
if self.updated.unwrap_or(published) > time {
self.errors.push(ValidationError {
field: String::from("updated"),
kind: ValidationErrorKind::Outdated {
current: self.updated.unwrap_or(published),
proposed: time,
},
})
} else {
self.updated = Some(time);
}
}
self
}
@ -256,7 +303,14 @@ impl<'a> SubmissionChanges<'a> {
}
pub(crate) fn add_file(&mut self, file_id: Uuid) -> &mut Self {
self.files.push(file_id);
if let Ok(Some(_)) = self.state.store.files.by_id(file_id) {
self.files.push(file_id);
} else {
self.errors.push(ValidationError {
field: String::from("files"),
kind: ValidationErrorKind::MissingFile(file_id),
});
}
self
}
@ -277,8 +331,12 @@ impl<'a> SubmissionChanges<'a> {
|| self.original_files != self.files
}
pub(crate) fn save(self) -> Result<Submission, StoreError> {
self.state.store.submissions.save(&self)
pub(crate) fn save(self) -> Result<Result<Submission, Vec<ValidationError>>, StoreError> {
if self.errors.is_empty() {
self.state.store.submissions.save(&self).map(Ok)
} else {
Ok(Err(self.errors))
}
}
}
@ -403,17 +461,6 @@ impl Store {
let mut stored_submission: StoredSubmission =
serde_json::from_slice(&stored_submission_ivec)?;
if let Some(updated) = changes.updated {
if let Some(previously_updated) = stored_submission
.updated
.or_else(|| stored_submission.published)
{
if updated < previously_updated {
return Err(StoreError::Outdated);
}
}
}
let already_published = stored_submission.published.is_some();
if let Some(title) = &changes.title {

View file

@ -22,6 +22,7 @@ struct JobState {
pub(super) fn build(
base_url: Url,
pict_rs_upstream: Url,
content_config: hyaenidae_profiles::ContentConfig,
db: sled::Db,
) -> Result<State, anyhow::Error> {
let storage = Storage::new();
@ -32,6 +33,7 @@ pub(super) fn build(
Spawn(queue_handle.clone()),
base_url,
pict_rs_upstream,
content_config,
Arbiter::current(),
&db,
)?;

View file

@ -82,6 +82,7 @@ async fn main() -> anyhow::Result<()> {
let state = jobs::build(
config.base_url.clone(),
config.pictrs_upstream.clone(),
config.content(),
db.clone(),
)?;
let accounts_state = hyaenidae_accounts::state(&accounts_config, db.clone())?;
@ -195,10 +196,92 @@ struct Config {
)]
skip_signature_validation: bool,
#[structopt(
long,
env = "HYAENIDAE_MAX_BIO_LENGTH",
about = "Maximum allowed characters in a profile bio",
default_value = "500"
)]
max_bio: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_DISPLAY_NAME_LENGTH",
about = "Maximum allowed characters in a profile display name",
default_value = "20"
)]
max_display_name: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_HANDLE_LENGTH",
about = "Maximum allowed characters in a profile handle",
default_value = "20"
)]
max_handle: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_SUBMISSION_TITLE_LENGTH",
about = "Maximum allowed characters in a submission title",
default_value = "50"
)]
max_submission_title: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_SUBMISSION_BODY_LENGTH",
about = "Maximum allowed characters in a submission body",
default_value = "1000"
)]
max_submission_body: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_POST_TITLE_LENGTH",
about = "Maximum allowed characters in a post title",
default_value = "50"
)]
max_post_title: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_POST_BODY_LENGTH",
about = "Maximum allowed characters in a post body",
default_value = "1000"
)]
max_post_body: usize,
#[structopt(
long,
env = "HYAENIDAE_MAX_COMMENT_LENGTH",
about = "Maximum allowed characters in a comment",
default_value = "500"
)]
max_comment: usize,
#[structopt(short, long, about = "enable debug logging")]
debug: bool,
}
impl Config {
fn content(&self) -> hyaenidae_profiles::ContentConfig {
hyaenidae_profiles::ContentConfig {
max_bio_length: self.max_bio,
max_display_name_length: self.max_display_name,
max_handle_length: self.max_handle,
max_domain_length: 256,
max_submission_title_length: self.max_submission_title,
max_submission_body_length: self.max_submission_body,
max_post_title_length: self.max_post_title,
max_post_body_length: self.max_post_body,
max_comment_length: self.max_comment,
max_server_title_length: 50,
max_server_body_length: 1000,
}
}
}
#[derive(Clone)]
struct SecretKey {
inner: Vec<u8>,
@ -245,6 +328,7 @@ impl State {
spawn: jobs::Spawn,
base_url: url::Url,
pict_rs_upstream: url::Url,
content_config: hyaenidae_profiles::ContentConfig,
arbiter: Arbiter,
db: &Db,
) -> Result<Self, Error> {
@ -263,6 +347,7 @@ impl State {
apub.clone(),
spawn.clone(),
Urls,
content_config,
arbiter,
db.clone(),
)?,