Federated blogging application, thanks to ActivityPub https://joinplu.me
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

671 lines
22 KiB

  1. use chrono::Utc;
  2. use heck::{CamelCase, KebabCase};
  3. use riker::actors::*;
  4. use rocket::request::LenientForm;
  5. use rocket::response::{Flash, Redirect};
  6. use rocket_i18n::I18n;
  7. use std::{
  8. borrow::Cow,
  9. collections::{HashMap, HashSet},
  10. time::Duration,
  11. };
  12. use validator::{Validate, ValidationError, ValidationErrors};
  13. use crate::routes::{
  14. comments::NewCommentForm, errors::ErrorPage, ContentLen, RemoteForm, RespondOrRedirect,
  15. };
  16. use crate::template_utils::{IntoContext, Ructe};
  17. use plume_common::activity_pub::{broadcast, ActivityStream, ApRequest};
  18. use plume_common::utils;
  19. use plume_models::{
  20. blogs::*,
  21. comments::{Comment, CommentTree},
  22. inbox::inbox,
  23. instance::Instance,
  24. medias::Media,
  25. mentions::Mention,
  26. post_authors::*,
  27. posts::*,
  28. safe_string::SafeString,
  29. search::UpdateDocument,
  30. tags::*,
  31. timeline::*,
  32. users::User,
  33. Error, PlumeRocket,
  34. };
  35. #[get("/~/<blog>/<slug>?<responding_to>", rank = 4)]
  36. pub fn details(
  37. blog: String,
  38. slug: String,
  39. responding_to: Option<i32>,
  40. rockets: PlumeRocket,
  41. ) -> Result<Ructe, ErrorPage> {
  42. let conn = &*rockets.conn;
  43. let user = rockets.user.clone();
  44. let blog = Blog::find_by_fqn(&rockets, &blog)?;
  45. let post = Post::find_by_slug(&*conn, &slug, blog.id)?;
  46. if !(post.published
  47. || post
  48. .get_authors(&*conn)?
  49. .into_iter()
  50. .any(|a| a.id == user.clone().map(|u| u.id).unwrap_or(0)))
  51. {
  52. return Ok(render!(errors::not_authorized(
  53. &rockets.to_context(),
  54. i18n!(rockets.intl.catalog, "This post isn't published yet.")
  55. )));
  56. }
  57. let comments = CommentTree::from_post(&*conn, &post, user.as_ref())?;
  58. let previous = responding_to.and_then(|r| Comment::get(&*conn, r).ok());
  59. Ok(render!(posts::details(
  60. &rockets.to_context(),
  61. post.clone(),
  62. blog,
  63. &NewCommentForm {
  64. warning: previous.clone().map(|p| p.spoiler_text).unwrap_or_default(),
  65. content: previous.clone().and_then(|p| Some(format!(
  66. "@{} {}",
  67. p.get_author(&*conn).ok()?.fqn,
  68. Mention::list_for_comment(&*conn, p.id).ok()?
  69. .into_iter()
  70. .filter_map(|m| {
  71. let user = user.clone();
  72. if let Ok(mentioned) = m.get_mentioned(&*conn) {
  73. if user.is_none() || mentioned.id != user.expect("posts::details_response: user error while listing mentions").id {
  74. Some(format!("@{}", mentioned.fqn))
  75. } else {
  76. None
  77. }
  78. } else {
  79. None
  80. }
  81. }).collect::<Vec<String>>().join(" "))
  82. )).unwrap_or_default(),
  83. ..NewCommentForm::default()
  84. },
  85. ValidationErrors::default(),
  86. Tag::for_post(&*conn, post.id)?,
  87. comments,
  88. previous,
  89. post.count_likes(&*conn)?,
  90. post.count_reshares(&*conn)?,
  91. user.clone().and_then(|u| u.has_liked(&*conn, &post).ok()).unwrap_or(false),
  92. user.clone().and_then(|u| u.has_reshared(&*conn, &post).ok()).unwrap_or(false),
  93. user.and_then(|u| u.is_following(&*conn, post.get_authors(&*conn).ok()?[0].id).ok()).unwrap_or(false),
  94. post.get_authors(&*conn)?[0].clone()
  95. )))
  96. }
  97. #[get("/~/<blog>/<slug>", rank = 3)]
  98. pub fn activity_details(
  99. blog: String,
  100. slug: String,
  101. _ap: ApRequest,
  102. rockets: PlumeRocket,
  103. ) -> Result<ActivityStream<LicensedArticle>, Option<String>> {
  104. let conn = &*rockets.conn;
  105. let blog = Blog::find_by_fqn(&rockets, &blog).map_err(|_| None)?;
  106. let post = Post::find_by_slug(&*conn, &slug, blog.id).map_err(|_| None)?;
  107. if post.published {
  108. Ok(ActivityStream::new(
  109. post.to_activity(&*conn)
  110. .map_err(|_| String::from("Post serialization error"))?,
  111. ))
  112. } else {
  113. Err(Some(String::from("Not published yet.")))
  114. }
  115. }
  116. #[get("/~/<blog>/new", rank = 2)]
  117. pub fn new_auth(blog: String, i18n: I18n) -> Flash<Redirect> {
  118. utils::requires_login(
  119. &i18n!(
  120. i18n.catalog,
  121. "To write a new post, you need to be logged in"
  122. ),
  123. uri!(new: blog = blog),
  124. )
  125. }
  126. #[get("/~/<blog>/new", rank = 1)]
  127. pub fn new(blog: String, cl: ContentLen, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
  128. let conn = &*rockets.conn;
  129. let b = Blog::find_by_fqn(&rockets, &blog)?;
  130. let user = rockets.user.clone().unwrap();
  131. if !user.is_author_in(&*conn, &b)? {
  132. // TODO actually return 403 error code
  133. return Ok(render!(errors::not_authorized(
  134. &rockets.to_context(),
  135. i18n!(rockets.intl.catalog, "You are not an author of this blog.")
  136. )));
  137. }
  138. let medias = Media::for_user(&*conn, user.id)?;
  139. Ok(render!(posts::new(
  140. &rockets.to_context(),
  141. i18n!(rockets.intl.catalog, "New post"),
  142. b,
  143. false,
  144. &NewPostForm {
  145. license: Instance::get_local()?.default_license,
  146. ..NewPostForm::default()
  147. },
  148. true,
  149. None,
  150. ValidationErrors::default(),
  151. medias,
  152. cl.0
  153. )))
  154. }
  155. #[get("/~/<blog>/<slug>/edit")]
  156. pub fn edit(
  157. blog: String,
  158. slug: String,
  159. cl: ContentLen,
  160. rockets: PlumeRocket,
  161. ) -> Result<Ructe, ErrorPage> {
  162. let conn = &*rockets.conn;
  163. let intl = &rockets.intl.catalog;
  164. let b = Blog::find_by_fqn(&rockets, &blog)?;
  165. let post = Post::find_by_slug(&*conn, &slug, b.id)?;
  166. let user = rockets.user.clone().unwrap();
  167. if !user.is_author_in(&*conn, &b)? {
  168. return Ok(render!(errors::not_authorized(
  169. &rockets.to_context(),
  170. i18n!(intl, "You are not an author of this blog.")
  171. )));
  172. }
  173. let source = if !post.source.is_empty() {
  174. post.source.clone()
  175. } else {
  176. post.content.get().clone() // fallback to HTML if the markdown was not stored
  177. };
  178. let medias = Media::for_user(&*conn, user.id)?;
  179. let title = post.title.clone();
  180. Ok(render!(posts::new(
  181. &rockets.to_context(),
  182. i18n!(intl, "Edit {0}"; &title),
  183. b,
  184. true,
  185. &NewPostForm {
  186. title: post.title.clone(),
  187. subtitle: post.subtitle.clone(),
  188. content: source,
  189. tags: Tag::for_post(&*conn, post.id)?
  190. .into_iter()
  191. .filter_map(|t| if !t.is_hashtag { Some(t.tag) } else { None })
  192. .collect::<Vec<String>>()
  193. .join(", "),
  194. license: post.license.clone(),
  195. draft: true,
  196. cover: post.cover_id,
  197. },
  198. !post.published,
  199. Some(post),
  200. ValidationErrors::default(),
  201. medias,
  202. cl.0
  203. )))
  204. }
  205. #[post("/~/<blog>/<slug>/edit", data = "<form>")]
  206. pub fn update(
  207. blog: String,
  208. slug: String,
  209. cl: ContentLen,
  210. form: LenientForm<NewPostForm>,
  211. rockets: PlumeRocket,
  212. ) -> RespondOrRedirect {
  213. let conn = &*rockets.conn;
  214. let b = Blog::find_by_fqn(&rockets, &blog).expect("post::update: blog error");
  215. let mut post =
  216. Post::find_by_slug(&*conn, &slug, b.id).expect("post::update: find by slug error");
  217. let user = rockets.user.clone().unwrap();
  218. let intl = &rockets.intl.catalog;
  219. let new_slug = if !post.published {
  220. form.title.to_string().to_kebab_case()
  221. } else {
  222. post.slug.clone()
  223. };
  224. let mut errors = match form.validate() {
  225. Ok(_) => ValidationErrors::new(),
  226. Err(e) => e,
  227. };
  228. if new_slug != slug && Post::find_by_slug(&*conn, &new_slug, b.id).is_ok() {
  229. errors.add(
  230. "title",
  231. ValidationError {
  232. code: Cow::from("existing_slug"),
  233. message: Some(Cow::from("A post with the same title already exists.")),
  234. params: HashMap::new(),
  235. },
  236. );
  237. }
  238. if errors.is_empty() {
  239. if !user
  240. .is_author_in(&*conn, &b)
  241. .expect("posts::update: is author in error")
  242. {
  243. // actually it's not "Ok"…
  244. Flash::error(
  245. Redirect::to(uri!(super::blogs::details: name = blog, page = _)),
  246. i18n!(&intl, "You are not allowed to publish on this blog."),
  247. )
  248. .into()
  249. } else {
  250. let (content, mentions, hashtags) = utils::md_to_html(
  251. form.content.to_string().as_ref(),
  252. Some(
  253. &Instance::get_local()
  254. .expect("posts::update: Error getting local instance")
  255. .public_domain,
  256. ),
  257. false,
  258. Some(Media::get_media_processor(
  259. &conn,
  260. b.list_authors(&conn)
  261. .expect("Could not get author list")
  262. .iter()
  263. .collect(),
  264. )),
  265. );
  266. // update publication date if when this article is no longer a draft
  267. let newly_published = if !post.published && !form.draft {
  268. post.published = true;
  269. post.creation_date = Utc::now().naive_utc();
  270. true
  271. } else {
  272. false
  273. };
  274. post.slug = new_slug.clone();
  275. post.title = form.title.clone();
  276. post.subtitle = form.subtitle.clone();
  277. post.content = SafeString::new(&content);
  278. post.source = form.content.clone();
  279. post.license = form.license.clone();
  280. post.cover_id = form.cover;
  281. if post.published {
  282. post.update_mentions(
  283. &conn,
  284. mentions
  285. .into_iter()
  286. .filter_map(|m| Mention::build_activity(&rockets, &m).ok())
  287. .collect(),
  288. )
  289. .expect("post::update: mentions error");
  290. }
  291. let tags = form
  292. .tags
  293. .split(',')
  294. .map(|t| t.trim().to_camel_case())
  295. .filter(|t| !t.is_empty())
  296. .collect::<HashSet<_>>()
  297. .into_iter()
  298. .filter_map(|t| Tag::build_activity(t).ok())
  299. .collect::<Vec<_>>();
  300. post.update_tags(&conn, tags)
  301. .expect("post::update: tags error");
  302. let hashtags = hashtags
  303. .into_iter()
  304. .map(|h| h.to_camel_case())
  305. .collect::<HashSet<_>>()
  306. .into_iter()
  307. .filter_map(|t| Tag::build_activity(t).ok())
  308. .collect::<Vec<_>>();
  309. post.update_hashtags(&conn, hashtags)
  310. .expect("post::update: hashtags error");
  311. if post.published {
  312. if newly_published {
  313. let act = post
  314. .create_activity(&conn)
  315. .expect("post::update: act error");
  316. let dest = User::one_by_instance(&*conn).expect("post::update: dest error");
  317. rockets.worker.execute(move || broadcast(&user, act, dest));
  318. Timeline::add_to_all_timelines(&rockets, &post, Kind::Original).ok();
  319. } else {
  320. let act = post
  321. .update_activity(&*conn)
  322. .expect("post::update: act error");
  323. let dest = User::one_by_instance(&*conn).expect("posts::update: dest error");
  324. rockets.worker.execute(move || broadcast(&user, act, dest));
  325. }
  326. }
  327. let searcher_actor = rockets.actors.select("searcher-actor").unwrap();
  328. searcher_actor.try_tell(UpdateDocument(post.clone()), None);
  329. Flash::success(
  330. Redirect::to(uri!(details: blog = blog, slug = new_slug, responding_to = _)),
  331. i18n!(intl, "Your article has been updated."),
  332. )
  333. .into()
  334. }
  335. } else {
  336. let medias = Media::for_user(&*conn, user.id).expect("posts:update: medias error");
  337. render!(posts::new(
  338. &rockets.to_context(),
  339. i18n!(intl, "Edit {0}"; &form.title),
  340. b,
  341. true,
  342. &*form,
  343. form.draft,
  344. Some(post),
  345. errors,
  346. medias,
  347. cl.0
  348. ))
  349. .into()
  350. }
  351. }
  352. #[derive(Default, FromForm, Validate)]
  353. pub struct NewPostForm {
  354. #[validate(custom(function = "valid_slug", message = "Invalid title"))]
  355. pub title: String,
  356. pub subtitle: String,
  357. pub content: String,
  358. pub tags: String,
  359. pub license: String,
  360. pub draft: bool,
  361. pub cover: Option<i32>,
  362. }
  363. pub fn valid_slug(title: &str) -> Result<(), ValidationError> {
  364. let slug = title.to_string().to_kebab_case();
  365. if slug.is_empty() {
  366. Err(ValidationError::new("empty_slug"))
  367. } else if slug == "new" {
  368. Err(ValidationError::new("invalid_slug"))
  369. } else {
  370. Ok(())
  371. }
  372. }
  373. #[post("/~/<blog_name>/new", data = "<form>")]
  374. pub fn create(
  375. blog_name: String,
  376. form: LenientForm<NewPostForm>,
  377. cl: ContentLen,
  378. rockets: PlumeRocket,
  379. ) -> Result<RespondOrRedirect, ErrorPage> {
  380. let conn = &*rockets.conn;
  381. let blog = Blog::find_by_fqn(&rockets, &blog_name).expect("post::create: blog error");
  382. let slug = form.title.to_string().to_kebab_case();
  383. let user = rockets.user.clone().unwrap();
  384. let mut errors = match form.validate() {
  385. Ok(_) => ValidationErrors::new(),
  386. Err(e) => e,
  387. };
  388. if Post::find_by_slug(&*conn, &slug, blog.id).is_ok() {
  389. errors.add(
  390. "title",
  391. ValidationError {
  392. code: Cow::from("existing_slug"),
  393. message: Some(Cow::from("A post with the same title already exists.")),
  394. params: HashMap::new(),
  395. },
  396. );
  397. }
  398. if errors.is_empty() {
  399. if !user
  400. .is_author_in(&*conn, &blog)
  401. .expect("post::create: is author in error")
  402. {
  403. // actually it's not "Ok"…
  404. return Ok(Flash::error(
  405. Redirect::to(uri!(super::blogs::details: name = blog_name, page = _)),
  406. i18n!(
  407. &rockets.intl.catalog,
  408. "You are not allowed to publish on this blog."
  409. ),
  410. )
  411. .into());
  412. }
  413. let (content, mentions, hashtags) = utils::md_to_html(
  414. form.content.to_string().as_ref(),
  415. Some(
  416. &Instance::get_local()
  417. .expect("post::create: local instance error")
  418. .public_domain,
  419. ),
  420. false,
  421. Some(Media::get_media_processor(
  422. &conn,
  423. blog.list_authors(&conn)
  424. .expect("Could not get author list")
  425. .iter()
  426. .collect(),
  427. )),
  428. );
  429. let post = Post::insert(
  430. &*conn,
  431. NewPost {
  432. blog_id: blog.id,
  433. slug: slug.to_string(),
  434. title: form.title.to_string(),
  435. content: SafeString::new(&content),
  436. published: !form.draft,
  437. license: form.license.clone(),
  438. ap_url: "".to_string(),
  439. creation_date: None,
  440. subtitle: form.subtitle.clone(),
  441. source: form.content.clone(),
  442. cover_id: form.cover,
  443. },
  444. &rockets.searcher,
  445. )
  446. .expect("post::create: post save error");
  447. PostAuthor::insert(
  448. &*conn,
  449. NewPostAuthor {
  450. post_id: post.id,
  451. author_id: user.id,
  452. },
  453. )
  454. .expect("post::create: author save error");
  455. let tags = form
  456. .tags
  457. .split(',')
  458. .map(|t| t.trim().to_camel_case())
  459. .filter(|t| !t.is_empty())
  460. .collect::<HashSet<_>>();
  461. for tag in tags {
  462. Tag::insert(
  463. &*conn,
  464. NewTag {
  465. tag,
  466. is_hashtag: false,
  467. post_id: post.id,
  468. },
  469. )
  470. .expect("post::create: tags save error");
  471. }
  472. for hashtag in hashtags {
  473. Tag::insert(
  474. &*conn,
  475. NewTag {
  476. tag: hashtag.to_camel_case(),
  477. is_hashtag: true,
  478. post_id: post.id,
  479. },
  480. )
  481. .expect("post::create: hashtags save error");
  482. }
  483. if post.published {
  484. for m in mentions {
  485. Mention::from_activity(
  486. &*conn,
  487. &Mention::build_activity(&rockets, &m)
  488. .expect("post::create: mention build error"),
  489. post.id,
  490. true,
  491. true,
  492. )
  493. .expect("post::create: mention save error");
  494. }
  495. let act = post
  496. .create_activity(&*conn)
  497. .expect("posts::create: activity error");
  498. let dest = User::one_by_instance(&*conn).expect("posts::create: dest error");
  499. let worker = &rockets.worker;
  500. worker.execute(move || broadcast(&user, act, dest));
  501. Timeline::add_to_all_timelines(&rockets, &post, Kind::Original)?;
  502. }
  503. Ok(Flash::success(
  504. Redirect::to(uri!(details: blog = blog_name, slug = slug, responding_to = _)),
  505. i18n!(&rockets.intl.catalog, "Your article has been saved."),
  506. )
  507. .into())
  508. } else {
  509. let medias = Media::for_user(&*conn, user.id).expect("posts::create: medias error");
  510. Ok(render!(posts::new(
  511. &rockets.to_context(),
  512. i18n!(rockets.intl.catalog, "New article"),
  513. blog,
  514. false,
  515. &*form,
  516. form.draft,
  517. None,
  518. errors,
  519. medias,
  520. cl.0
  521. ))
  522. .into())
  523. }
  524. }
  525. #[post("/~/<blog_name>/<slug>/delete")]
  526. pub fn delete(
  527. blog_name: String,
  528. slug: String,
  529. rockets: PlumeRocket,
  530. intl: I18n,
  531. ) -> Result<Flash<Redirect>, ErrorPage> {
  532. let user = rockets.user.clone().unwrap();
  533. let post = Blog::find_by_fqn(&rockets, &blog_name)
  534. .and_then(|blog| Post::find_by_slug(&*rockets.conn, &slug, blog.id));
  535. if let Ok(post) = post {
  536. if !post
  537. .get_authors(&*rockets.conn)?
  538. .into_iter()
  539. .any(|a| a.id == user.id)
  540. {
  541. return Ok(Flash::error(
  542. Redirect::to(uri!(details: blog = blog_name, slug = slug, responding_to = _)),
  543. i18n!(intl.catalog, "You are not allowed to delete this article."),
  544. ));
  545. }
  546. let dest = User::one_by_instance(&*rockets.conn)?;
  547. let delete_activity = post.build_delete(&*rockets.conn)?;
  548. inbox(
  549. &rockets,
  550. serde_json::to_value(&delete_activity).map_err(Error::from)?,
  551. )?;
  552. let user_c = user.clone();
  553. rockets
  554. .worker
  555. .execute(move || broadcast(&user_c, delete_activity, dest));
  556. let conn = rockets.conn;
  557. rockets
  558. .worker
  559. .execute_after(Duration::from_secs(10 * 60), move || {
  560. user.rotate_keypair(&*conn)
  561. .expect("Failed to rotate keypair");
  562. });
  563. Ok(Flash::success(
  564. Redirect::to(uri!(super::blogs::details: name = blog_name, page = _)),
  565. i18n!(intl.catalog, "Your article has been deleted."),
  566. ))
  567. } else {
  568. Ok(Flash::error(Redirect::to(
  569. uri!(super::blogs::details: name = blog_name, page = _),
  570. ), i18n!(intl.catalog, "It looks like the article you tried to delete doesn't exist. Maybe it is already gone?")))
  571. }
  572. }
  573. #[get("/~/<blog_name>/<slug>/remote_interact")]
  574. pub fn remote_interact(
  575. rockets: PlumeRocket,
  576. blog_name: String,
  577. slug: String,
  578. ) -> Result<Ructe, ErrorPage> {
  579. let target = Blog::find_by_fqn(&rockets, &blog_name)
  580. .and_then(|blog| Post::find_by_slug(&rockets.conn, &slug, blog.id))?;
  581. Ok(render!(posts::remote_interact(
  582. &rockets.to_context(),
  583. target,
  584. super::session::LoginForm::default(),
  585. ValidationErrors::default(),
  586. RemoteForm::default(),
  587. ValidationErrors::default()
  588. )))
  589. }
  590. #[post("/~/<blog_name>/<slug>/remote_interact", data = "<remote>")]
  591. pub fn remote_interact_post(
  592. rockets: PlumeRocket,
  593. blog_name: String,
  594. slug: String,
  595. remote: LenientForm<RemoteForm>,
  596. ) -> Result<RespondOrRedirect, ErrorPage> {
  597. let target = Blog::find_by_fqn(&rockets, &blog_name)
  598. .and_then(|blog| Post::find_by_slug(&rockets.conn, &slug, blog.id))?;
  599. if let Some(uri) = User::fetch_remote_interact_uri(&remote.remote)
  600. .ok()
  601. .map(|uri| uri.replace("{uri}", &target.ap_url))
  602. {
  603. Ok(Redirect::to(uri).into())
  604. } else {
  605. let mut errs = ValidationErrors::new();
  606. errs.add("remote", ValidationError {
  607. code: Cow::from("invalid_remote"),
  608. message: Some(Cow::from(i18n!(rockets.intl.catalog, "Couldn't obtain enough information about your account. Please make sure your username is correct."))),
  609. params: HashMap::new(),
  610. });
  611. //could not get your remote url?
  612. Ok(render!(posts::remote_interact(
  613. &rockets.to_context(),
  614. target,
  615. super::session::LoginForm::default(),
  616. ValidationErrors::default(),
  617. remote.clone(),
  618. errs
  619. ))
  620. .into())
  621. }
  622. }