forked from Plume/Plume
0ea1d57e48
* Fix some follow issues Fix not receiving notifications when followed by remote users Fix imposibility to be unfollowed by Mastodon/Pleroma users (tested only against Pleroma) * Fix notification on every post * Fix issues with federation Send Link instead of Object when emiting Follow request Receive both Link and Object for Follow request Don't panic when fetching user with no followers or posts from Pleroma Reorder follower routes so Activity Pub one is reachable * Generate absolute urls for mentions and tags * Verify author when undoing activity by Link
399 lines
15 KiB
Rust
399 lines
15 KiB
Rust
use chrono::Utc;
|
|
use heck::{CamelCase, KebabCase};
|
|
use rocket::request::LenientForm;
|
|
use rocket::response::{Redirect, Flash};
|
|
use rocket_i18n::I18n;
|
|
use std::{collections::{HashMap, HashSet}, borrow::Cow};
|
|
use validator::{Validate, ValidationError, ValidationErrors};
|
|
|
|
use plume_common::activity_pub::{broadcast, ActivityStream, ApRequest, inbox::Deletable};
|
|
use plume_common::utils;
|
|
use plume_models::{
|
|
blogs::*,
|
|
db_conn::DbConn,
|
|
comments::Comment,
|
|
instance::Instance,
|
|
medias::Media,
|
|
mentions::Mention,
|
|
post_authors::*,
|
|
posts::*,
|
|
safe_string::SafeString,
|
|
tags::*,
|
|
users::User
|
|
};
|
|
use routes::comments::NewCommentForm;
|
|
use template_utils::Ructe;
|
|
use Worker;
|
|
use Searcher;
|
|
|
|
#[get("/~/<blog>/<slug>?<responding_to>", rank = 4)]
|
|
pub fn details(blog: String, slug: String, conn: DbConn, user: Option<User>, responding_to: Option<i32>, intl: I18n) -> Result<Ructe, Ructe> {
|
|
let blog = Blog::find_by_fqn(&*conn, &blog).ok_or_else(|| render!(errors::not_found(&(&*conn, &intl.catalog, user.clone()))))?;
|
|
let post = Post::find_by_slug(&*conn, &slug, blog.id).ok_or_else(|| render!(errors::not_found(&(&*conn, &intl.catalog, user.clone()))))?;
|
|
if post.published || post.get_authors(&*conn).into_iter().any(|a| a.id == user.clone().map(|u| u.id).unwrap_or(0)) {
|
|
let comments = Comment::list_by_post(&*conn, post.id);
|
|
|
|
let previous = responding_to.map(|r| Comment::get(&*conn, r)
|
|
.expect("posts::details_reponse: Error retrieving previous comment"));
|
|
|
|
Ok(render!(posts::details(
|
|
&(&*conn, &intl.catalog, user.clone()),
|
|
post.clone(),
|
|
blog,
|
|
&NewCommentForm {
|
|
warning: previous.clone().map(|p| p.spoiler_text).unwrap_or_default(),
|
|
content: previous.clone().map(|p| format!(
|
|
"@{} {}",
|
|
p.get_author(&*conn).get_fqn(&*conn),
|
|
Mention::list_for_comment(&*conn, p.id)
|
|
.into_iter()
|
|
.filter_map(|m| {
|
|
let user = user.clone();
|
|
if let Some(mentioned) = m.get_mentioned(&*conn) {
|
|
if user.is_none() || mentioned.id != user.expect("posts::details_response: user error while listing mentions").id {
|
|
Some(format!("@{}", mentioned.get_fqn(&*conn)))
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}).collect::<Vec<String>>().join(" "))
|
|
).unwrap_or_default(),
|
|
..NewCommentForm::default()
|
|
},
|
|
ValidationErrors::default(),
|
|
Tag::for_post(&*conn, post.id),
|
|
comments.into_iter().filter(|c| c.in_response_to_id.is_none()).collect::<Vec<Comment>>(),
|
|
previous,
|
|
post.count_likes(&*conn),
|
|
post.count_reshares(&*conn),
|
|
user.clone().map(|u| u.has_liked(&*conn, &post)).unwrap_or(false),
|
|
user.clone().map(|u| u.has_reshared(&*conn, &post)).unwrap_or(false),
|
|
user.map(|u| u.is_following(&*conn, post.get_authors(&*conn)[0].id)).unwrap_or(false),
|
|
post.get_authors(&*conn)[0].clone()
|
|
)))
|
|
} else {
|
|
Err(render!(errors::not_authorized(
|
|
&(&*conn, &intl.catalog, user.clone()),
|
|
"This post isn't published yet."
|
|
)))
|
|
}
|
|
}
|
|
|
|
#[get("/~/<blog>/<slug>", rank = 3)]
|
|
pub fn activity_details(blog: String, slug: String, conn: DbConn, _ap: ApRequest) -> Result<ActivityStream<LicensedArticle>, Option<String>> {
|
|
let blog = Blog::find_by_fqn(&*conn, &blog).ok_or(None)?;
|
|
let post = Post::find_by_slug(&*conn, &slug, blog.id).ok_or(None)?;
|
|
if post.published {
|
|
Ok(ActivityStream::new(post.to_activity(&*conn)))
|
|
} else {
|
|
Err(Some(String::from("Not published yet.")))
|
|
}
|
|
}
|
|
|
|
#[get("/~/<blog>/new", rank = 2)]
|
|
pub fn new_auth(blog: String, i18n: I18n) -> Flash<Redirect> {
|
|
utils::requires_login(
|
|
i18n!(i18n.catalog, "You need to be logged in order to write a new post"),
|
|
uri!(new: blog = blog)
|
|
)
|
|
}
|
|
|
|
#[get("/~/<blog>/new", rank = 1)]
|
|
pub fn new(blog: String, user: User, conn: DbConn, intl: I18n) -> Option<Ructe> {
|
|
let b = Blog::find_by_fqn(&*conn, &blog)?;
|
|
|
|
if !user.is_author_in(&*conn, &b) {
|
|
// TODO actually return 403 error code
|
|
Some(render!(errors::not_authorized(
|
|
&(&*conn, &intl.catalog, Some(user)),
|
|
"You are not author in this blog."
|
|
)))
|
|
} else {
|
|
let medias = Media::for_user(&*conn, user.id);
|
|
Some(render!(posts::new(
|
|
&(&*conn, &intl.catalog, Some(user)),
|
|
b,
|
|
false,
|
|
&NewPostForm {
|
|
license: Instance::get_local(&*conn).map(|i| i.default_license).unwrap_or_else(||String::from("CC-BY-SA")),
|
|
..NewPostForm::default()
|
|
},
|
|
true,
|
|
None,
|
|
ValidationErrors::default(),
|
|
medias
|
|
)))
|
|
}
|
|
}
|
|
|
|
#[get("/~/<blog>/<slug>/edit")]
|
|
pub fn edit(blog: String, slug: String, user: User, conn: DbConn, intl: I18n) -> Option<Ructe> {
|
|
let b = Blog::find_by_fqn(&*conn, &blog)?;
|
|
let post = Post::find_by_slug(&*conn, &slug, b.id)?;
|
|
|
|
if !user.is_author_in(&*conn, &b) {
|
|
Some(render!(errors::not_authorized(
|
|
&(&*conn, &intl.catalog, Some(user)),
|
|
"You are not author in this blog."
|
|
)))
|
|
} else {
|
|
let source = if !post.source.is_empty() {
|
|
post.source.clone()
|
|
} else {
|
|
post.content.get().clone() // fallback to HTML if the markdown was not stored
|
|
};
|
|
|
|
let medias = Media::for_user(&*conn, user.id);
|
|
Some(render!(posts::new(
|
|
&(&*conn, &intl.catalog, Some(user)),
|
|
b,
|
|
true,
|
|
&NewPostForm {
|
|
title: post.title.clone(),
|
|
subtitle: post.subtitle.clone(),
|
|
content: source,
|
|
tags: Tag::for_post(&*conn, post.id)
|
|
.into_iter()
|
|
.filter_map(|t| if !t.is_hashtag {Some(t.tag)} else {None})
|
|
.collect::<Vec<String>>()
|
|
.join(", "),
|
|
license: post.license.clone(),
|
|
draft: true,
|
|
cover: post.cover_id,
|
|
},
|
|
!post.published,
|
|
Some(post),
|
|
ValidationErrors::default(),
|
|
medias
|
|
)))
|
|
}
|
|
}
|
|
|
|
#[post("/~/<blog>/<slug>/edit", data = "<form>")]
|
|
pub fn update(blog: String, slug: String, user: User, conn: DbConn, form: LenientForm<NewPostForm>, worker: Worker, intl: I18n, searcher: Searcher)
|
|
-> Result<Redirect, Option<Ructe>> {
|
|
let b = Blog::find_by_fqn(&*conn, &blog).ok_or(None)?;
|
|
let mut post = Post::find_by_slug(&*conn, &slug, b.id).ok_or(None)?;
|
|
|
|
let new_slug = if !post.published {
|
|
form.title.to_string().to_kebab_case()
|
|
} else {
|
|
post.slug.clone()
|
|
};
|
|
|
|
let mut errors = match form.validate() {
|
|
Ok(_) => ValidationErrors::new(),
|
|
Err(e) => e
|
|
};
|
|
|
|
if new_slug != slug && Post::find_by_slug(&*conn, &new_slug, b.id).is_some() {
|
|
errors.add("title", ValidationError {
|
|
code: Cow::from("existing_slug"),
|
|
message: Some(Cow::from("A post with the same title already exists.")),
|
|
params: HashMap::new()
|
|
});
|
|
}
|
|
|
|
if errors.is_empty() {
|
|
if !user.is_author_in(&*conn, &b) {
|
|
// actually it's not "Ok"…
|
|
Ok(Redirect::to(uri!(super::blogs::details: name = blog, page = _)))
|
|
} else {
|
|
let (content, mentions, hashtags) = utils::md_to_html(form.content.to_string().as_ref(), &Instance::get_local(&conn).expect("posts::update: Error getting local instance").public_domain);
|
|
|
|
// update publication date if when this article is no longer a draft
|
|
let newly_published = if !post.published && !form.draft {
|
|
post.published = true;
|
|
post.creation_date = Utc::now().naive_utc();
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
|
|
post.slug = new_slug.clone();
|
|
post.title = form.title.clone();
|
|
post.subtitle = form.subtitle.clone();
|
|
post.content = SafeString::new(&content);
|
|
post.source = form.content.clone();
|
|
post.license = form.license.clone();
|
|
post.cover_id = form.cover;
|
|
post.update(&*conn, &searcher);
|
|
let post = post.update_ap_url(&*conn);
|
|
|
|
if post.published {
|
|
post.update_mentions(&conn, mentions.into_iter().map(|m| Mention::build_activity(&conn, &m)).collect());
|
|
}
|
|
|
|
let tags = form.tags.split(',').map(|t| t.trim().to_camel_case()).filter(|t| !t.is_empty())
|
|
.collect::<HashSet<_>>().into_iter().map(|t| Tag::build_activity(&conn, t)).collect::<Vec<_>>();
|
|
post.update_tags(&conn, tags);
|
|
|
|
let hashtags = hashtags.into_iter().map(|h| h.to_camel_case()).collect::<HashSet<_>>()
|
|
.into_iter().map(|t| Tag::build_activity(&conn, t)).collect::<Vec<_>>();
|
|
post.update_hashtags(&conn, hashtags);
|
|
|
|
if post.published {
|
|
if newly_published {
|
|
let act = post.create_activity(&conn);
|
|
let dest = User::one_by_instance(&*conn);
|
|
worker.execute(move || broadcast(&user, act, dest));
|
|
} else {
|
|
let act = post.update_activity(&*conn);
|
|
let dest = User::one_by_instance(&*conn);
|
|
worker.execute(move || broadcast(&user, act, dest));
|
|
}
|
|
}
|
|
|
|
Ok(Redirect::to(uri!(details: blog = blog, slug = new_slug, responding_to = _)))
|
|
}
|
|
} else {
|
|
let medias = Media::for_user(&*conn, user.id);
|
|
let temp = render!(posts::new(
|
|
&(&*conn, &intl.catalog, Some(user)),
|
|
b,
|
|
true,
|
|
&*form,
|
|
form.draft.clone(),
|
|
Some(post),
|
|
errors.clone(),
|
|
medias.clone()
|
|
));
|
|
Err(Some(temp))
|
|
}
|
|
}
|
|
|
|
#[derive(Default, FromForm, Validate, Serialize)]
|
|
pub struct NewPostForm {
|
|
#[validate(custom(function = "valid_slug", message = "Invalid title"))]
|
|
pub title: String,
|
|
pub subtitle: String,
|
|
pub content: String,
|
|
pub tags: String,
|
|
pub license: String,
|
|
pub draft: bool,
|
|
pub cover: Option<i32>,
|
|
}
|
|
|
|
pub fn valid_slug(title: &str) -> Result<(), ValidationError> {
|
|
let slug = title.to_string().to_kebab_case();
|
|
if slug.is_empty() {
|
|
Err(ValidationError::new("empty_slug"))
|
|
} else if slug == "new" {
|
|
Err(ValidationError::new("invalid_slug"))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[post("/~/<blog_name>/new", data = "<form>")]
|
|
pub fn create(blog_name: String, form: LenientForm<NewPostForm>, user: User, conn: DbConn, worker: Worker, intl: I18n, searcher: Searcher) -> Result<Redirect, Option<Ructe>> {
|
|
let blog = Blog::find_by_fqn(&*conn, &blog_name).ok_or(None)?;
|
|
let slug = form.title.to_string().to_kebab_case();
|
|
|
|
let mut errors = match form.validate() {
|
|
Ok(_) => ValidationErrors::new(),
|
|
Err(e) => e
|
|
};
|
|
if Post::find_by_slug(&*conn, &slug, blog.id).is_some() {
|
|
errors.add("title", ValidationError {
|
|
code: Cow::from("existing_slug"),
|
|
message: Some(Cow::from("A post with the same title already exists.")),
|
|
params: HashMap::new()
|
|
});
|
|
}
|
|
|
|
if errors.is_empty() {
|
|
if !user.is_author_in(&*conn, &blog) {
|
|
// actually it's not "Ok"…
|
|
Ok(Redirect::to(uri!(super::blogs::details: name = blog_name, page = _)))
|
|
} else {
|
|
let (content, mentions, hashtags) = utils::md_to_html(form.content.to_string().as_ref(), &Instance::get_local(&conn).expect("posts::create: Error getting l ocal instance").public_domain);
|
|
|
|
let post = Post::insert(&*conn, NewPost {
|
|
blog_id: blog.id,
|
|
slug: slug.to_string(),
|
|
title: form.title.to_string(),
|
|
content: SafeString::new(&content),
|
|
published: !form.draft,
|
|
license: form.license.clone(),
|
|
ap_url: "".to_string(),
|
|
creation_date: None,
|
|
subtitle: form.subtitle.clone(),
|
|
source: form.content.clone(),
|
|
cover_id: form.cover,
|
|
},
|
|
&searcher,
|
|
);
|
|
let post = post.update_ap_url(&*conn);
|
|
PostAuthor::insert(&*conn, NewPostAuthor {
|
|
post_id: post.id,
|
|
author_id: user.id
|
|
});
|
|
|
|
let tags = form.tags.split(',')
|
|
.map(|t| t.trim().to_camel_case())
|
|
.filter(|t| !t.is_empty())
|
|
.collect::<HashSet<_>>();
|
|
for tag in tags {
|
|
Tag::insert(&*conn, NewTag {
|
|
tag,
|
|
is_hashtag: false,
|
|
post_id: post.id
|
|
});
|
|
}
|
|
for hashtag in hashtags {
|
|
Tag::insert(&*conn, NewTag {
|
|
tag: hashtag.to_camel_case(),
|
|
is_hashtag: true,
|
|
post_id: post.id
|
|
});
|
|
}
|
|
|
|
if post.published {
|
|
for m in mentions {
|
|
Mention::from_activity(&*conn, &Mention::build_activity(&*conn, &m), post.id, true, true);
|
|
}
|
|
|
|
let act = post.create_activity(&*conn);
|
|
let dest = User::one_by_instance(&*conn);
|
|
worker.execute(move || broadcast(&user, act, dest));
|
|
}
|
|
|
|
Ok(Redirect::to(uri!(details: blog = blog_name, slug = slug, responding_to = _)))
|
|
}
|
|
} else {
|
|
let medias = Media::for_user(&*conn, user.id);
|
|
Err(Some(render!(posts::new(
|
|
&(&*conn, &intl.catalog, Some(user)),
|
|
blog,
|
|
false,
|
|
&*form,
|
|
form.draft,
|
|
None,
|
|
errors.clone(),
|
|
medias
|
|
))))
|
|
}
|
|
}
|
|
|
|
#[post("/~/<blog_name>/<slug>/delete")]
|
|
pub fn delete(blog_name: String, slug: String, conn: DbConn, user: User, worker: Worker, searcher: Searcher) -> Redirect {
|
|
let post = Blog::find_by_fqn(&*conn, &blog_name)
|
|
.and_then(|blog| Post::find_by_slug(&*conn, &slug, blog.id));
|
|
|
|
if let Some(post) = post {
|
|
if !post.get_authors(&*conn).into_iter().any(|a| a.id == user.id) {
|
|
Redirect::to(uri!(details: blog = blog_name.clone(), slug = slug.clone(), responding_to = _))
|
|
} else {
|
|
let dest = User::one_by_instance(&*conn);
|
|
let delete_activity = post.delete(&(&conn, &searcher));
|
|
worker.execute(move || broadcast(&user, delete_activity, dest));
|
|
|
|
Redirect::to(uri!(super::blogs::details: name = blog_name, page = _))
|
|
}
|
|
} else {
|
|
Redirect::to(uri!(super::blogs::details: name = blog_name, page = _))
|
|
}
|
|
}
|