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"]
}
}
}
15 changes: 15 additions & 0 deletions src/main/handlebars/components/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>
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>
{{> components/editor id="post" field="body"}}
thekid marked this conversation as resolved.
Show resolved Hide resolved
</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>
{{> components/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>
{{> components/editor id=_id field="description" value=description}}
<div>
<button type="submit">💾</button>
<button type="cancel" hx-get="/group/{{_id}}/description">x</button>
Expand Down
2 changes: 1 addition & 1 deletion src/main/handlebars/index.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
</div>
<div>
<label for="body">Group description</label>
<textarea name="description" cols="80" rows="4"></textarea>
{{> components/editor id="group" field="description"}}
</div>
<div>
<button type="submit" hx-disabled-elt="this">Create</button>
Expand Down
32 changes: 29 additions & 3 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 @@ -117,8 +140,8 @@
padding: .5rem;
}

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

.post p.emoji {
Expand Down Expand Up @@ -173,5 +196,8 @@
{{> content}}
</main>
<script src="/static/vendor.js"></script>
{{#> scripts}}
<!-- Defaults to empty -->
{{/scripts}}
</body>
</html>
101 changes: 101 additions & 0 deletions src/main/js/editor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// 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(/<>&'"/, 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;
})

// 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'));
}
});
}
}
91 changes: 91 additions & 0 deletions src/main/php/de/thekid/crews/Markup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php namespace de\thekid\crews;

use DOMDocument;

/**
* Transforms HTML markup to a subset we can render in the pages
*
* @test de.thekid.crews.unittest.MarkupTest
* @see https://research.securitum.com/dompurify-bypass-using-mxss/
* @see https://research.securitum.com/mutation-xss-via-mathml-mutation-dompurify-2-0-17-bypass/
*/
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= libxml_get_external_entity_loader();
libxml_set_external_entity_loader(fn() => null);

try {
$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);
libxml_set_external_entity_loader($entityLoader);
}

return $this->process($doc->getElementsByTagName('body')->item(0)->childNodes);
}
}
11 changes: 7 additions & 4 deletions src/main/php/de/thekid/crews/web/Group.php
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
<?php namespace de\thekid\crews\web;

use com\mongodb\{Database, Collection, Document, ObjectId};
use de\thekid\crews\{Events, User};
use de\thekid\crews\{Markup, Events, User};
use util\Date;
use web\frontend\{Handler, Get, Post, Delete, Param, Put, Value, View};

#[Handler('/group/{group}')]
class Group {
private Collection $groups, $posts;
private $markup= new Markup();

public function __construct(Database $db, private Events $events) {
$this->groups= $db->collection('groups');
Expand Down Expand Up @@ -36,15 +37,17 @@ public function group(ObjectId $group, string $view) {

#[Put]
public function describe(#[Value] User $user, ObjectId $group, #[Param] string $description) {
$result= $this->groups->modify($user->where($group, 'owner'), ['$set' => ['description' => $description]]);
$result= $this->groups->modify($user->where($group, 'owner'), ['$set' => [
'description' => $this->markup->transform($description),
]]);
return View::named('group#description')->with($result->document()->properties());
}

#[Post('/posts')]
public function create(#[Value] User $user, ObjectId $group, #[Param] string $body) {
$insert= $this->posts->insert(new Document([
'group' => $group,
'body' => $body,
'body' => $this->markup->transform($body),
'editor' => $user->reference(),
'created' => Date::now(),
]));
Expand All @@ -71,7 +74,7 @@ public function delete(#[Value] User $user, ObjectId $group, ObjectId $id) {
#[Put('/posts/{id}')]
public function update(#[Value] User $user, ObjectId $group, ObjectId $id, #[Param] string $body) {
$this->posts->update($user->where($id, 'editor'), ['$set' => [
'body' => $body,
'body' => $this->markup->transform($body),
'updated' => Date::now(),
]]);
$this->events->publish($user, $group, ['update' => $id]);
Expand Down
Loading
Loading