Skip to main content

max / makenotwork

3.0 KB · 79 lines History Blame Raw
1 //! Rendering a creator's markdown back to them before it is saved.
2 //!
3 //! The described markdown field (`crate::quasi::rich_field`) emits a Preview
4 //! pane and nothing that fills it: `makeover-webview` never turns a value into
5 //! markup, on the stated grounds that a host with its own sanitiser and its own
6 //! content-security posture still owns both. This server has both, so this is
7 //! where the pane is filled.
8 //!
9 //! # Why the server and not the browser
10 //!
11 //! The four hand-written editors this replaces had one preview between them,
12 //! and it was a regex pass over h1-h3, bold, italic and inline code
13 //! (`static/partial-item-text-editor.js`, before this landed). It was safe --
14 //! it escaped first and substituted after -- and it was not what publishes. A
15 //! creator reading it learned what that regex did, not what
16 //! `render_creator_markdown` does, so tables, links, lists and media rewriting
17 //! all appeared for the first time after save.
18 //!
19 //! Rendering here answers with the pipeline that actually publishes, ammonia
20 //! and media-host restriction included, which is both the honest preview and
21 //! the one place the sanitising stays. See `crate::markdown` for the policy.
22
23 use axum::{Json, extract::State};
24 use serde::{Deserialize, Serialize};
25
26 use crate::{
27 auth::AuthUser,
28 config::Config,
29 error::Result,
30 validation::{self, limits},
31 };
32
33 /// What the editor is holding right now.
34 #[derive(Debug, Deserialize)]
35 pub(super) struct PreviewRequest {
36 /// The markdown source, unsaved.
37 pub markdown: String,
38 }
39
40 /// The pane's contents.
41 #[derive(Debug, Serialize)]
42 pub(super) struct PreviewResponse {
43 /// Sanitised markup, ready for the pane. Named `html` rather than `body`
44 /// because nothing here is a resource and there is nothing else to return.
45 html: String,
46 }
47
48 /// Render unsaved markdown the way saving it would.
49 ///
50 /// Authenticated because the media rewriting is per-creator: relative image
51 /// paths resolve under the asking user's CDN prefix, which is the same thing
52 /// `render_creator_markdown` does at save time. Stores nothing.
53 #[tracing::instrument(skip_all, name = "preview::markdown")]
54 pub(super) async fn preview_markdown(
55 State(config): State<Config>,
56 AuthUser(user): AuthUser,
57 Json(req): Json<PreviewRequest>,
58 ) -> Result<Json<PreviewResponse>> {
59 // The same bound the save path applies, so a body too long to store is
60 // refused here rather than previewed and then rejected. `ITEM_TEXT_BODY_MAX`
61 // is the largest of the four surfaces' limits; a shorter one is caught by
62 // its own endpoint on save.
63 if req.markdown.chars().count() > limits::ITEM_TEXT_BODY_MAX {
64 return Err(crate::error::AppError::validation(format!(
65 "Content must be {} characters or less",
66 limits::ITEM_TEXT_BODY_MAX
67 )));
68 }
69 validation::reject_control_chars_multiline("Content", &req.markdown)?;
70
71 let html = crate::markdown::render_creator_markdown(
72 &req.markdown,
73 user.id,
74 config.cdn_base_url.as_str(),
75 );
76
77 Ok(Json(PreviewResponse { html }))
78 }
79