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