use std::{borrow::Cow, sync::OnceLock};
use ammonia::{Builder, UrlRelative};
use lightningcss::{
properties::{Property, PropertyId},
stylesheet::ParserOptions,
};
pub fn repair_and_sanitize(content: &str) -> String {
let document = cleaner().clean(content);
document.to_string()
}
fn cleaner() -> &'static ammonia::Builder<'static> {
fn new_cleaner() -> ammonia::Builder<'static> {
let mut builder = Builder::default();
builder
.url_relative(UrlRelative::RewriteWithBase(
"https://glowfic.com/".try_into().unwrap(),
))
.strip_comments(false)
.add_generic_attributes(ALLOWED_ATTRIBUTES)
.attribute_filter(allowed_attribute);
builder
}
static CLEANER: OnceLock<ammonia::Builder> = OnceLock::new();
CLEANER.get_or_init(new_cleaner)
}
fn allowed_attribute<'u>(element: &str, attribute: &str, value: &'u str) -> Option<Cow<'u, str>> {
match (element, attribute, value) {
(_, "style", _) => style::allowed(value),
(_, "title" | "lang" | "aria-label", _) => Some(Cow::Borrowed(value)),
(_, "id" | "name", _) => None,
(_, "class", class) if CLASSES.contains(&class) => Some(Cow::Borrowed(value)),
(_, "class", _) => {
log::info!("Removing unrecognised class: <{element} {attribute}=\"{value}\">");
None
}
("a", "href", _) => Some(Cow::Borrowed(value)),
("a", "rel", "noopener noreferrer") => Some(Cow::Borrowed(value)),
("a", "target", "_blank" | "_self" | "_parent" | "_top") => Some(Cow::Borrowed(value)),
("img", "src", _) => Some(Cow::Borrowed(value)),
("img", "alt", _) => Some(Cow::Borrowed(value)),
("img" | "table", "width" | "height", _)
if Property::parse_string(PropertyId::Width, value, ParserOptions::default())
.is_ok() =>
{
Some(Cow::Borrowed(value))
}
("img" | "table", "border", _)
if Property::parse_string(PropertyId::Border, value, ParserOptions::default())
.is_ok() =>
{
Some(Cow::Borrowed(value))
}
("ol", "start", _) if value.parse::<u128>().is_ok() => Some(Cow::Borrowed(value)),
("td" | "th", "colspan", _) if value.parse::<u128>().is_ok() => Some(Cow::Borrowed(value)),
(_, "dir", "ltr" | "rtl") => Some(Cow::Borrowed(value)),
("details", "open", "" | "open") => Some(Cow::Borrowed(value)),
("details", "open", "closed") => None,
(_, "align", "left" | "right" | "center" | "justify") => Some(Cow::Borrowed(value)),
(
_,
"post-id" | "icon-id" | "board-id" | "author-id" | "character-id" | "reply-id",
value,
) if value.parse::<u128>().is_ok() => Some(Cow::Borrowed(value)),
(_, "author-ids" | "author-name" | "character-name", value) => Some(Cow::Borrowed(value)),
(_, _, _) => {
log::info!("Removing unrecognised attribute: <{element} {attribute}=\"{value}\">");
None
}
}
}
mod style {
use std::borrow::Cow;
use cssparser::{Parser, ParserInput, ToCss};
use lightningcss::{
declaration::DeclarationBlock,
printer::Printer,
properties::Property,
stylesheet::{ParserOptions, PrinterOptions},
};
pub(super) fn allowed(value: &str) -> Option<Cow<'_, str>> {
let Ok(declarations) = parse_declaration_block(value) else {
log::info!("Removing style attribute that has failed to parse: style=\"{value}\"");
return None;
};
let new = clean_declarations(declarations.clone());
if new == declarations {
return Some(Cow::Borrowed(value));
}
let new_value = serialize_declaration_block(&new);
log::info!(
"Removing unrecognised or forbidden properties from style attribute: \nstyle=\"{value}\"\n-> style=\"{new_value}\""
);
if new_value.is_empty() {
None
} else {
Some(Cow::Owned(new_value))
}
}
fn clean_declarations(mut declarations: DeclarationBlock<'_>) -> DeclarationBlock<'_> {
declarations.important_declarations.retain(allowed_property);
declarations.declarations.retain(allowed_property);
declarations
}
fn allowed_property(property: &Property<'_>) -> bool {
match property {
Property::Background(_)
| Property::BackgroundImage(_)
| Property::BorderImage(_, _)
| Property::BorderImageSource(_)
| Property::FontFamily(_)
| Property::ListStyle(_)
| Property::ListStyleType(_)
| Property::ListStyleImage(_)
| Property::Mask(_, _)
| Property::MaskImage(_, _)
| Property::MaskBorder(_)
| Property::MaskBorderSource(_)
| Property::WebKitMaskBoxImage(_, _)
| Property::WebKitMaskBoxImageSource(_, _)
| Property::ClipPath(_, _)
| Property::Filter(_, _)
| Property::BackdropFilter(_, _)
| Property::Cursor(_) => false,
Property::Display(_)
| Property::Position(_)
| Property::Overflow(_)
| Property::OverflowWrap(_)
| Property::BoxSizing(_, _)
| Property::Transform(_, _)
| Property::Width(_)
| Property::MinWidth(_)
| Property::MaxWidth(_)
| Property::Height(_)
| Property::MinHeight(_)
| Property::MaxHeight(_)
| Property::Margin(_)
| Property::MarginTop(_)
| Property::MarginRight(_)
| Property::MarginBottom(_)
| Property::MarginLeft(_)
| Property::MarginInline(_)
| Property::MarginInlineStart(_)
| Property::MarginInlineEnd(_)
| Property::ScrollMarginInline(_)
| Property::ScrollMarginInlineStart(_)
| Property::ScrollMarginInlineEnd(_)
| Property::Padding(_)
| Property::PaddingTop(_)
| Property::PaddingRight(_)
| Property::PaddingBottom(_)
| Property::PaddingLeft(_)
| Property::Border(_)
| Property::BorderTop(_)
| Property::BorderRight(_)
| Property::BorderBottom(_)
| Property::BorderLeft(_)
| Property::BorderRadius(_, _)
| Property::Outline(_)
| Property::VerticalAlign(_)
| Property::Color(_)
| Property::BackgroundColor(_)
| Property::BackgroundPosition(_)
| Property::BackgroundPositionX(_)
| Property::BackgroundPositionY(_)
| Property::BackgroundRepeat(_)
| Property::FontSize(_)
| Property::FontWeight(_)
| Property::FontStyle(_)
| Property::FontVariantCaps(_)
| Property::FontStretch(_)
| Property::LineHeight(_)
| Property::TextDecoration(_, _)
| Property::TextDecorationLine(_, _)
| Property::TextDecorationColor(_, _)
| Property::TextDecorationStyle(_, _)
| Property::TextDecorationThickness(_)
| Property::TextAlign(_)
| Property::TextIndent(_)
| Property::TextTransform(_)
| Property::TextShadow(_)
| Property::TextSizeAdjust(_, _)
| Property::LetterSpacing(_)
| Property::WordWrap(_)
| Property::WordSpacing(_)
| Property::WhiteSpace(_)
| Property::Caret(_)
| Property::CaretColor(_)
| Property::ListStylePosition(_) => true,
Property::Unparsed(_) | Property::Custom(_) => allowed_property_manual(property),
_ => false,
}
}
fn allowed_property_manual(property: &Property<'_>) -> bool {
let (prefix, name, value) = property_prefix_name_and_value(property);
match (&*prefix, &*name, &*value) {
(_, "clear", "none" | "left" | "right" | "both" | "inline-star" | "inline-end")
| (_, "float", "none" | "left" | "right" | "inline-start" | "inline-end")
| (
_,
"break-before",
"auto" | "avoid" | "always" | "all" | "avoid-page" | "page" | "left" | "right"
| "recto" | "verso" | "avoid-column" | "column" | "avoid-region" | "region",
)
| (_, "font-kerning", "auto" | "normal" | "none") => true,
(_, "orphans" | "widows", _) if value.parse::<u128>().is_ok() => true,
_ => false,
}
}
pub fn property_prefix_name_and_value(property: &Property) -> (String, String, String) {
let vendor_prefix = property.property_id().prefix().to_css_string();
let name = property.property_id().name().to_owned();
let value = {
let value = property
.to_css_string(false, PrinterOptions::default())
.unwrap();
value
.strip_prefix(&vendor_prefix)
.unwrap()
.strip_prefix(&name)
.unwrap()
.strip_prefix(": ")
.unwrap()
.to_owned()
};
(vendor_prefix, name, value)
}
fn parse_declaration_block(
value: &str,
) -> Result<DeclarationBlock<'_>, cssparser::ParseError<'_, lightningcss::error::ParserError<'_>>>
{
let mut input = ParserInput::new(value);
let mut parser = Parser::new(&mut input);
DeclarationBlock::parse(&mut parser, &ParserOptions::default())
}
fn serialize_declaration_block(declarations: &DeclarationBlock<'_>) -> String {
let mut css = String::new();
let mut printer = Printer::new(&mut css, PrinterOptions::default());
for property in &declarations.important_declarations {
property.to_css(&mut printer, true).unwrap();
printer.delim(';', false).unwrap();
}
for property in &declarations.declarations {
property.to_css(&mut printer, false).unwrap();
printer.delim(';', false).unwrap();
}
css.trim().to_owned()
}
}
const ALLOWED_ATTRIBUTES: &[&str] = &[
"align",
"alt",
"aria-label",
"border",
"boundary",
"class",
"colspan",
"dir",
"height",
"href",
"id",
"lang",
"name",
"open",
"role",
"src",
"start",
"style",
"target",
"title",
"width",
"post-id",
"author-ids",
"icon-id",
"board-id",
"author-id",
"author-name",
"character-id",
"character-name",
"reply-id",
];
const CLASSES: &[&str] = &[
"title-page",
"copyright-page",
"description",
"content",
"content-block",
"character",
"character",
"icon",
"icon-caption",
];