From 86cdbda60b74e9d9acfd8ef4f6d6ef1bd57b1142 Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 6 Oct 2021 09:55:22 +0700 Subject: [PATCH] TF-105 Create html transformer --- .../base/dom_transformer.dart | 21 +++++ .../base/text_transformer.dart | 9 ++ .../dom/blockquote_transformers.dart | 24 +++++ .../dom/image_transformers.dart | 34 ++++++++ .../dom/link_transformers.dart | 17 ++++ .../dom/meta_transformers.dart | 43 +++++++++ .../dom/script_transformers.dart | 17 ++++ .../dom/table_transformers.dart | 36 ++++++++ .../html_transformer/html_transform.dart | 42 +++++++++ .../message_content_transformer.dart | 28 ++++++ .../text/convert_tags_text_transformer.dart | 13 +++ .../text/linebreak_text_transformer.dart | 15 ++++ .../text/links_text_transformer.dart | 47 ++++++++++ .../transform_configuration.dart | 87 +++++++++++++++++++ 14 files changed, 433 insertions(+) create mode 100644 core/lib/presentation/utils/html_transformer/base/dom_transformer.dart create mode 100644 core/lib/presentation/utils/html_transformer/base/text_transformer.dart create mode 100644 core/lib/presentation/utils/html_transformer/dom/blockquote_transformers.dart create mode 100644 core/lib/presentation/utils/html_transformer/dom/image_transformers.dart create mode 100644 core/lib/presentation/utils/html_transformer/dom/link_transformers.dart create mode 100644 core/lib/presentation/utils/html_transformer/dom/meta_transformers.dart create mode 100644 core/lib/presentation/utils/html_transformer/dom/script_transformers.dart create mode 100644 core/lib/presentation/utils/html_transformer/dom/table_transformers.dart create mode 100644 core/lib/presentation/utils/html_transformer/html_transform.dart create mode 100644 core/lib/presentation/utils/html_transformer/message_content_transformer.dart create mode 100644 core/lib/presentation/utils/html_transformer/text/convert_tags_text_transformer.dart create mode 100644 core/lib/presentation/utils/html_transformer/text/linebreak_text_transformer.dart create mode 100644 core/lib/presentation/utils/html_transformer/text/links_text_transformer.dart create mode 100644 core/lib/presentation/utils/html_transformer/transform_configuration.dart diff --git a/core/lib/presentation/utils/html_transformer/base/dom_transformer.dart b/core/lib/presentation/utils/html_transformer/base/dom_transformer.dart new file mode 100644 index 000000000..a1be6ece6 --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/base/dom_transformer.dart @@ -0,0 +1,21 @@ + +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; +import 'package:html/dom.dart'; + +/// Transforms the HTML DOM. +abstract class DomTransformer { + + const DomTransformer(); + + /// Uses the `DOM` [document] and specified [message] to transform the `document`. + /// + /// All changes will be visible to subsequent transformers. + void process(Document document, String message, TransformConfiguration configuration); + + /// Adds a HEAD element if necessary + void ensureDocumentHeadIsAvailable(Document document) { + if (document.head == null) { + document.children.insert(0, Element.html('')); + } + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/base/text_transformer.dart b/core/lib/presentation/utils/html_transformer/base/text_transformer.dart new file mode 100644 index 000000000..ac374b85e --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/base/text_transformer.dart @@ -0,0 +1,9 @@ + +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; + +/// Transforms plain text messages. +abstract class TextTransformer { + const TextTransformer(); + + String transform(String text, String message, TransformConfiguration configuration); +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/dom/blockquote_transformers.dart b/core/lib/presentation/utils/html_transformer/dom/blockquote_transformers.dart new file mode 100644 index 000000000..7263afa02 --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/dom/blockquote_transformers.dart @@ -0,0 +1,24 @@ + +import 'package:core/presentation/utils/html_transformer/base/dom_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; +import 'package:html/dom.dart'; + +class SetStyleBlockQuoteTransformer extends DomTransformer { + + const SetStyleBlockQuoteTransformer(); + + @override + void process(Document document, String message, TransformConfiguration configuration) { + final quoteElements = document.getElementsByTagName('blockquote'); + for (final quote in quoteElements) { + final style = quote.attributes['style']; + if (style == null) { + quote.attributes['style'] = ''' + margin: 0px 8px 0px 8px; + padding: 8px 16px 8px 16px; + border-left:5px solid #eee; + '''; + } + } + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/dom/image_transformers.dart b/core/lib/presentation/utils/html_transformer/dom/image_transformers.dart new file mode 100644 index 000000000..94d4197f3 --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/dom/image_transformers.dart @@ -0,0 +1,34 @@ + +import 'package:html/dom.dart'; +import 'package:core/presentation/utils/html_transformer/base/dom_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; + +class ImageTransformer extends DomTransformer { + + const ImageTransformer(); + + @override + void process(Document document, String message, TransformConfiguration configuration) { + final imageElements = document.getElementsByTagName('img'); + for (final imageElement in imageElements) { + final src = imageElement.attributes['src']; + if (src != null) { + if (src.startsWith('http')) { + if (configuration.blockExternalImages) { + imageElement.attributes.remove('src'); + } else if (src.startsWith('http:')) { + // always at least enforce HTTPS images: + final url = src.substring('http:'.length); + imageElement.attributes['src'] = 'https:$url'; + } + } + } + final style = imageElement.attributes['style']; + if (style == null) { + imageElement.attributes['style'] = 'display: inline;max-width: 100%;height: auto;'; + } else { + imageElement.attributes['style'] = 'display: inline;max-width: 100%;height: auto;' + style; + } + } + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/dom/link_transformers.dart b/core/lib/presentation/utils/html_transformer/dom/link_transformers.dart new file mode 100644 index 000000000..c74c906d8 --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/dom/link_transformers.dart @@ -0,0 +1,17 @@ + +import 'package:core/presentation/utils/html_transformer/base/dom_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; +import 'package:html/dom.dart'; + +class EnsureRelationNoReferrerTransformer extends DomTransformer { + + const EnsureRelationNoReferrerTransformer(); + + @override + void process(Document document, String message, TransformConfiguration configuration) { + final linkElements = document.getElementsByTagName('a'); + for (final linkElement in linkElements) { + linkElement.attributes['rel'] = 'noopener noreferrer'; + } + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/dom/meta_transformers.dart b/core/lib/presentation/utils/html_transformer/dom/meta_transformers.dart new file mode 100644 index 000000000..be2b8a61e --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/dom/meta_transformers.dart @@ -0,0 +1,43 @@ + +import 'package:html/dom.dart'; +import 'package:core/presentation/utils/html_transformer/base/dom_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; + +class ViewPortTransformer extends DomTransformer { + + static final Element _viewPortMetaElement = Element.html( + ''); + static final Element _contentTypeMetaElement = Element.html( + ''); + + const ViewPortTransformer(); + + @override + void process(Document document, String message, TransformConfiguration configuration) { + final metaElements = document.getElementsByTagName('meta'); + var viewportNeedsToBeAdded = true; + var contentTypeNeedsToBeAdded = true; + for (final metaElement in metaElements) { + if (metaElement.attributes['name'] == 'viewport') { + viewportNeedsToBeAdded = false; + metaElement.attributes['content'] = 'width=device-width, initial-scale=1.0'; + } else if (metaElement.attributes['charset'] != null) { + metaElement.attributes['charset'] = 'utf-8'; + } else { + final httpEquiv = metaElement.attributes['http-equiv']; + if (httpEquiv != null && httpEquiv.toLowerCase() == 'content-type') { + contentTypeNeedsToBeAdded = false; + metaElement.attributes['content'] = 'text/html; charset=utf-8'; + } + } + } + if (contentTypeNeedsToBeAdded) { + ensureDocumentHeadIsAvailable(document); + document.head!.append(_contentTypeMetaElement); + } + if (viewportNeedsToBeAdded) { + ensureDocumentHeadIsAvailable(document); + document.head!.append(_viewPortMetaElement); + } + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/dom/script_transformers.dart b/core/lib/presentation/utils/html_transformer/dom/script_transformers.dart new file mode 100644 index 000000000..cd6d595d6 --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/dom/script_transformers.dart @@ -0,0 +1,17 @@ + +import 'package:html/dom.dart'; +import 'package:core/presentation/utils/html_transformer/base/dom_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; + +class RemoveScriptTransformer extends DomTransformer { + + const RemoveScriptTransformer(); + + @override + void process(Document document, String message, TransformConfiguration configuration) { + final scriptElements = document.getElementsByTagName('script'); + for (final scriptElement in scriptElements) { + scriptElement.remove(); + } + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/dom/table_transformers.dart b/core/lib/presentation/utils/html_transformer/dom/table_transformers.dart new file mode 100644 index 000000000..7c3055e74 --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/dom/table_transformers.dart @@ -0,0 +1,36 @@ + +import 'package:core/presentation/utils/html_transformer/base/dom_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; +import 'package:html/dom.dart'; + +class SetStyleTableTransformer extends DomTransformer { + + const SetStyleTableTransformer(); + + @override + void process(Document document, String message, TransformConfiguration configuration) { + final tableElements = document.getElementsByTagName('table'); + for (final table in tableElements) { + final style = table.attributes['style']; + if (style == null) { + table.attributes['style'] = 'width: 100%;max-width: 100%;border:1px solid #f0f0f0;border-collapse: collapse;border-spacing: 2px;'; + } + } + + final tdElements = document.getElementsByTagName('td'); + for (final tdTag in tdElements) { + final style = tdTag.attributes['style']; + if (style == null) { + tdTag.attributes['style'] = 'padding: 13px;margin: 0px;border:1px solid #f0f0f0;'; + } + } + + final thElements = document.getElementsByTagName('th'); + for (final thTag in thElements) { + final style = thTag.attributes['style']; + if (style == null) { + thTag.attributes['style'] = 'padding: 13px;margin: 0px;border:1px solid #f0f0f0;'; + } + } + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/html_transform.dart b/core/lib/presentation/utils/html_transformer/html_transform.dart new file mode 100644 index 000000000..5dc89206b --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/html_transform.dart @@ -0,0 +1,42 @@ +import 'package:core/presentation/utils/html_transformer/message_content_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; +import 'package:html/dom.dart'; + +class HtmlTransform { + + final String _message; + + HtmlTransform(this._message); + + /// Transforms this message to HTML code. + /// + /// Set [blockExternalImages] to `true` in case external images should be blocked. + /// Optionally specify the [transformConfiguration] to control all aspects of the transformation - in that case other parameters are ignored. + String transformToHtml({ + bool? blockExternalImages, + TransformConfiguration? transformConfiguration, + }) { + final document = transformToDocument( + blockExternalImages: blockExternalImages, + transformConfiguration: transformConfiguration, + ); + return document.outerHtml; + } + + /// Transforms this message to Document. + /// + /// Set [blockExternalImages] to `true` in case external images should be blocked. + /// Optionally specify the [transformConfiguration] to control all aspects of the transformation - in that case other parameters are ignored. + Document transformToDocument({ + bool? blockExternalImages, + int? maxImageWidth, + TransformConfiguration? transformConfiguration, + }) { + transformConfiguration ??= TransformConfiguration.create( + blockExternalImages: blockExternalImages, + maxImageWidth: maxImageWidth, + ); + final transformer = MessageContentTransformer(transformConfiguration); + return transformer.toDocument(_message); + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/message_content_transformer.dart b/core/lib/presentation/utils/html_transformer/message_content_transformer.dart new file mode 100644 index 000000000..4dcca68cb --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/message_content_transformer.dart @@ -0,0 +1,28 @@ +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; +import 'package:html/dom.dart'; +import 'package:html/parser.dart' show parse; + +/// Transforms messages +class MessageContentTransformer { + /// The configuration used for the transformation + final TransformConfiguration configuration; + + MessageContentTransformer(this.configuration); + + void transformDocument(Document document, String message) { + for (final domTransformer in configuration.domTransformers) { + domTransformer.process(document, message, configuration); + } + } + + Document toDocument(String message) { + var html = message; + final document = parse(html); + transformDocument(document, message); + return document; + } + + String toHtml(String message) { + return toDocument(message).outerHtml; + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/text/convert_tags_text_transformer.dart b/core/lib/presentation/utils/html_transformer/text/convert_tags_text_transformer.dart new file mode 100644 index 000000000..a235b9c12 --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/text/convert_tags_text_transformer.dart @@ -0,0 +1,13 @@ + +import 'package:core/presentation/utils/html_transformer/base/text_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; + +class ConvertTagsTextTransformer implements TextTransformer { + + const ConvertTagsTextTransformer(); + + @override + String transform(String text, String message, TransformConfiguration configuration) { + return text.replaceAll('<', '<').replaceAll('>', '>'); + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/text/linebreak_text_transformer.dart b/core/lib/presentation/utils/html_transformer/text/linebreak_text_transformer.dart new file mode 100644 index 000000000..98abf9a6b --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/text/linebreak_text_transformer.dart @@ -0,0 +1,15 @@ + +import 'package:core/presentation/utils/html_transformer/base/text_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; + +class LineBreakTextTransformer extends TextTransformer { + + const LineBreakTextTransformer(); + + @override + String transform(String text, String message, TransformConfiguration configuration) { + text = text.replaceAll('\r\n', '
'); + text = text.replaceAll('\n', '
'); + return text; + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/text/links_text_transformer.dart b/core/lib/presentation/utils/html_transformer/text/links_text_transformer.dart new file mode 100644 index 000000000..6864133a1 --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/text/links_text_transformer.dart @@ -0,0 +1,47 @@ + +import 'package:core/presentation/utils/html_transformer/base/text_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; + +class LinksTextTransformer extends TextTransformer { + + static final RegExp schemeRegEx = RegExp(r'[a-z]{3,6}://'); + // Not a perfect but good enough regular expression to match URLs in text. + // It also matches a space at the beginning and a dot at the end, + // so this is filtered out manually in the found matches + static final RegExp linkRegEx = RegExp( + r'(([a-z]{3,6}:\/\/)|(^|\s))([a-zA-Z0-9\-]+\.)+[a-z]{2,13}([\?\/]+[\.\?\=\&\%\/\w\+\-]*)?'); + const LinksTextTransformer(); + + @override + String transform(String text, String message, TransformConfiguration configuration) { + final matches = linkRegEx.allMatches(text); + if (matches.isEmpty) { + return text; + } + final buffer = StringBuffer(); + var end = 0; + for (final match in matches) { + if (match.end < text.length && text[match.end] == '@') { + // this is an email address, abort abort! + continue; + } + final originalGroup = match.group(0)!; + final group = originalGroup.trimLeft(); + final start = match.start + originalGroup.length - group.length; + buffer.write(text.substring(end, start)); + final endsWithDot = group.endsWith('.'); + final urlText = + endsWithDot ? group.substring(0, group.length - 1) : group; + buffer.write('')..write(urlText)..write(''); + end = endsWithDot ? match.end - 1 : match.end; + } + if (end < text.length) { + buffer.write(text.substring(end)); + } + return buffer.toString(); + } +} \ No newline at end of file diff --git a/core/lib/presentation/utils/html_transformer/transform_configuration.dart b/core/lib/presentation/utils/html_transformer/transform_configuration.dart new file mode 100644 index 000000000..498ee4962 --- /dev/null +++ b/core/lib/presentation/utils/html_transformer/transform_configuration.dart @@ -0,0 +1,87 @@ + +import 'package:core/presentation/utils/html_transformer/dom/blockquote_transformers.dart'; +import 'package:core/presentation/utils/html_transformer/dom/image_transformers.dart'; +import 'package:core/presentation/utils/html_transformer/dom/link_transformers.dart'; +import 'package:core/presentation/utils/html_transformer/dom/meta_transformers.dart'; +import 'package:core/presentation/utils/html_transformer/dom/script_transformers.dart'; +import 'package:core/presentation/utils/html_transformer/base/dom_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/dom/table_transformers.dart'; +import 'package:core/presentation/utils/html_transformer/text/convert_tags_text_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/text/linebreak_text_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/text/links_text_transformer.dart'; +import 'package:core/presentation/utils/html_transformer/base/text_transformer.dart'; + +/// Contains the configuration for all transformations. +class TransformConfiguration { + + /// Should external images be blocked? + final bool blockExternalImages; + + /// The maximum width for embedded images. It make sense to limit this to reduce the generated HTML size. + final int? maxImageWidth; + + /// The list of DOM transformers being used + final List domTransformers; + + /// The list of text transformers that are used before a plain text message without HTML part is converted into HTML + final List textTransformers; + + /// Creates a new transform configuration + /// + /// Compare [create] to have an easier to use building function + const TransformConfiguration( + this.blockExternalImages, + this.maxImageWidth, + this.domTransformers, + this.textTransformers + ); + + /// Provides easy access to a standard configuration that does not block external images. + static const TransformConfiguration standardConfiguration = TransformConfiguration( + false, + standardMaxImageWidth, + standardDomTransformers, + standardTextTransformers + ); + + /// Provides an easy option to customize a configuration. + /// + /// Any specified [customDomTransformers] or [customTextTransformers] are being appended to the standard transformers. + static TransformConfiguration create({ + bool? blockExternalImages, + int? maxImageWidth, + List? customDomTransformers, + List? customTextTransformers + }) { + final domTransformers = (customDomTransformers != null) + ? [...standardDomTransformers, ...customDomTransformers] + : [...standardDomTransformers]; + final textTransformers = (customTextTransformers != null) + ? [...standardTextTransformers, ...customTextTransformers] + : standardTextTransformers; + maxImageWidth ??= standardMaxImageWidth; + return TransformConfiguration( + blockExternalImages ?? false, + maxImageWidth, + domTransformers, + textTransformers + ); + } + + static const int? standardMaxImageWidth = null; + + static const List standardDomTransformers = [ + ViewPortTransformer(), + RemoveScriptTransformer(), + ImageTransformer(), + EnsureRelationNoReferrerTransformer(), + SetStyleBlockQuoteTransformer(), + SetStyleTableTransformer(), + ]; + + static const List standardTextTransformers = [ + ConvertTagsTextTransformer(), + LinksTextTransformer(), + LineBreakTextTransformer(), + ]; +} \ No newline at end of file