Skip to main content

bobashare_web/
lib.rs

1//! Webserver written with [`axum`] which provides a frontend and REST API for
2//! [`bobashare`]
3
4use std::{num::ParseIntError, path::PathBuf, str::FromStr, time::Duration as StdDuration};
5
6use bobashare::storage::file::FileBackend;
7use chrono::TimeDelta;
8use displaydoc::Display;
9use pulldown_cmark::{html::push_html, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
10use syntect::{
11    html::{ClassStyle, ClassedHTMLGenerator},
12    parsing::SyntaxSet,
13};
14use thiserror::Error;
15use tokio::sync::broadcast;
16use tracing::{event, instrument, Level};
17use url::Url;
18
19pub mod api;
20pub mod static_routes;
21pub mod views;
22
23#[cfg(test)]
24mod tests;
25
26/// Prefix for CSS classes used for [`syntect`] highlighting
27pub const HIGHLIGHT_CLASS_PREFIX: &str = "hl-";
28/// [`ClassStyle`] used for [`syntect`] highlighting
29pub const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed {
30    prefix: HIGHLIGHT_CLASS_PREFIX,
31};
32
33/// Options used for [`pulldown_cmark`] rendering
34pub const MARKDOWN_OPTIONS: Options = Options::all();
35
36/// A struct that contains all the state and config for bobashare
37#[derive(Debug, Clone)]
38pub struct AppState {
39    /// instance name, displayed on all pages
40    pub instance_name: String,
41    /// storage backend
42    pub backend: FileBackend,
43    /// how often between each cleanup
44    pub cleanup_interval: StdDuration,
45    /// base URL (ex. `http://localhost:3000/`)
46    pub base_url: Url,
47    /// base URL for downloading raw upload files (ex. `http://localhost:3000/raw/`)
48    pub raw_url: Url,
49    /// length of randomly generated IDs
50    pub id_length: usize,
51    /// default expiry time
52    pub default_expiry: TimeDelta,
53    /// maximum expiry time ([`None`] for no maximum)
54    pub max_expiry: Option<TimeDelta>,
55    /// maximum file size in bytes
56    pub max_file_size: u64,
57
58    // syntax highlighting
59    pub syntax_set: SyntaxSet,
60
61    /// extra text to display in footer
62    pub extra_footer_text: Option<String>,
63    /// path to markdown file for about page
64    pub about_page: Option<PathBuf>,
65    /// raw markdown text content of about page file
66    pub about_page_content: String,
67
68    /// channel to broadcast shutdown -- will force all uploads to stop
69    pub shutdown_tx: broadcast::Sender<()>,
70}
71
72/// Take the requested expiry, and make sure it's within the maximum expiry.
73///
74/// # Meaning of [`None`]
75///
76/// If the maximum expiry (`max_expiry`) is None, then any expiry will be
77/// allowed, including no expiry. If the requested expiry (`other`) is
78/// set to None, then it will return the maximum allowed expiry.
79///
80/// # Examples
81///
82/// Requesting no expiry with no maximum expiry:
83///
84/// ```
85/// # use chrono::TimeDelta;
86/// let max_expiry = None;
87/// assert_eq!(bobashare_web::clamp_expiry(max_expiry, None), None);
88/// ```
89///
90/// Requesting no expiry but a maximum expiry is set (gives the maximum allowed
91/// expiry):
92///
93/// ```
94/// # use chrono::TimeDelta;
95/// let max_expiry = Some(TimeDelta::days(7));
96/// assert_eq!(bobashare_web::clamp_expiry(max_expiry, None), max_expiry);
97/// ```
98///
99/// Requesting an expiry with no maximum expiry:
100///
101/// ```
102/// # use chrono::TimeDelta;
103/// let max_expiry = None;
104/// assert_eq!(
105///     bobashare_web::clamp_expiry(max_expiry, Some(TimeDelta::days(3))),
106///     Some(TimeDelta::days(3)),
107/// );
108/// ```
109///
110/// Requesting an expiry that's within the maximum expiry:
111///
112/// ```
113/// # use chrono::TimeDelta;
114/// let max_expiry = Some(TimeDelta::days(7));
115/// assert_eq!(
116///     bobashare_web::clamp_expiry(max_expiry, Some(TimeDelta::days(3))),
117///     Some(TimeDelta::days(3)),
118/// );
119/// ```
120///
121/// Requesting an expiry that's outside of the maximum expiry (clamps to the
122/// maximum expiry):
123///
124/// ```
125/// # use chrono::TimeDelta;
126/// let max_expiry = Some(TimeDelta::days(7));
127/// assert_eq!(
128///     bobashare_web::clamp_expiry(max_expiry, Some(TimeDelta::days(30))),
129///     max_expiry,
130/// );
131/// ```
132pub fn clamp_expiry(max_expiry: Option<TimeDelta>, other: Option<TimeDelta>) -> Option<TimeDelta> {
133    match other {
134        // if no expiry requested, use the max no matter what
135        None => max_expiry,
136        Some(e) => match max_expiry {
137            // if no max expiry, keep requested expiry
138            None => Some(e),
139            Some(max) => Some(e.clamp(TimeDelta::zero(), max)),
140        },
141    }
142}
143
144/// Error encountered in converting string to duration values with
145/// [`str_to_duration`]
146#[derive(Debug, Error, Display)]
147pub enum StrToDurationError {
148    /// string does not match duration format (try: 15d)
149    Invalid,
150
151    /// could not parse number in duration, is it too large?
152    NumberParse(#[from] ParseIntError),
153}
154
155/// Take a string with a simple duration format (single number followed by unit)
156/// and output a [`StdDuration`]. Accepts durations in minutes (m), hours
157/// (h), days (d), weeks (w), months (mon), or years (y).
158///
159/// A month is equivalent to 30 days. A year is equivalent to 365 days.
160///
161/// # Examples
162///
163/// Basic (small numbers that fit within the unit)
164///
165/// ```
166/// use bobashare_web::str_to_duration;
167/// use chrono::TimeDelta;
168///
169/// assert_eq!(
170///     TimeDelta::from_std(str_to_duration("17m")?)?,
171///     TimeDelta::minutes(17),
172/// );
173/// assert_eq!(
174///     TimeDelta::from_std(str_to_duration("14h")?)?,
175///     TimeDelta::hours(14),
176/// );
177/// assert_eq!(
178///     TimeDelta::from_std(str_to_duration("26d")?)?,
179///     TimeDelta::days(26),
180/// );
181/// assert_eq!(
182///     TimeDelta::from_std(str_to_duration("2w")?)?,
183///     TimeDelta::weeks(2),
184/// );
185/// assert_eq!(
186///     TimeDelta::from_std(str_to_duration("4mon")?)?,
187///     TimeDelta::days(30 * 4),
188/// );
189/// assert_eq!(
190///     TimeDelta::from_std(str_to_duration("7y")?)?,
191///     TimeDelta::days(365 * 7),
192/// );
193///
194/// # Ok::<(), anyhow::Error>(())
195/// ```
196///
197/// Demonstrate the day values of months and years
198///
199/// ```
200/// # use bobashare_web::str_to_duration;
201/// # use chrono::TimeDelta;
202/// assert_eq!(
203///     TimeDelta::from_std(str_to_duration("1mon")?)?,
204///     TimeDelta::days(30),
205/// );
206/// assert_eq!(
207///     TimeDelta::from_std(str_to_duration("1y")?)?,
208///     TimeDelta::days(365),
209/// );
210/// # Ok::<(), anyhow::Error>(())
211/// ```
212// TODO: make it look nicer
213pub fn str_to_duration(s: &str) -> Result<StdDuration, StrToDurationError> {
214    let mut chars = s.char_indices();
215    if !chars.next().is_some_and(|(_, c)| c.is_ascii_digit()) {
216        return Err(StrToDurationError::Invalid);
217    }
218
219    let unit_idx = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
220
221    let count_str = &s[..unit_idx];
222    let count = u64::from_str(count_str)?;
223    let unit_str = &s[unit_idx..];
224
225    Ok(match unit_str {
226        "s" => StdDuration::from_secs(count),
227        "m" => StdDuration::from_secs(count * 60),
228        "h" => StdDuration::from_secs(count * 60 * 60),
229        "d" => StdDuration::from_secs(count * 60 * 60 * 24),
230        "w" => StdDuration::from_secs(count * 60 * 60 * 24 * 7),
231        "mon" => StdDuration::from_secs(count * 60 * 60 * 24 * 30),
232        "y" => StdDuration::from_secs(count * 60 * 60 * 24 * 365),
233        _ => return Err(StrToDurationError::Invalid),
234    })
235}
236
237#[derive(Debug, Error, Display)]
238/// Errors for [`render_markdown_with_syntax_set`]
239pub enum RenderMarkdownWithSyntaxError {
240    /// error highlighting markdown-fenced code block: {0}
241    HighlightCodeBlock(#[source] syntect::Error),
242}
243
244/// Render markdown into HTML, including syntax highlighting for code blocks
245/// using [`syntect`].
246///
247/// Takes in a [`SyntaxSet`] to use for highlighting.
248#[instrument(name = "render", skip(syntax_set, source), level = "debug")]
249pub fn render_markdown_with_syntax_set(
250    source: &str,
251    syntax_set: &SyntaxSet,
252) -> Result<String, RenderMarkdownWithSyntaxError> {
253    let mut parser = Parser::new_ext(source, MARKDOWN_OPTIONS).peekable();
254    let mut output = Vec::new();
255    // wrap multiline code blocks in a pre.highlight, and apply a syntect class to
256    // the inner code
257    while let Some(event) = parser.next() {
258        match event {
259            // patch GHSA-g7gw-4888-mr65
260            Event::Html(s) | Event::InlineHtml(s) => {
261                event!(Level::TRACE, ?s, "removed raw HTML");
262            }
263
264            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(token))) => {
265                output.push(Event::Html("<pre class=\"highlight\">".into()));
266                let syntax = syntax_set
267                    .find_syntax_by_token(&token)
268                    .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
269                let mut generator =
270                    ClassedHTMLGenerator::new_with_class_style(syntax, syntax_set, CLASS_STYLE);
271
272                // peek so we don't consume the end tag
273                // TODO: figure out if take_while() can do this better
274                while let Some(Event::Text(t)) = parser.peek() {
275                    generator
276                        .parse_html_for_line_which_includes_newline(t)
277                        .map_err(RenderMarkdownWithSyntaxError::HighlightCodeBlock)?;
278                    parser.next();
279                }
280                output.push(Event::Html(generator.finalize().into()));
281            }
282            Event::End(TagEnd::CodeBlock) => {
283                output.push(Event::Html("</pre>".into()));
284            }
285            e => output.push(e),
286        }
287    }
288
289    // FIXME: figure out where this specific calculation came from
290    let mut displayed = String::with_capacity(source.len() * 3 / 2);
291    push_html(&mut displayed, output.into_iter());
292    Ok(displayed)
293}