From 45ac20c7a4d632bbb23aaba50c2d79ac5ebac465 Mon Sep 17 00:00:00 2001 From: winter Date: Fri, 14 Feb 2025 17:56:45 +0000 Subject: implement formatted note contents (html and commonmark) --- Digitigrade/FormattingProvider.php | 7 ++++ Digitigrade/FormattingProvider/CommonMark.php | 49 ++++++++++++++++++++++++ Digitigrade/FormattingProvider/SanitisedHtml.php | 31 +++++++++++++++ Digitigrade/Model/Note.php | 36 ++++++++++++++++- 4 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 Digitigrade/FormattingProvider.php create mode 100644 Digitigrade/FormattingProvider/CommonMark.php create mode 100644 Digitigrade/FormattingProvider/SanitisedHtml.php (limited to 'Digitigrade') diff --git a/Digitigrade/FormattingProvider.php b/Digitigrade/FormattingProvider.php new file mode 100644 index 0000000..1d35b38 --- /dev/null +++ b/Digitigrade/FormattingProvider.php @@ -0,0 +1,7 @@ + 'escape', + 'renderer' => [ + 'soft_break' => '
' + ], + 'autolink' => [ + 'allowed_protocols' => SanitisedHtml::ALLOWED_URL_SCHEMES + ], + 'external_link' => [ + 'internal_hosts' => [GlobalConfig::getInstance()->getCanonicalHost()], + 'open_in_new_window' => true, + 'nofollow' => 'all', + 'noopener' => 'all', + 'noreferrer' => 'external' + ] + ]); + $env->addExtension(new CommonMarkCoreExtension); + $env->addExtension(new AutolinkExtension); + $env->addExtension(new ExternalLinkExtension); + $env->addExtension(new StrikethroughExtension); + $this->converter = new MarkdownConverter($env); + } + + public function renderToHtml(string $input): string { + return $this->converter->convert($input); + } + + public function renderToPlainText(string $input): string { + // TODO: somehow remove markup and leave the unformatted content behind + return htmlspecialchars($input); + } +} \ No newline at end of file diff --git a/Digitigrade/FormattingProvider/SanitisedHtml.php b/Digitigrade/FormattingProvider/SanitisedHtml.php new file mode 100644 index 0000000..fa2e5eb --- /dev/null +++ b/Digitigrade/FormattingProvider/SanitisedHtml.php @@ -0,0 +1,31 @@ +sanitiser = new HtmlSanitizer((new HtmlSanitizerConfig()) + ->allowSafeElements() + ->allowLinkSchemes(self::ALLOWED_URL_SCHEMES) + ); + } + + public function renderToHtml(string $input): string { + return $this->sanitiser->sanitize($input); + } + + public function renderToPlainText(string $input): string { + // TODO: somehow remove tags and leave the unformatted content behind + return htmlspecialchars($input); + } +} \ No newline at end of file diff --git a/Digitigrade/Model/Note.php b/Digitigrade/Model/Note.php index a4df99d..d5d4e69 100644 --- a/Digitigrade/Model/Note.php +++ b/Digitigrade/Model/Note.php @@ -2,6 +2,8 @@ namespace Digitigrade\Model; use Digitigrade\Db; +use Digitigrade\FormattingProvider\CommonMark; +use Digitigrade\FormattingProvider\SanitisedHtml; use Digitigrade\Model\HomeTimelineItem; use Digitigrade\Notification\Notifyable; use Digitigrade\Timeline\TimelineIncludeable; @@ -91,6 +93,7 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable { $this->saveMentions(); $this->saveAttachments(); $this->saveExtensions(); + $this->saveFormattedContents(); } protected function validate(): bool { @@ -102,7 +105,11 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable { $pdo = Db::getInstance()->getPdo(); $stmt = $pdo->prepare('SELECT mimetype, body FROM note_formatted_content WHERE note_id = ?'); $stmt->execute([$this->id]); - return $stmt->fetchAll(\PDO::FETCH_ASSOC); + $rows = $stmt->fetchAll(); + return array_combine( + array_map(fn($row) => $row['mimetype'], $rows), + array_map(fn($row) => $row['body'], $rows) + ); } private function findMentions(): array { @@ -126,6 +133,17 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable { return $items; } + private function saveFormattedContents() { + $pdo = Db::getInstance()->getPdo(); + foreach ($this->formattedContent as $mimetype => $body) { + $stmt = $pdo->prepare( + 'INSERT INTO note_formatted_content(note_id, mimetype, body) VALUES (?, ?, ?)' . + 'ON CONFLICT (note_id, mimetype) DO UPDATE SET body = EXCLUDED.body' + ); + $stmt->execute([$this->id, $mimetype, $body]); + } + } + private function saveMentions() { $pdo = Db::getInstance()->getPdo(); foreach ($this->mentions as $mention) { @@ -230,7 +248,21 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable { * @return ?string the marked up content */ public function getFormattedContent(string $mimetype): ?string { - return array_filter($this->formattedContent, fn($item) => $item['mimetype'] == $mimetype)[0]['body'] ?? null; + return $this->formattedContent[$mimetype] ?? null; + } + + /** + * Gets the best available formatted content (or plain if there aren't any) + * and converts it to sanitised HTML + * @return string HTML that is hopefully safe to include directly + */ + public function getBestContentAsHtml(): string { + if (isset($this->formattedContent['text/html'])) { + return SanitisedHtml::getInstance()->renderToHtml($this->formattedContent['text/html']); + } elseif (isset($this->formattedContent['text/markdown'])) { + return CommonMark::getInstance()->renderToHtml($this->formattedContent['text/markdown']); + } + return htmlspecialchars($this->plainContent); } public function renderAsHtml() { -- cgit v1.3