From 71608f83ce842c555b1c3f214285854b27b13258 Mon Sep 17 00:00:00 2001 From: menvae Date: Wed, 26 Nov 2025 23:27:48 +0200 Subject: [PATCH 01/16] add editing for descriptions --- src/components/map-set/description.vue | 129 +++++++++++++++++++++++++ src/models/maps/APIMapSet.ts | 1 - src/pages/set/[id]/info.vue | 8 +- 3 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 src/components/map-set/description.vue diff --git a/src/components/map-set/description.vue b/src/components/map-set/description.vue new file mode 100644 index 0000000..2cb7c66 --- /dev/null +++ b/src/components/map-set/description.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/src/models/maps/APIMapSet.ts b/src/models/maps/APIMapSet.ts index 192cae8..86022d7 100644 --- a/src/models/maps/APIMapSet.ts +++ b/src/models/maps/APIMapSet.ts @@ -10,7 +10,6 @@ export type APIMapSet = { artist: string; source: string; tags: string[]; - description: string; flags: number; status: APIMapSetStatus; submitted: number; diff --git a/src/pages/set/[id]/info.vue b/src/pages/set/[id]/info.vue index 90fe057..b603cc5 100644 --- a/src/pages/set/[id]/info.vue +++ b/src/pages/set/[id]/info.vue @@ -6,17 +6,19 @@ definePageMeta({ alias: '/set/:id' }); -defineProps<{ +const props = defineProps<{ mapset: APIMapSet; map: APIMap; }>(); + +const { data: api_description } = await API.PerformGet(`/mapset/${props.mapset.id}/description`); +const description = api_description ?? "No Description Provided."; \ No newline at end of file diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index 73836f7..c2bc4c9 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -2,6 +2,7 @@ import { marked, type RendererObject } from 'marked'; import ParsedMarkdown from '~/models/markdown/ParsedMarkdown'; import ParsedSection from '~/models/markdown/ParsedSection'; import ParsedSubSection from '~/models/markdown/ParsedSubSection'; +import Sanitizer from './sanitize'; export default class Markdown { static FootnoteRegex = /\[\^(\d{1,2})\]/g; @@ -36,8 +37,8 @@ export default class Markdown { return data; } - static Render(md: string): string { - md = md.replaceAll('<', '<'); + static async Render(md: string, sanitize: boolean = true): Promise { + md = sanitize ? await Sanitizer.Sanitize(md) : md.replaceAll('<', '<'); const config: RendererObject = { heading: (head) => { diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts new file mode 100644 index 0000000..a409f63 --- /dev/null +++ b/src/utils/sanitize.ts @@ -0,0 +1,85 @@ +import type { Config } from 'dompurify'; + +export interface SanitizeOptions { + allowedTags?: string[]; + allowedAttr?: string[]; + allowDataAttr?: boolean; + stripAll?: boolean; +} + +export default class Sanitizer { + + private static readonly DEFAULT_ALLOWED_TAGS: string[] = [ + 'p', 'br', 'span', 'div', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'strong', 'em', 'b', 'i', 'u', 's', 'strike', 'del', + 'blockquote', 'code', 'pre', + 'ul', 'ol', 'li', + 'a', 'img', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'hr', + 'MarkdownHeader', 'MarkdownCodeBlock', 'MarkdownBlockquote', + 'MarkdownFootnote', 'MarkdownImage', 'NuxtLink' + ]; + + private static readonly DEFAULT_ALLOWED_ATTR: string[] = [ + 'href', 'src', 'alt', 'title', 'class', 'id', + 'text', 'level', 'lang', 'type', 'path', 'num', 'to' + ]; + + static async Sanitize(dirty: string, options?: SanitizeOptions): Promise { + if (!dirty || typeof dirty !== 'string') { + return ''; + } + + if (typeof window === 'undefined') { + return dirty; + } + + if (options?.stripAll) { + return this.StripAllHtml(dirty); + } + + const { default: DOMPurify } = await import('dompurify'); + + let config: Config = { + ALLOWED_TAGS: options?.allowedTags || this.DEFAULT_ALLOWED_TAGS, + ALLOWED_ATTR: options?.allowedAttr || this.DEFAULT_ALLOWED_ATTR, + ALLOW_DATA_ATTR: options?.allowDataAttr ?? false, + KEEP_CONTENT: true, + RETURN_TRUSTED_TYPE: false, + FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'link', 'base', 'meta', 'form'], + FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'oninput'], + ALLOW_UNKNOWN_PROTOCOLS: false, + ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|xxx):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i + }; + + try { + return DOMPurify.sanitize(dirty, config); + } catch (error) { + console.error('Sanitization error:', error); + return ''; + } + } + + static async StripAllHtml(dirty: string): Promise { + if (!dirty || typeof dirty !== 'string') { + return ''; + } + + if (typeof window === 'undefined') { + return dirty; + } + + const { default: DOMPurify } = await import('dompurify'); + + const sanitized = DOMPurify.sanitize(dirty, { + ALLOWED_TAGS: [], + KEEP_CONTENT: true + }); + + const textarea = document.createElement('textarea'); + textarea.innerHTML = sanitized; + return textarea.value; + } +} \ No newline at end of file From 054856664871ef06e9c442a79755e1651e0c3868 Mon Sep 17 00:00:00 2001 From: menvae Date: Thu, 27 Nov 2025 22:35:51 +0200 Subject: [PATCH 03/16] reduce schemes this is probably for the better I don't think it's reasonable to have them --- src/utils/sanitize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts index a409f63..507fef6 100644 --- a/src/utils/sanitize.ts +++ b/src/utils/sanitize.ts @@ -51,7 +51,7 @@ export default class Sanitizer { FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'link', 'base', 'meta', 'form'], FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'oninput'], ALLOW_UNKNOWN_PROTOCOLS: false, - ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|xxx):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i + ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i }; try { From a13cd15cc4f6c171d52d514e91d5cace1c477b92 Mon Sep 17 00:00:00 2001 From: menvae Date: Mon, 8 Dec 2025 21:45:43 +0200 Subject: [PATCH 04/16] refactor for description + properly sanitize md + new md editor + change style of scrollbar a bit (it looks ugly in textarea) + fix the overflow caused by description --- src/App.vue | 21 ++- src/components/map-set/description.vue | 168 +++------------------- src/components/markdown-editor.vue | 187 +++++++++++++++++++++++++ src/utils/markdown.ts | 4 +- 4 files changed, 230 insertions(+), 150 deletions(-) create mode 100644 src/components/markdown-editor.vue diff --git a/src/App.vue b/src/App.vue index aad8a02..4e75768 100644 --- a/src/App.vue +++ b/src/App.vue @@ -18,12 +18,31 @@ else API.Logout(); -
+
+ + \ No newline at end of file diff --git a/src/components/markdown-editor.vue b/src/components/markdown-editor.vue new file mode 100644 index 0000000..7bcb70d --- /dev/null +++ b/src/components/markdown-editor.vue @@ -0,0 +1,187 @@ + + + + + \ No newline at end of file diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index c2bc4c9..87a67fa 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -38,7 +38,7 @@ export default class Markdown { } static async Render(md: string, sanitize: boolean = true): Promise { - md = sanitize ? await Sanitizer.Sanitize(md) : md.replaceAll('<', '<'); + md = md.replaceAll('<', '<'); const config: RendererObject = { heading: (head) => { @@ -141,6 +141,6 @@ export default class Markdown { }); } - return html; + return sanitize ? Sanitizer.Sanitize(html) : html; } } From d71cd729f00637b82b6367d037389fbd547ed7dc Mon Sep 17 00:00:00 2001 From: menvae Date: Mon, 8 Dec 2025 22:18:06 +0200 Subject: [PATCH 05/16] add image md support + fix preview/edit and save in md editor --- src/components/markdown-editor.vue | 12 +++++++++++- src/models/markdown/ParsedImage.ts | 11 +++++++++++ src/models/markdown/ParsedMarkdown.ts | 10 +++++++--- src/utils/markdown.ts | 28 +++++++++++++++++++++++++-- 4 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 src/models/markdown/ParsedImage.ts diff --git a/src/components/markdown-editor.vue b/src/components/markdown-editor.vue index 7bcb70d..344d8d6 100644 --- a/src/components/markdown-editor.vue +++ b/src/components/markdown-editor.vue @@ -13,6 +13,11 @@ const props = defineProps<{ cancelButtonText?: string; }>(); +const emit = defineEmits<{ + 'update:modelValue': [value: string]; + 'save': [value: string]; +}>(); + const MAX_CHARACTERS = props.maxCharacters ?? 2000; const MAX_HEIGHT = props.maxHeight ?? '400px'; const MIN_HEIGHT = props.minHeight ?? '200px'; @@ -50,9 +55,12 @@ const isOverLimit = computed(() => { const toggleMode = () => { if (isEditing.value) { + emit('update:modelValue', editedText.value); isEditing.value = false; } else { - editedText.value = props.modelValue || ''; + if (!editedText.value) { + editedText.value = props.modelValue || ''; + } isEditing.value = true; } }; @@ -67,6 +75,8 @@ const handleSave = async () => { try { isSaving.value = true; + emit('save', editedText.value); + emit('update:modelValue', editedText.value); isEditing.value = false; } catch (error) { console.error('Failed to save:', error); diff --git a/src/models/markdown/ParsedImage.ts b/src/models/markdown/ParsedImage.ts new file mode 100644 index 0000000..f86f47d --- /dev/null +++ b/src/models/markdown/ParsedImage.ts @@ -0,0 +1,11 @@ +export default class ParsedImage { + src: string; + alt: string; + lineNumber: number; + + constructor(src: string, alt: string, lineNumber: number) { + this.src = src + this.alt = alt + this.lineNumber = lineNumber + } +} \ No newline at end of file diff --git a/src/models/markdown/ParsedMarkdown.ts b/src/models/markdown/ParsedMarkdown.ts index 030a097..126748d 100644 --- a/src/models/markdown/ParsedMarkdown.ts +++ b/src/models/markdown/ParsedMarkdown.ts @@ -1,10 +1,14 @@ +import type ParsedImage from "./ParsedImage"; import type ParsedSection from "./ParsedSection" export default class ParsedMarkdown { - sections: ParsedSection[] = [] - raw: string + raw: string; + sections: ParsedSection[]; + images?: Array; constructor(raw: string) { - this.raw = raw + this.raw = raw; + this.sections = []; + this.images = []; } } \ No newline at end of file diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index 87a67fa..d1e88fd 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -2,16 +2,20 @@ import { marked, type RendererObject } from 'marked'; import ParsedMarkdown from '~/models/markdown/ParsedMarkdown'; import ParsedSection from '~/models/markdown/ParsedSection'; import ParsedSubSection from '~/models/markdown/ParsedSubSection'; +import ParsedImage from '~/models/markdown/ParsedImage'; import Sanitizer from './sanitize'; export default class Markdown { static FootnoteRegex = /\[\^(\d{1,2})\]/g; static BlockquoteRegex = /\{: \.(\w+) \}/g; + static ImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; static Parse(md: string): ParsedMarkdown { const data = new ParsedMarkdown(md); + data.images = []; + const lines = md.split('\n'); - data.raw.split('\n').forEach((line) => { + lines.forEach((line, index) => { if (line.startsWith('## ')) { const text = line.slice(3); const id = line @@ -32,6 +36,22 @@ export default class Markdown { .replace(/[^\w]+/g, '-'); last.subs.push(new ParsedSubSection(text, id)); } + + const imageMatches = [...line.matchAll(Markdown.ImageRegex)]; + imageMatches.forEach((match) => { + const alt = match[1]; + const src = match[2]; + + if (!data.images) { + data.images = []; + } + + data.images.push(new ParsedImage( + src, + alt, + index + 1 + )); + }); }); return data; @@ -73,7 +93,11 @@ export default class Markdown { return `${content}`; }, - image: (image) => `` + image: (image) => { + const escapedAlt = image.text.replace(/\"/g, '"'); + const escapedHref = image.href.replace(/\"/g, '"'); + return `${escapedAlt}`; + } }; marked.use({ renderer: config }); From 20a9ade3c3032c02ff88888a0d9b5dba6261610b Mon Sep 17 00:00:00 2001 From: menvae Date: Mon, 8 Dec 2025 23:01:17 +0200 Subject: [PATCH 06/16] add image domain whitelisting --- src/utils/markdown.ts | 56 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index d1e88fd..9302bce 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -10,6 +10,38 @@ export default class Markdown { static BlockquoteRegex = /\{: \.(\w+) \}/g; static ImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + static AllowedImageDomains: string[] = [ + 'flux.moe', + 'catbox.moe', + 'imgur.com' + ]; + + static isImageAllowed(url: string): boolean { + try { + if (url.startsWith('/')) { + return true; + } + + if (url.startsWith('//')) { + return true; + } + + const urlObj = new URL(url, window?.location?.href || ''); + const hostname = urlObj.hostname; + + const currentDomain = window?.location?.hostname || ''; + if (hostname === currentDomain || hostname.endsWith('.' + currentDomain)) { + return true; + } + + return this.AllowedImageDomains.some(domain => { + return hostname === domain || hostname.endsWith('.' + domain); + }); + } catch { + return false; + } + } + static Parse(md: string): ParsedMarkdown { const data = new ParsedMarkdown(md); data.images = []; @@ -42,15 +74,17 @@ export default class Markdown { const alt = match[1]; const src = match[2]; - if (!data.images) { - data.images = []; + if (this.isImageAllowed(src)) { + if (!data.images) { + data.images = []; + } + + data.images.push(new ParsedImage( + src, + alt, + index + 1 + )); } - - data.images.push(new ParsedImage( - src, - alt, - index + 1 - )); }); }); @@ -94,6 +128,10 @@ export default class Markdown { return `${content}`; }, image: (image) => { + if (!this.isImageAllowed(image.href)) { + return ''; + } + const escapedAlt = image.text.replace(/\"/g, '"'); const escapedHref = image.href.replace(/\"/g, '"'); return `${escapedAlt}`; @@ -167,4 +205,4 @@ export default class Markdown { return sanitize ? Sanitizer.Sanitize(html) : html; } -} +} \ No newline at end of file From 440ac10313e1c914d1197960acda3e18626923b2 Mon Sep 17 00:00:00 2001 From: menvae Date: Mon, 8 Dec 2025 23:05:46 +0200 Subject: [PATCH 07/16] add sanitize prop to md editor --- src/components/map-set/description.vue | 1 + src/components/markdown-editor.vue | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/map-set/description.vue b/src/components/map-set/description.vue index 5f14aa9..31330e1 100644 --- a/src/components/map-set/description.vue +++ b/src/components/map-set/description.vue @@ -36,6 +36,7 @@ const handleSave = async (value: string) => { v-model="description" :can-edit="canEdit" :max-characters="2000" + :sanitize="true" max-height="400px" placeholder="No description provided." @save="handleSave" diff --git a/src/components/markdown-editor.vue b/src/components/markdown-editor.vue index 344d8d6..1aa39eb 100644 --- a/src/components/markdown-editor.vue +++ b/src/components/markdown-editor.vue @@ -11,6 +11,7 @@ const props = defineProps<{ minHeight?: string; saveButtonText?: string; cancelButtonText?: string; + sanitize?: boolean; }>(); const emit = defineEmits<{ @@ -23,6 +24,7 @@ const MAX_HEIGHT = props.maxHeight ?? '400px'; const MIN_HEIGHT = props.minHeight ?? '200px'; const SAVE_TEXT = props.saveButtonText ?? 'Save'; const CANCEL_TEXT = props.cancelButtonText ?? 'Cancel'; +const SANITIZE = props.sanitize ?? true; const isEditing = ref(false); const isSaving = ref(false); @@ -34,14 +36,14 @@ watch(() => props.modelValue, async (newValue) => { if (!isEditing.value) { isRendering.value = true; const content = newValue || ''; - renderedMarkdownContent.value = await Markdown.Render(content, true); + renderedMarkdownContent.value = await Markdown.Render(content, SANITIZE); isRendering.value = false; } }, { immediate: true }); watch(editedText, async (newText) => { if (newText) { - renderedMarkdownContent.value = await Markdown.Render(newText, true); + renderedMarkdownContent.value = await Markdown.Render(newText, SANITIZE); } }); From e6f947c899e885eadd7b1446d60a6c51033c319a Mon Sep 17 00:00:00 2001 From: menvae Date: Sat, 13 Dec 2025 01:11:43 +0200 Subject: [PATCH 08/16] add uploading to catbox on image drag + refactor --- src/components/markdown-editor.vue | 187 ++++++++++++++++++++++------- src/utils/API.ts | 33 +++++ 2 files changed, 176 insertions(+), 44 deletions(-) diff --git a/src/components/markdown-editor.vue b/src/components/markdown-editor.vue index 1aa39eb..faf77fe 100644 --- a/src/components/markdown-editor.vue +++ b/src/components/markdown-editor.vue @@ -1,75 +1,72 @@