1383 lines
39 KiB
Rust
1383 lines
39 KiB
Rust
use crate::{
|
|
comments::{Cache, CommentNode},
|
|
error::{Error, OptionExt},
|
|
extensions::SubmissionExt,
|
|
images::{largest_image, FullImage, ImageType},
|
|
middleware::{CurrentSubmission, UserProfile},
|
|
nav::NavState,
|
|
profiles::Settings,
|
|
views::{OwnedProfileView, OwnedSubmissionView},
|
|
ActixLoader, State,
|
|
};
|
|
use actix_web::{client::Client, web, HttpRequest, HttpResponse, Scope};
|
|
use hyaenidae_profiles::{
|
|
apub::actions::{
|
|
CreateComment, CreateReport, CreateSubmission, DeleteSubmission, UpdateSubmission,
|
|
},
|
|
store::{File, Profile, Submission, Visibility},
|
|
};
|
|
use hyaenidae_toolkit::{Button, FileInput, Select, TextInput, Tile};
|
|
use i18n_embed_fl::fl;
|
|
use std::collections::HashMap;
|
|
use uuid::Uuid;
|
|
|
|
pub(super) fn scope() -> Scope {
|
|
web::scope("/submissions")
|
|
.service(
|
|
web::resource("/create")
|
|
.route(web::get().to(files_page))
|
|
.route(web::post().to(upload_files)),
|
|
)
|
|
.service(
|
|
web::scope("/{submission_id}")
|
|
.route("", web::get().to(submission_page))
|
|
.service(
|
|
web::resource("/report")
|
|
.route(web::get().to(report_page))
|
|
.route(web::post().to(report)),
|
|
)
|
|
.route("/report-success", web::get().to(report_success_page))
|
|
.service(
|
|
web::resource("/comment")
|
|
.route(web::get().to(route_to_update_page))
|
|
.route(web::post().to(create_comment)),
|
|
)
|
|
.service(
|
|
web::resource("/update")
|
|
.route(web::get().to(update_page))
|
|
.route(web::post().to(update_submission)),
|
|
)
|
|
.service(
|
|
web::resource("/visibility")
|
|
.route(web::get().to(route_to_update_page))
|
|
.route(web::post().to(update_visibility)),
|
|
)
|
|
.service(
|
|
web::resource("/sensitive")
|
|
.route(web::get().to(route_to_update_page))
|
|
.route(web::post().to(update_sensitive)),
|
|
)
|
|
.service(
|
|
web::resource("/add-file")
|
|
.route(web::get().to(route_to_update_page))
|
|
.route(web::post().to(add_file)),
|
|
)
|
|
.service(
|
|
web::resource("/remove-file")
|
|
.route(web::get().to(route_to_update_page))
|
|
.route(web::post().to(remove_file)),
|
|
)
|
|
.service(
|
|
web::resource("/publish")
|
|
.route(web::get().to(route_to_update_page))
|
|
.route(web::post().to(publish_submission)),
|
|
)
|
|
.service(
|
|
web::resource("/delete")
|
|
.route(web::get().to(route_to_update_page))
|
|
.route(web::post().to(delete_submission)),
|
|
),
|
|
)
|
|
}
|
|
|
|
pub struct ReportView {
|
|
pub(crate) files: HashMap<Uuid, File>,
|
|
pub(crate) submission: Submission,
|
|
pub(crate) author: Profile,
|
|
error: Option<String>,
|
|
value: Option<String>,
|
|
}
|
|
|
|
impl ReportView {
|
|
async fn build(submission_id: Uuid, state: &State) -> Result<Self, Error> {
|
|
let store = state.profiles.clone();
|
|
|
|
let view = web::block(move || {
|
|
let mut files = HashMap::new();
|
|
|
|
let submission = store.store.submissions.by_id(submission_id)?.req()?;
|
|
let author = store.store.profiles.by_id(submission.profile_id())?.req()?;
|
|
|
|
let file_ids = submission
|
|
.files()
|
|
.iter()
|
|
.copied()
|
|
.chain(author.icon())
|
|
.chain(author.banner());
|
|
|
|
for file_id in file_ids {
|
|
if !files.contains_key(&file_id) {
|
|
let file = store.store.files.by_id(file_id)?.req()?;
|
|
files.insert(file.id(), file);
|
|
}
|
|
}
|
|
|
|
Ok(ReportView {
|
|
files,
|
|
submission,
|
|
author,
|
|
error: None,
|
|
value: None,
|
|
})
|
|
})
|
|
.await?;
|
|
|
|
Ok(view)
|
|
}
|
|
|
|
pub(crate) fn submission(&self) -> OwnedSubmissionView {
|
|
OwnedSubmissionView {
|
|
submission: self.submission.clone(),
|
|
files: self
|
|
.submission
|
|
.files()
|
|
.iter()
|
|
.filter_map(move |file_id| self.files.get(file_id))
|
|
.map(|file| file.clone())
|
|
.collect(),
|
|
current_file: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn author(&self) -> OwnedProfileView {
|
|
OwnedProfileView {
|
|
profile: self.author.clone(),
|
|
icon: self
|
|
.author
|
|
.icon()
|
|
.and_then(|i| self.files.get(&i))
|
|
.map(|i| i.clone()),
|
|
banner: self
|
|
.author
|
|
.banner()
|
|
.and_then(|b| self.files.get(&b))
|
|
.map(|b| b.clone()),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn input(&self, loader: &ActixLoader) -> TextInput {
|
|
let input = TextInput::new("body")
|
|
.title(&fl!(loader, "report-input"))
|
|
.placeholder(&fl!(loader, "report-placeholder"))
|
|
.textarea();
|
|
|
|
let input = if let Some(error) = &self.error {
|
|
input.error_opt(Some(error.to_owned()))
|
|
} else {
|
|
input
|
|
};
|
|
|
|
if let Some(value) = &self.value {
|
|
input.value(value)
|
|
} else {
|
|
input
|
|
}
|
|
}
|
|
|
|
fn error_opt(&mut self, opt: Option<String>) -> &mut Self {
|
|
self.error = opt;
|
|
self
|
|
}
|
|
|
|
fn value(&mut self, text: String) -> &mut Self {
|
|
self.value = Some(text);
|
|
self
|
|
}
|
|
}
|
|
|
|
async fn report_page(
|
|
loader: ActixLoader,
|
|
submission_id: web::Path<Uuid>,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let profile = profile.0;
|
|
|
|
let view = ReportView::build(submission_id.into_inner(), &state).await?;
|
|
|
|
let can_view_sensitive = state.settings.for_profile(profile.id()).await?.sensitive;
|
|
|
|
if let Some(res) = can_view(
|
|
Some(profile.id()),
|
|
can_view_sensitive,
|
|
&view.submission,
|
|
&state,
|
|
)
|
|
.await?
|
|
{
|
|
return Ok(res);
|
|
}
|
|
|
|
crate::rendered(HttpResponse::Ok(), |cursor| {
|
|
crate::templates::submissions::report(cursor, &loader, &view, &nav_state)
|
|
})
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
struct ReportForm {
|
|
body: String,
|
|
}
|
|
|
|
const MAX_REPORT_LEN: usize = 1000;
|
|
|
|
async fn report(
|
|
loader: ActixLoader,
|
|
form: web::Form<ReportForm>,
|
|
submission_id: web::Path<Uuid>,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let profile = profile.0;
|
|
|
|
let mut view = ReportView::build(submission_id.into_inner(), &state).await?;
|
|
|
|
let can_view_sensitive = state.settings.for_profile(profile.id()).await?.sensitive;
|
|
|
|
if let Some(res) = can_view(
|
|
Some(profile.id()),
|
|
can_view_sensitive,
|
|
&view.submission,
|
|
&state,
|
|
)
|
|
.await?
|
|
{
|
|
return Ok(res);
|
|
}
|
|
|
|
let form = form.into_inner();
|
|
|
|
let error = if form.body.len() > MAX_REPORT_LEN {
|
|
format!("Must be shorter than {} characters", MAX_REPORT_LEN)
|
|
} else if form.body.trim().is_empty() {
|
|
format!("Must be present")
|
|
} else {
|
|
let res = state
|
|
.profiles
|
|
.run(CreateReport::from_submission(
|
|
view.submission.id(),
|
|
profile.id(),
|
|
Some(form.body.clone()),
|
|
))
|
|
.await;
|
|
|
|
match res {
|
|
Ok(_) => return Ok(to_report_success_page(view.submission.id())),
|
|
Err(e) => e.to_string(),
|
|
}
|
|
};
|
|
|
|
view.error_opt(Some(error)).value(form.body);
|
|
|
|
crate::rendered(HttpResponse::BadRequest(), |cursor| {
|
|
crate::templates::submissions::report(cursor, &loader, &view, &nav_state)
|
|
})
|
|
}
|
|
|
|
async fn report_success_page(
|
|
loader: ActixLoader,
|
|
submission_id: web::Path<Uuid>,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let profile = profile.0;
|
|
|
|
let view = ReportView::build(submission_id.into_inner(), &state).await?;
|
|
|
|
let can_view_sensitive = state.settings.for_profile(profile.id()).await?.sensitive;
|
|
|
|
if let Some(res) = can_view(
|
|
Some(profile.id()),
|
|
can_view_sensitive,
|
|
&view.submission,
|
|
&state,
|
|
)
|
|
.await?
|
|
{
|
|
return Ok(res);
|
|
}
|
|
|
|
crate::rendered(HttpResponse::Ok(), |cursor| {
|
|
crate::templates::submissions::report_success(cursor, &loader, &view, &nav_state)
|
|
})
|
|
}
|
|
|
|
fn to_report_success_page(submission_id: Uuid) -> HttpResponse {
|
|
crate::redirect(&format!("/submissions/{}/report-success", submission_id))
|
|
}
|
|
|
|
pub struct SubmissionState {
|
|
pub(crate) files: HashMap<Uuid, File>,
|
|
pub(crate) submission: Submission,
|
|
title_value: Option<String>,
|
|
title_error: Option<String>,
|
|
description_value: Option<String>,
|
|
description_error: Option<String>,
|
|
file_error: Option<String>,
|
|
pub(crate) remove_file_error: HashMap<Uuid, String>,
|
|
pub(crate) publish_error: Option<String>,
|
|
pub(crate) delete_error: Option<String>,
|
|
pub(crate) visibility_error: Option<String>,
|
|
pub(crate) sensitive_error: Option<String>,
|
|
pub(crate) settings: Settings,
|
|
}
|
|
|
|
impl SubmissionState {
|
|
async fn new(submission: Submission, state: &State) -> Result<Self, Error> {
|
|
let file_ids: Vec<Uuid> = submission.files().iter().copied().collect();
|
|
|
|
let settings = state.settings.for_profile(submission.profile_id()).await?;
|
|
|
|
let store = state.profiles.clone();
|
|
let files = web::block(move || {
|
|
let mut files = HashMap::new();
|
|
|
|
for file_id in file_ids {
|
|
if !files.contains_key(&file_id) {
|
|
let file = store.store.files.by_id(file_id)?.req()?;
|
|
files.insert(file.id(), file);
|
|
}
|
|
}
|
|
|
|
Ok(files) as Result<HashMap<Uuid, File>, Error>
|
|
})
|
|
.await?;
|
|
|
|
Ok(SubmissionState {
|
|
files,
|
|
submission,
|
|
title_value: None,
|
|
title_error: None,
|
|
description_value: None,
|
|
description_error: None,
|
|
file_error: None,
|
|
remove_file_error: HashMap::new(),
|
|
publish_error: None,
|
|
delete_error: None,
|
|
visibility_error: None,
|
|
sensitive_error: None,
|
|
settings,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn visibility(&self, loader: &ActixLoader) -> Select {
|
|
Select::new("visibility")
|
|
.title(&fl!(loader, "submission-visibility-select"))
|
|
.options(&[
|
|
(&fl!(loader, "submission-visibility-followers"), "Followers"),
|
|
(&fl!(loader, "submission-visibility-unlisted"), "Unlisted"),
|
|
(&fl!(loader, "submission-visibility-public"), "Public"),
|
|
])
|
|
.default_option(&self.submission.visibility().to_string())
|
|
}
|
|
|
|
pub(crate) fn title_input(&self, loader: &ActixLoader) -> TextInput {
|
|
TextInput::new("title")
|
|
.title(&fl!(loader, "update-submission-title-input"))
|
|
.placeholder(&fl!(loader, "update-submission-title-placeholder"))
|
|
.value(
|
|
self.title_value.as_deref().unwrap_or(
|
|
self.submission
|
|
.title_source()
|
|
.unwrap_or(self.submission.title()),
|
|
),
|
|
)
|
|
.error_opt(self.title_error.clone())
|
|
}
|
|
|
|
pub(crate) fn description_input(&self, loader: &ActixLoader) -> TextInput {
|
|
let input = TextInput::new("description")
|
|
.title(&fl!(loader, "update-submission-description-input"))
|
|
.placeholder(&fl!(loader, "update-submission-description-placeholder"))
|
|
.textarea()
|
|
.error_opt(self.description_error.clone());
|
|
|
|
if let Some(text) = self
|
|
.description_value
|
|
.as_deref()
|
|
.or_else(|| self.submission.description_source())
|
|
{
|
|
input.value(text)
|
|
} else {
|
|
input
|
|
}
|
|
}
|
|
|
|
pub(crate) fn file_input(&self, loader: &ActixLoader) -> FileInput {
|
|
file_input(loader)
|
|
}
|
|
|
|
pub(crate) fn is_published(&self) -> bool {
|
|
self.submission.published().is_some()
|
|
}
|
|
|
|
pub(crate) fn view_path(&self) -> String {
|
|
self.submission.view_path()
|
|
}
|
|
|
|
pub(crate) fn visibility_path(&self) -> String {
|
|
format!("/submissions/{}/visibility", self.submission.id())
|
|
}
|
|
|
|
pub(crate) fn sensitive_path(&self) -> String {
|
|
format!("/submissions/{}/sensitive", self.submission.id())
|
|
}
|
|
|
|
pub(crate) fn update_path(&self) -> String {
|
|
format!("/submissions/{}/update", self.submission.id())
|
|
}
|
|
|
|
pub(crate) fn add_file_path(&self) -> String {
|
|
format!("/submissions/{}/add-file", self.submission.id())
|
|
}
|
|
|
|
pub(crate) fn remove_file_path(&self) -> String {
|
|
format!("/submissions/{}/remove-file", self.submission.id())
|
|
}
|
|
|
|
pub(crate) fn publish_path(&self) -> String {
|
|
format!("/submissions/{}/publish", self.submission.id())
|
|
}
|
|
|
|
pub(crate) fn delete_path(&self) -> String {
|
|
format!("/submissions/{}/delete", self.submission.id())
|
|
}
|
|
|
|
pub(crate) fn images<'a>(&'a self) -> impl Iterator<Item = (Uuid, FullImage)> + 'a {
|
|
self.submission
|
|
.files()
|
|
.iter()
|
|
.filter_map(move |file_id| self.files.get(file_id))
|
|
.filter_map(|file| Some((file.id(), file.pictrs_key()?)))
|
|
.enumerate()
|
|
.map(move |(index, (id, key))| {
|
|
let title = format!("{} file {}", self.submission.title_text(), index + 1);
|
|
|
|
(id, FullImage::new(key, &title))
|
|
})
|
|
}
|
|
|
|
fn title(mut self, text: &str) -> Self {
|
|
self.title_value = Some(text.to_owned());
|
|
self
|
|
}
|
|
|
|
fn title_error(mut self, error: String) -> Self {
|
|
self.title_error = Some(error);
|
|
self
|
|
}
|
|
|
|
fn description(mut self, text: &str) -> Self {
|
|
self.description_value = Some(text.to_owned());
|
|
self
|
|
}
|
|
|
|
fn description_error(mut self, error: Option<String>) -> Self {
|
|
self.description_error = error;
|
|
self
|
|
}
|
|
|
|
fn file_error(mut self, error: String) -> Self {
|
|
self.file_error = Some(error);
|
|
self
|
|
}
|
|
|
|
fn remove_file_error(mut self, file_id: Uuid, error: String) -> Self {
|
|
self.remove_file_error.insert(file_id, error);
|
|
self
|
|
}
|
|
|
|
fn publish_error(mut self, error: String) -> Self {
|
|
self.publish_error = Some(error);
|
|
self
|
|
}
|
|
|
|
fn delete_error(mut self, error: String) -> Self {
|
|
self.delete_error = Some(error);
|
|
self
|
|
}
|
|
|
|
fn visibility_error(mut self, error: String) -> Self {
|
|
self.visibility_error = Some(error);
|
|
self
|
|
}
|
|
|
|
fn sensitive_error(mut self, error: String) -> Self {
|
|
self.sensitive_error = Some(error);
|
|
self
|
|
}
|
|
}
|
|
|
|
async fn files_page(
|
|
loader: ActixLoader,
|
|
_: UserProfile,
|
|
nav_state: NavState,
|
|
) -> Result<HttpResponse, Error> {
|
|
let file_input = file_input(&loader);
|
|
|
|
crate::rendered(HttpResponse::Ok(), |cursor| {
|
|
crate::templates::submissions::create(cursor, &loader, file_input, None, &nav_state)
|
|
})
|
|
}
|
|
|
|
async fn upload_files(
|
|
loader: ActixLoader,
|
|
profile: UserProfile,
|
|
req: HttpRequest,
|
|
pl: web::Payload,
|
|
nav_state: NavState,
|
|
client: web::Data<Client>,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let profile = profile.0;
|
|
|
|
let res = state
|
|
.profiles
|
|
.upload_image(req, pl.into_inner(), &client)
|
|
.await;
|
|
|
|
let error = match res {
|
|
Ok(file_ids) if file_ids.len() == 1 => {
|
|
let res = state
|
|
.profiles
|
|
.run(CreateSubmission::from_file(profile.id(), file_ids[0]))
|
|
.await;
|
|
|
|
match res {
|
|
Ok(Some(submission_id)) => return Ok(to_update_page(submission_id)),
|
|
Ok(None) => "Error creating submission".to_string(),
|
|
Err(e) => e.to_string(),
|
|
}
|
|
}
|
|
Ok(_) => "Incorrect number of files".to_owned(),
|
|
Err(e) => e.to_string(),
|
|
};
|
|
|
|
let file_input = file_input(&loader);
|
|
|
|
crate::rendered(HttpResponse::BadRequest(), |cursor| {
|
|
crate::templates::submissions::create(cursor, &loader, file_input, Some(error), &nav_state)
|
|
})
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
struct FileQuery {
|
|
file_page: usize,
|
|
}
|
|
|
|
pub struct ViewSubmissionState {
|
|
pub(crate) cache: Cache,
|
|
pub(crate) profile: Option<Profile>,
|
|
pub(crate) submission: Submission,
|
|
pub(crate) poster: Profile,
|
|
pub(crate) current_file: Uuid,
|
|
pub(crate) nav: Vec<Button>,
|
|
pub(crate) is_self: bool,
|
|
comment_error: Option<String>,
|
|
comment_value: Option<String>,
|
|
pub(crate) comments: CommentNode,
|
|
}
|
|
|
|
impl ViewSubmissionState {
|
|
pub(crate) fn image(&self) -> Option<FullImage> {
|
|
let file = self.cache.files.get(&self.current_file)?;
|
|
let key = file.pictrs_key()?;
|
|
|
|
Some(FullImage::new(key, self.submission.title()))
|
|
}
|
|
|
|
pub(crate) fn comment_input(&self, loader: &ActixLoader) -> TextInput {
|
|
let input = TextInput::new("body")
|
|
.title(&fl!(loader, "comment-input"))
|
|
.placeholder(&fl!(loader, "comment-placeholder"))
|
|
.textarea()
|
|
.error_opt(self.comment_error.clone());
|
|
|
|
if let Some(value) = &self.comment_value {
|
|
input.value(value)
|
|
} else {
|
|
input
|
|
}
|
|
}
|
|
|
|
pub(crate) fn parent(&self) -> CommentNode {
|
|
CommentNode {
|
|
item: crate::comments::Item::Submission(self.submission.id()),
|
|
author_id: self.submission.profile_id(),
|
|
is_self: self.is_self,
|
|
children: vec![],
|
|
}
|
|
}
|
|
|
|
pub(crate) fn tiles(&self, loader: &ActixLoader) -> Vec<Tile> {
|
|
OwnedSubmissionView {
|
|
submission: self.submission.clone(),
|
|
files: self
|
|
.submission
|
|
.files()
|
|
.iter()
|
|
.filter_map(move |file_id| self.cache.files.get(file_id))
|
|
.map(|file| file.clone())
|
|
.collect(),
|
|
current_file: Some(self.current_file),
|
|
}
|
|
.tiles(loader)
|
|
}
|
|
|
|
pub(crate) fn poster(&self) -> OwnedProfileView {
|
|
OwnedProfileView {
|
|
profile: self.poster.clone(),
|
|
icon: self
|
|
.poster
|
|
.icon()
|
|
.and_then(|i| self.cache.files.get(&i))
|
|
.map(|i| i.clone()),
|
|
banner: self
|
|
.poster
|
|
.banner()
|
|
.and_then(|b| self.cache.files.get(&b))
|
|
.map(|b| b.clone()),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn can_view(
|
|
viewer: Option<Uuid>,
|
|
can_view_sensitive: bool,
|
|
submission: &Submission,
|
|
state: &State,
|
|
) -> Result<Option<HttpResponse>, Error> {
|
|
let store = state.profiles.clone();
|
|
let submission = submission.clone();
|
|
let opt = web::block(move || {
|
|
Ok(crate::pagination::submission::can_view(
|
|
viewer,
|
|
&submission,
|
|
&store.store,
|
|
&mut Default::default(),
|
|
true,
|
|
can_view_sensitive,
|
|
)) as Result<Option<()>, Error>
|
|
})
|
|
.await?;
|
|
|
|
if opt.is_none() {
|
|
return Ok(Some(crate::to_404()));
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
async fn files_for_submission(
|
|
submission: &Submission,
|
|
mut cache: Cache,
|
|
state: &State,
|
|
) -> Result<Cache, Error> {
|
|
let file_ids: Vec<Uuid> = submission.files().iter().copied().collect();
|
|
let store = state.profiles.clone();
|
|
|
|
let cache = web::block(move || {
|
|
for file_id in file_ids {
|
|
if !cache.files.contains_key(&file_id) {
|
|
let file = store.store.files.by_id(file_id)?.req()?;
|
|
cache.files.insert(file.id(), file);
|
|
}
|
|
}
|
|
|
|
Ok(cache) as Result<Cache, Error>
|
|
})
|
|
.await?;
|
|
|
|
Ok(cache)
|
|
}
|
|
|
|
async fn files_for_profile(
|
|
profile: &Profile,
|
|
mut cache: Cache,
|
|
state: &State,
|
|
) -> Result<Cache, Error> {
|
|
let file_ids: Vec<Uuid> = profile
|
|
.icon()
|
|
.iter()
|
|
.chain(profile.banner().iter())
|
|
.copied()
|
|
.collect();
|
|
let store = state.profiles.clone();
|
|
|
|
let cache = web::block(move || {
|
|
for file_id in file_ids {
|
|
if !cache.files.contains_key(&file_id) {
|
|
let file = store.store.files.by_id(file_id)?.req()?;
|
|
cache.files.insert(file.id(), file);
|
|
}
|
|
}
|
|
|
|
Ok(cache)
|
|
})
|
|
.await?;
|
|
|
|
Ok(cache)
|
|
}
|
|
|
|
fn submission_nav(
|
|
page: Option<FileQuery>,
|
|
submission: &Submission,
|
|
next_submission: Option<Uuid>,
|
|
previous_submission: Option<Uuid>,
|
|
files: &HashMap<Uuid, File>,
|
|
) -> (Vec<Button>, Uuid) {
|
|
let file_count = submission.files().len();
|
|
let file_num: usize = if let Some(page) = page {
|
|
file_count.saturating_sub(1).min(page.file_page)
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let current_file_id = submission.files()[file_num];
|
|
let current_file = files.get(¤t_file_id);
|
|
|
|
let mut nav = vec![];
|
|
if let Some(prev) = previous_submission {
|
|
nav.push(Button::secondary("Previous").href(&format!("/submissions/{}", prev)));
|
|
}
|
|
if let Some(key) = current_file.and_then(|file| file.pictrs_key()) {
|
|
nav.push(Button::secondary("Download").href(&largest_image(key, ImageType::Png)));
|
|
}
|
|
nav.push(Button::primary_outline("Report").href(&submission.report_path()));
|
|
if let Some(next) = next_submission {
|
|
nav.push(Button::secondary("Next").href(&format!("/submissions/{}", next)));
|
|
}
|
|
|
|
(nav, current_file_id)
|
|
}
|
|
|
|
async fn adjacent_submissions(
|
|
viewer: Option<Uuid>,
|
|
can_view_sensitive: bool,
|
|
submission: &Submission,
|
|
state: &State,
|
|
) -> Result<(Option<Uuid>, Option<Uuid>), Error> {
|
|
let submission_id = submission.id();
|
|
let is_published = submission.published().is_some();
|
|
let store = state.profiles.clone();
|
|
let (next, prev) = web::block(move || {
|
|
if is_published {
|
|
let inner_store = store.clone();
|
|
let prev = store
|
|
.store
|
|
.submissions
|
|
.published_newer_than_for_profile(submission_id)
|
|
.filter(|id| *id != submission_id)
|
|
.find_map(move |id| {
|
|
let submission = inner_store.store.submissions.by_id(id).ok()??;
|
|
|
|
crate::pagination::submission::can_view(
|
|
viewer,
|
|
&submission,
|
|
&inner_store.store,
|
|
&mut Default::default(),
|
|
true,
|
|
can_view_sensitive,
|
|
)
|
|
.map(move |_| id)
|
|
});
|
|
|
|
let next = store
|
|
.store
|
|
.submissions
|
|
.published_older_than_for_profile(submission_id)
|
|
.filter(|id| *id != submission_id)
|
|
.find_map(move |id| {
|
|
let submission = store.store.submissions.by_id(id).ok()??;
|
|
|
|
crate::pagination::submission::can_view(
|
|
viewer,
|
|
&submission,
|
|
&store.store,
|
|
&mut Default::default(),
|
|
true,
|
|
can_view_sensitive,
|
|
)
|
|
.map(move |_| id)
|
|
});
|
|
|
|
Ok((next, prev)) as Result<_, Error>
|
|
} else {
|
|
let inner_store = store.clone();
|
|
let prev = store
|
|
.store
|
|
.submissions
|
|
.drafted_newer_than_for_profile(submission_id)
|
|
.filter(|id| *id != submission_id)
|
|
.find_map(move |id| {
|
|
let submission = inner_store.store.submissions.by_id(id).ok()??;
|
|
|
|
crate::pagination::submission::can_view(
|
|
viewer,
|
|
&submission,
|
|
&inner_store.store,
|
|
&mut Default::default(),
|
|
true,
|
|
can_view_sensitive,
|
|
)
|
|
.map(move |_| id)
|
|
});
|
|
|
|
let next = store
|
|
.store
|
|
.submissions
|
|
.drafted_older_than_for_profile(submission_id)
|
|
.filter(|id| *id != submission_id)
|
|
.find_map(move |id| {
|
|
let submission = store.store.submissions.by_id(id).ok()??;
|
|
|
|
crate::pagination::submission::can_view(
|
|
viewer,
|
|
&submission,
|
|
&store.store,
|
|
&mut Default::default(),
|
|
true,
|
|
can_view_sensitive,
|
|
)
|
|
.map(move |_| id)
|
|
});
|
|
|
|
Ok((next, prev)) as Result<_, Error>
|
|
}
|
|
})
|
|
.await?;
|
|
|
|
Ok((next, prev))
|
|
}
|
|
|
|
async fn submission_page(
|
|
loader: ActixLoader,
|
|
profile: Option<UserProfile>,
|
|
submission: Option<CurrentSubmission>,
|
|
page: Option<web::Query<FileQuery>>,
|
|
nav_state: NavState,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let submission = submission.req()?.0;
|
|
let profile = profile.map(|p| p.0);
|
|
|
|
let viewer = profile.as_ref().map(|p| p.id());
|
|
let is_self = viewer
|
|
.map(|id| id == submission.profile_id())
|
|
.unwrap_or(false);
|
|
|
|
let poster_id = submission.profile_id();
|
|
let store = state.profiles.clone();
|
|
let poster = web::block(move || store.store.profiles.by_id(poster_id)?.req()).await?;
|
|
|
|
let cache = Cache::new();
|
|
let cache = files_for_submission(&submission, cache, &state).await?;
|
|
let cache = files_for_profile(&poster, cache, &state).await?;
|
|
|
|
let can_view_sensitive = if let Some(viewer) = viewer {
|
|
state.settings.for_profile(viewer).await?.sensitive
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if let Some(res) = can_view(viewer, can_view_sensitive, &submission, &state).await? {
|
|
return Ok(res);
|
|
}
|
|
|
|
let (next_submission, previous_submission) =
|
|
adjacent_submissions(viewer, can_view_sensitive, &submission, &state).await?;
|
|
|
|
let (nav, current_file) = submission_nav(
|
|
page.map(|p| p.into_inner()),
|
|
&submission,
|
|
next_submission,
|
|
previous_submission,
|
|
&cache.files,
|
|
);
|
|
|
|
let (comments, cache) =
|
|
CommentNode::from_submission(submission.id(), viewer, cache, &state).await?;
|
|
|
|
let view = ViewSubmissionState {
|
|
cache,
|
|
profile,
|
|
submission,
|
|
poster,
|
|
current_file,
|
|
nav,
|
|
is_self,
|
|
comment_value: None,
|
|
comment_error: None,
|
|
comments,
|
|
};
|
|
|
|
crate::rendered(HttpResponse::Ok(), |cursor| {
|
|
crate::templates::submissions::public(cursor, &loader, &view, &nav_state)
|
|
})
|
|
}
|
|
|
|
async fn update_page(
|
|
loader: ActixLoader,
|
|
submission: CurrentSubmission,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let submission = submission.0;
|
|
let profile = profile.0;
|
|
|
|
if submission.profile_id() != profile.id() {
|
|
return Ok(crate::to_404());
|
|
}
|
|
|
|
let state = SubmissionState::new(submission, &state).await?;
|
|
|
|
crate::rendered(HttpResponse::Ok(), |cursor| {
|
|
crate::templates::submissions::update(cursor, &loader, &state, &nav_state)
|
|
})
|
|
}
|
|
|
|
const MAX_TITLE_LEN: usize = 50;
|
|
const MAX_DESCRIPTION_LEN: usize = 500;
|
|
const MAX_COMMENT_LEN: usize = 500;
|
|
|
|
fn validate_title(title: &str) -> Option<String> {
|
|
if title.len() > MAX_TITLE_LEN {
|
|
return Some(format!("Must be shorter than {} characters", MAX_TITLE_LEN));
|
|
}
|
|
|
|
if title.trim().len() == 0 {
|
|
return Some(format!("Must be present"));
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn validate_description(description: &str) -> Option<String> {
|
|
if description.len() > MAX_DESCRIPTION_LEN {
|
|
return Some(format!(
|
|
"Must be shorter than {} characters",
|
|
MAX_DESCRIPTION_LEN
|
|
));
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
struct CommentForm {
|
|
body: String,
|
|
}
|
|
|
|
async fn create_comment(
|
|
loader: ActixLoader,
|
|
form: web::Form<CommentForm>,
|
|
submission: CurrentSubmission,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
page: Option<web::Query<FileQuery>>,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let submission = submission.0;
|
|
let profile = profile.0;
|
|
|
|
let form = form.into_inner();
|
|
|
|
let store = state.profiles.clone();
|
|
let poster_id = submission.profile_id();
|
|
let poster = web::block(move || store.store.profiles.by_id(poster_id)?.req()).await?;
|
|
|
|
let can_view_sensitive = state.settings.for_profile(profile.id()).await?.sensitive;
|
|
|
|
if let Some(res) = can_view(Some(profile.id()), can_view_sensitive, &submission, &state).await?
|
|
{
|
|
return Ok(res);
|
|
}
|
|
|
|
let error = if form.body.trim().is_empty() {
|
|
"Must be present".to_string()
|
|
} else if form.body.len() > MAX_COMMENT_LEN {
|
|
format!("Must be shorter than {} characters", MAX_COMMENT_LEN)
|
|
} else {
|
|
let res = state
|
|
.profiles
|
|
.run(CreateComment::from_text(
|
|
submission.id(),
|
|
profile.id(),
|
|
None,
|
|
form.body.clone(),
|
|
))
|
|
.await;
|
|
|
|
match res {
|
|
Ok(_) => return Ok(to_submission_page(submission.id())),
|
|
Err(e) => e.to_string(),
|
|
}
|
|
};
|
|
|
|
let cache = Cache::new();
|
|
let cache = files_for_submission(&submission, cache, &state).await?;
|
|
let cache = files_for_profile(&poster, cache, &state).await?;
|
|
|
|
let (next_submission, previous_submission) =
|
|
adjacent_submissions(Some(profile.id()), can_view_sensitive, &submission, &state).await?;
|
|
|
|
let (nav, current_file) = submission_nav(
|
|
page.map(|p| p.into_inner()),
|
|
&submission,
|
|
next_submission,
|
|
previous_submission,
|
|
&cache.files,
|
|
);
|
|
|
|
let (comments, cache) =
|
|
CommentNode::from_submission(submission.id(), Some(profile.id()), cache, &state).await?;
|
|
|
|
let is_self = profile.id() == poster.id();
|
|
|
|
let view = ViewSubmissionState {
|
|
cache,
|
|
profile: Some(profile),
|
|
submission,
|
|
poster,
|
|
current_file,
|
|
nav,
|
|
is_self,
|
|
comment_error: Some(error),
|
|
comment_value: Some(form.body),
|
|
comments,
|
|
};
|
|
|
|
crate::rendered(HttpResponse::Ok(), |cursor| {
|
|
crate::templates::submissions::public(cursor, &loader, &view, &nav_state)
|
|
})
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
struct UpdateSubmissionForm {
|
|
title: String,
|
|
description: String,
|
|
}
|
|
|
|
async fn update_submission(
|
|
loader: ActixLoader,
|
|
form: web::Form<UpdateSubmissionForm>,
|
|
submission: CurrentSubmission,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let submission = submission.0;
|
|
let profile = profile.0;
|
|
|
|
let form = form.into_inner();
|
|
|
|
if profile.id() != submission.profile_id() {
|
|
return Ok(crate::to_404());
|
|
}
|
|
|
|
let mut title_error = validate_title(&form.title);
|
|
let description_error = validate_description(&form.description);
|
|
|
|
if title_error.is_none() && description_error.is_none() {
|
|
let res = state
|
|
.profiles
|
|
.run(UpdateSubmission::from_text(
|
|
submission.id(),
|
|
form.title.clone(),
|
|
Some(form.description.clone()),
|
|
))
|
|
.await;
|
|
|
|
title_error = match res {
|
|
Ok(_) => return Ok(to_update_page(submission.id())),
|
|
Err(e) => Some(e.to_string()),
|
|
};
|
|
}
|
|
|
|
let state = SubmissionState::new(submission, &state)
|
|
.await?
|
|
.description_error(description_error)
|
|
.title(&form.title)
|
|
.description(&form.description);
|
|
|
|
let state = if let Some(error) = title_error {
|
|
state.title_error(error)
|
|
} else {
|
|
state
|
|
};
|
|
|
|
crate::rendered(HttpResponse::BadRequest(), |cursor| {
|
|
crate::templates::submissions::update(cursor, &loader, &state, &nav_state)
|
|
})
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
struct UpdateVisibilityForm {
|
|
visibility: Visibility,
|
|
local_only: Option<String>,
|
|
logged_in_only: Option<String>,
|
|
}
|
|
|
|
async fn update_visibility(
|
|
loader: ActixLoader,
|
|
form: web::Form<UpdateVisibilityForm>,
|
|
submission: CurrentSubmission,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let submission = submission.0;
|
|
let profile = profile.0;
|
|
|
|
let form = form.into_inner();
|
|
|
|
if profile.id() != submission.profile_id() {
|
|
return Ok(crate::to_404());
|
|
}
|
|
|
|
let res = state
|
|
.profiles
|
|
.run(UpdateSubmission::from_visibility(
|
|
submission.id(),
|
|
form.visibility,
|
|
form.local_only.is_some(),
|
|
form.logged_in_only.is_some(),
|
|
))
|
|
.await;
|
|
|
|
let error = match res {
|
|
Ok(_) => return Ok(to_update_page(submission.id())),
|
|
Err(e) => e.to_string(),
|
|
};
|
|
|
|
let state = SubmissionState::new(submission, &state)
|
|
.await?
|
|
.visibility_error(error);
|
|
|
|
crate::rendered(HttpResponse::BadRequest(), |cursor| {
|
|
crate::templates::submissions::update(cursor, &loader, &state, &nav_state)
|
|
})
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
struct UpdateSensitiveForm {
|
|
sensitive: Option<String>,
|
|
}
|
|
|
|
async fn update_sensitive(
|
|
loader: ActixLoader,
|
|
form: web::Form<UpdateSensitiveForm>,
|
|
submission: CurrentSubmission,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let submission = submission.0;
|
|
let profile = profile.0;
|
|
|
|
let form = form.into_inner();
|
|
|
|
if profile.id() != submission.profile_id() {
|
|
return Ok(crate::to_404());
|
|
}
|
|
|
|
let res = state
|
|
.profiles
|
|
.run(UpdateSubmission::from_sensitive(
|
|
submission.id(),
|
|
form.sensitive.is_some(),
|
|
))
|
|
.await;
|
|
|
|
let error = match res {
|
|
Ok(_) => return Ok(to_update_page(submission.id())),
|
|
Err(e) => e.to_string(),
|
|
};
|
|
|
|
let state = SubmissionState::new(submission, &state)
|
|
.await?
|
|
.sensitive_error(error);
|
|
|
|
crate::rendered(HttpResponse::BadRequest(), |cursor| {
|
|
crate::templates::submissions::update(cursor, &loader, &state, &nav_state)
|
|
})
|
|
}
|
|
|
|
async fn add_file(
|
|
loader: ActixLoader,
|
|
req: HttpRequest,
|
|
pl: web::Payload,
|
|
submission: CurrentSubmission,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
client: web::Data<Client>,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let submission = submission.0;
|
|
let profile = profile.0;
|
|
|
|
if profile.id() != submission.profile_id() {
|
|
return Ok(crate::to_404());
|
|
}
|
|
|
|
let res = state
|
|
.profiles
|
|
.upload_image(req, pl.into_inner(), &client)
|
|
.await;
|
|
|
|
let error = match res {
|
|
Ok(files) if files.len() == 1 => {
|
|
let res = state
|
|
.profiles
|
|
.run(UpdateSubmission::from_new_file(submission.id(), files[0]))
|
|
.await;
|
|
|
|
match res {
|
|
Ok(_) => return Ok(to_update_page(submission.id())),
|
|
Err(e) => e.to_string(),
|
|
}
|
|
}
|
|
Ok(_) => "Invalid number of files".to_owned(),
|
|
Err(e) => e.to_string(),
|
|
};
|
|
|
|
let state = SubmissionState::new(submission, &state)
|
|
.await?
|
|
.file_error(error);
|
|
|
|
crate::rendered(HttpResponse::BadRequest(), |cursor| {
|
|
crate::templates::submissions::update(cursor, &loader, &state, &nav_state)
|
|
})
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
struct RemoveFileForm {
|
|
file_id: Uuid,
|
|
}
|
|
|
|
async fn remove_file(
|
|
loader: ActixLoader,
|
|
form: web::Form<RemoveFileForm>,
|
|
submission: CurrentSubmission,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let submission = submission.0;
|
|
let profile = profile.0;
|
|
|
|
if profile.id() != submission.profile_id() {
|
|
return Ok(crate::to_404());
|
|
}
|
|
|
|
let res = state
|
|
.profiles
|
|
.run(UpdateSubmission::from_removed_file(
|
|
submission.id(),
|
|
form.file_id,
|
|
))
|
|
.await;
|
|
|
|
let error = match res {
|
|
Ok(_) => return Ok(to_update_page(submission.id())),
|
|
Err(e) => e.to_string(),
|
|
};
|
|
|
|
let state = SubmissionState::new(submission, &state)
|
|
.await?
|
|
.remove_file_error(form.file_id, error);
|
|
|
|
crate::rendered(HttpResponse::BadRequest(), |cursor| {
|
|
crate::templates::submissions::update(cursor, &loader, &state, &nav_state)
|
|
})
|
|
}
|
|
|
|
async fn publish_submission(
|
|
loader: ActixLoader,
|
|
submission: CurrentSubmission,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let submission = submission.0;
|
|
let profile = profile.0;
|
|
|
|
if profile.id() != submission.profile_id() {
|
|
return Ok(crate::to_404());
|
|
}
|
|
|
|
let error = if !submission.has_title() {
|
|
"Title must be present".to_owned()
|
|
} else {
|
|
let res = state
|
|
.profiles
|
|
.run(UpdateSubmission::publish_now(submission.id()))
|
|
.await;
|
|
|
|
match res {
|
|
Ok(_) => return Ok(to_submission_page(submission.id())),
|
|
Err(e) => e.to_string(),
|
|
}
|
|
};
|
|
|
|
let state = SubmissionState::new(submission, &state)
|
|
.await?
|
|
.publish_error(error);
|
|
|
|
crate::rendered(HttpResponse::BadRequest(), |cursor| {
|
|
crate::templates::submissions::update(cursor, &loader, &state, &nav_state)
|
|
})
|
|
}
|
|
|
|
async fn delete_submission(
|
|
loader: ActixLoader,
|
|
submission: CurrentSubmission,
|
|
profile: UserProfile,
|
|
nav_state: NavState,
|
|
state: web::Data<State>,
|
|
) -> Result<HttpResponse, Error> {
|
|
let submission = submission.0;
|
|
let profile = profile.0;
|
|
|
|
if profile.id() != submission.profile_id() {
|
|
return Ok(crate::to_404());
|
|
}
|
|
|
|
let res = state
|
|
.profiles
|
|
.run(DeleteSubmission::from_id(submission.id()))
|
|
.await;
|
|
|
|
let error = match res {
|
|
Ok(_) => return Ok(crate::to_home()),
|
|
Err(e) => e.to_string(),
|
|
};
|
|
|
|
let state = SubmissionState::new(submission, &state)
|
|
.await?
|
|
.delete_error(error);
|
|
|
|
crate::rendered(HttpResponse::BadRequest(), |cursor| {
|
|
crate::templates::submissions::update(cursor, &loader, &state, &nav_state)
|
|
})
|
|
}
|
|
|
|
async fn route_to_update_page(path: web::Path<Uuid>) -> HttpResponse {
|
|
to_update_page(path.into_inner())
|
|
}
|
|
|
|
fn to_update_page(id: Uuid) -> HttpResponse {
|
|
crate::redirect(&format!("/submissions/{}/update", id))
|
|
}
|
|
|
|
fn to_submission_page(id: Uuid) -> HttpResponse {
|
|
crate::redirect(&format!("/submissions/{}", id))
|
|
}
|
|
|
|
fn file_input(loader: &ActixLoader) -> FileInput {
|
|
FileInput::secondary("images[]", &fl!(loader, "create-submission-input"))
|
|
}
|