Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to use HTML in group descriptions and posts #4

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"htmx.org": "^1.9"
},
"bundles": {
"vendor": {"htmx.org": ["dist/htmx.min.js", "dist/ext/ws.js"], "fonts://display=swap": "Rubik:wght@300"}
"vendor": {
"htmx.org": ["dist/htmx.min.js", "dist/ext/ws.js"],
"fonts://display=swap": ["Rubik:wght@300", "JetBrains+Mono:wght@400"],
".": ["src/main/js/editor.js"]
}
}
}
16 changes: 7 additions & 9 deletions src/main/handlebars/group.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@
</div>
{{/if}}
</div>
<p id="description">
<div id="description">
{{#with group}}
{{#*fragment "description"}}{{description}}{{/fragment}}
{{#*fragment "description"}}{{& description}}{{/fragment}}
{{/with}}
</p>
</div>
<hr>

<form hx-post="/group/{{group._id}}/posts" hx-target="#posts" hx-swap="afterbegin" hx-on::after-request="this.reset()">
<div>
<label for="body">What's on your mind?</label>
<textarea name="body" cols="80" rows="4"></textarea>
{{> traits/editor id="post" field="body"}}
</div>
<button type="submit">Post</button>
</form>
Expand All @@ -28,7 +28,7 @@
{{#each posts}}
{{#*fragment "post"}}
<div class="post" id="p{{_id}}" {{#with swap}}hx-swap-oob="{{.}}"{{/with}}>
<p {{#if (emoji body)}}class="emoji"{{/if}}>{{body}}</p>
<p {{#if (emoji body)}}class="emoji"{{/if}}>{{& body}}</p>

<div class="actions" hx-target="#p{{_id}}">
<small>Posted by <b>{{editor.name}}</b> on {{date created}}{{#with updated}}, updated {{date .}}{{/with}}</small>
Expand All @@ -48,7 +48,7 @@
{{#*inline "edit"}}
<form hx-put="/group/{{group}}/posts/{{_id}}" hx-target="#p{{_id}}" hx-swap="outerHTML">
<div>
<textarea name="body" cols="80" rows="4">{{body}}</textarea>
{{> traits/editor id=_id field="body" value=body}}
</div>
<div>
<button type="submit">💾</button>
Expand All @@ -58,9 +58,7 @@
{{/inline}}
{{#*inline "update"}}
<form hx-put="/group/{{_id}}" hx-target="#description" hx-swap="innerHTML">
<div>
<textarea name="description" cols="80" rows="4">{{description}}</textarea>
</div>
{{> traits/editor id=_id field="description" value=description}}
<div>
<button type="submit">💾</button>
<button type="cancel" hx-get="/group/{{_id}}/description">x</button>
Expand Down
4 changes: 2 additions & 2 deletions src/main/handlebars/index.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<img src="https://picsum.photos/536/354?random={{_id}}" width="536" height="354" alt="{{name}}">
</a>
<h2><a href="/group/{{_id}}">{{name}}</a></h2>
<p>{{description}}</p>
<div class="description">{{& description}}</div>

<div class="actions">
<small>Created by <b>{{owner.name}}</b> on {{date created format="d.m.Y"}}</small>
Expand All @@ -39,7 +39,7 @@
</div>
<div>
<label for="body">Group description</label>
<textarea name="description" cols="80" rows="4"></textarea>
{{> traits/editor id="group" field="description"}}
</div>
<div>
<button type="submit" hx-disabled-elt="this">Create</button>
Expand Down
49 changes: 43 additions & 6 deletions src/main/handlebars/layout.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
font-family: 'Rubik', sans-serif;
}

pre, code {
font-family: 'JetBrains Mono', monospace;
}

body {
background-color: #ede6e1;
margin: 0;
Expand All @@ -34,7 +38,7 @@
form {
display: flex;
flex-direction: column;
width: max-content;
width: 100%;
align-items: flex-start;
gap: .5rem;
}
Expand All @@ -48,6 +52,25 @@
width: 100%;
}

div.editor {
width: 100%;
}

div[contenteditable] {
background-color: white;
padding: .25rem;
width: 100%;
outline: 1px solid black;
}

div[contenteditable] p {
margin: 0;
}

div[contenteditable] p + p {
margin-block-start: 1rem;
}

button[type='submit'] {
padding: .25rem .5rem;
}
Expand Down Expand Up @@ -85,12 +108,26 @@
}
}

.group h2, .group p {
.group h2 {
padding-inline: 1rem;
margin: 0;
}

.group h2 {
margin: 0;
.group .description {
padding-inline: 1rem;
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;

& * {
font-size: 1rem;
padding-inline: 0;
}

& *:not(:first-child) {
display: none;
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, CSS nesting works fine in all major browsers: https://caniuse.com/?search=CSS%20nesting 😄

}
}

.posts {
Expand All @@ -117,8 +154,8 @@
padding: .5rem;
}

.post p {
white-space: pre-wrap;
.post p:empty {
display: none;
}

.post p.emoji {
Expand Down
15 changes: 15 additions & 0 deletions src/main/handlebars/traits/editor.handlebars
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div class="editor" id="editor-{{id}}">
<input type="hidden" name="{{field}}" value="{{value}}">
<div contenteditable="true">{{& value}}</div>
<div class="buttons">
<button name="bold">B</button>
<button name="italic">I</button>
<button name="strikeThrough">S</button>
<span> | </span>
<button name="h2" data-command="formatBlock">Heading</button>
<button name="h3" data-command="formatBlock">Subtitle</button>
<button name="p" data-command="formatBlock">Paragraph</button>
<button name="pre" data-command="formatBlock">Preformatted</button>
</div>
</div>
<script type="module">new Editor(document.getElementById('editor-{{id}}'));</script>
106 changes: 106 additions & 0 deletions src/main/js/editor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Replacement for Sanitizer API, see https://web.dev/articles/sanitizer
// but also includes the possibility to rewrite tags to others.
class Markup {
EXPAND = true;
REMOVE = false;
#handle = {
'H1' : {},
'H2' : {},
'H3' : {},
'B' : {},
'I' : {},
'U' : {},
'P' : {},
'CODE' : {},
'PRE' : {},
'STRIKE' : {},
'STRONG' : {emit: 'b'},
'EM' : {emit: 'i'},
'SECTION' : {emit: 'p'},
'ARTICLE' : {emit: 'p'},
'A' : {attributes: ['href']},
'DIV' : this.EXPAND,
'SCRIPT' : this.REMOVE,
'EMBED' : this.REMOVE,
'OBJECT' : this.REMOVE,
'IFRAME' : this.REMOVE,
'#comment' : this.REMOVE,
'#text' : (n) => n.wholeText,
};

html(text) {
return text.replace(/[<>&'"]/g, m => `&#${m.charCodeAt(0)};`);
}

transform(nodes) {
let transformed = '';
for (const node of nodes) {
// console.log(node.nodeName, node);

const handle = this.#handle[node.nodeName] ?? null;
if (null === handle) {
transformed += this.html(node.innerText);
} else if (this.REMOVE === handle) {
// NOOP
} else if (this.EXPAND === handle) {
transformed += this.transform(node.childNodes);
} else if ('function' === typeof handle) {
transformed += handle(node);
} else {
const tag = handle.emit ?? node.nodeName;
transformed += `<${tag}`;
for (const attribute of handle.attributes ?? []) {
if (node.hasAttribute(attribute)) {
transformed += ` ${attribute}="${this.html(node.getAttribute(attribute))}"`;
}
}
transformed += `>${this.transform(node.childNodes)}</${tag}>`;
}
}
return transformed;
}
}

class Editor {
static markup = new Markup();

constructor($node) {
const $field = $node.querySelector('input[type="hidden"]');
const $editable = $node.querySelector('div[contenteditable="true"]');

for (const $button of $node.querySelectorAll('button')) {
$button.addEventListener('click', e => {
e.preventDefault();
if ($button.dataset.command) {
document.execCommand($button.dataset.command, false, $button.name);
} else {
document.execCommand($button.name, false, null);
}
});
}

// Synchronize hidden field
$editable.addEventListener('input', e => {
$field.value = $editable.innerHTML;
});

// If the enclosing form is being reset, reset the editable
$editable.closest('form').addEventListener('reset', e => {
$editable.innerText= '';
});

// Transform HTML, inserting text as-is. Todo: images, see
// https://web.dev/patterns/clipboard/paste-images/
$editable.addEventListener('paste', e => {
console.log(e);
e.preventDefault();
if (-1 !== e.clipboardData.types.indexOf('text/html')) {
const doc = document.implementation.createHTMLDocument('Pasted text');
doc.write(e.clipboardData.getData('text/html'));
document.execCommand('insertHTML', false, Editor.markup.transform(doc.body.childNodes));
} else {
document.execCommand('insertText', false, e.clipboardData.getData('text/plain'));
}
});
}
}
97 changes: 97 additions & 0 deletions src/main/php/de/thekid/crews/Markup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php namespace de\thekid\crews;

use DOMDocument;
use DOM\HTMLDocument;

/**
* Transforms HTML markup to a subset we can render in the pages
*
* @test de.thekid.crews.unittest.MarkupTest
* @see https://wiki.php.net/rfc/domdocument_html5_parser
* @see https://github.com/php/php-src/security/advisories/GHSA-3qrf-m4j2-pcrr
* @see https://research.securitum.com/dompurify-bypass-using-mxss/
* @see https://research.securitum.com/mutation-xss-via-mathml-mutation-dompurify-2-0-17-bypass/
* @see https://knowledge-base.secureflag.com/vulnerabilities/xml_injection/xml_entity_expansion_php.html
*/
class Markup {
const REMOVE= false;
const EXPAND= true;

private $handle= [
'h1' => [],
'h2' => [],
'h3' => [],
'p' => [],
'b' => [],
'i' => [],
'u' => [],
's' => [],
'pre' => [],
'code' => [],
'strike' => [],
'hr' => [],
'br' => [],
'a' => ['attributes' => ['href']],
'img' => ['attributes' => ['src']],
'strong' => ['emit' => 'b'],
'em' => ['emit' => 'i'],
'div' => self::EXPAND,
'#comment' => self::REMOVE,
'script' => self::REMOVE,
'embed' => self::REMOVE,
'object' => self::REMOVE,
'iframe' => self::REMOVE,
];

public function __construct(array<string, mixed> $handle= []) {
$this->handle+= $handle;
}

private function process($nodes) {
$text= '';
foreach ($nodes as $node) {
$handle= $this->handle[$node->nodeName] ?? null;
if (null === $handle) {
$text.= htmlspecialchars($node->textContent);
} else if (self::REMOVE === $handle) {
// NOOP
} else if (self::EXPAND === $handle) {
$text.= $this->process($node->childNodes);
} else {
$tag= $handle['emit'] ?? $node->nodeName;
$text.= "<{$tag}";
foreach ($handle['attributes'] ?? [] as $attribute) {
if ($node->hasAttribute($attribute)) {
$text.= ' '.$attribute.'="'.htmlspecialchars($node->getAttribute($attribute)).'"';
}
}
$text.= ">{$this->process($node->childNodes)}</{$tag}>";
}
}
return $text;
}

public function transform(string $input): string {
if (strlen($input) === strspn($input, "\r\n\t ")) return $input;

libxml_clear_errors();
$useInternal= libxml_use_internal_errors(true);
$entityLoader= PHP_VERSION_ID >= 80200 ? libxml_get_external_entity_loader() : null;
libxml_set_external_entity_loader(fn() => null);

// Use https://wiki.php.net/rfc/domdocument_html5_parser for PHP 8.4+
try {
if (PHP_VERSION_ID >= 80400) {
$doc= HTMLDocument::createFromString("<!DOCTYPE html><html><body>{$input}</body></html>", 0, 'utf-8');
} else {
$doc= new DOMDocument('1.0', 'utf-8');
$doc->loadHTML("<html><head><meta charset='utf-8'></head><body>{$input}</body></html>", LIBXML_NONET);
}
} finally {
libxml_use_internal_errors($useInternal);
$entityLoader && libxml_set_external_entity_loader($entityLoader);
}

return $this->process($doc->getElementsByTagName('body')->item(0)->childNodes);
}
}
Loading
Loading