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