diff --git a/package.json b/package.json index 6093281..49bf002 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "chroma.ts": "^1.0.10", + "dompurify": "^3.3.0", "marked": "^16.4.2", "nuxt": "^3.21.6", "vue": "^3.5.34", @@ -21,6 +22,7 @@ "@nuxt/image": "1.10.0", "@nuxtjs/mdc": "^0.17.4", "@nuxtjs/tailwindcss": "^6.14.0", + "@types/dompurify": "^3.0.5", "prettier": "3.6.2", "prettier-plugin-tailwindcss": "0.6.13", "sass-embedded": "^1.99.0" diff --git a/src/app.vue b/src/app.vue index 14ac760..550a9fb 100644 --- a/src/app.vue +++ b/src/app.vue @@ -18,7 +18,7 @@ else API.Logout(); -
+
\ No newline at end of file diff --git a/src/components/markdown/viewer.vue b/src/components/markdown/viewer.vue new file mode 100644 index 0000000..ea517c6 --- /dev/null +++ b/src/components/markdown/viewer.vue @@ -0,0 +1,231 @@ + + + + + \ No newline at end of file diff --git a/src/models/maps/APIMapSet.ts b/src/models/maps/APIMapSet.ts index 1a44e6f..f8f4a4d 100644 --- a/src/models/maps/APIMapSet.ts +++ b/src/models/maps/APIMapSet.ts @@ -11,7 +11,6 @@ export type APIMapSet = { artist: string; source: string; tags: string[]; - description: string; flags: number; status: APIMapSetStatus; submitted: number; 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/pages/set/[id]/info.vue b/src/pages/set/[id]/info.vue index 90fe057..bc8c2b1 100644 --- a/src/pages/set/[id]/info.vue +++ b/src/pages/set/[id]/info.vue @@ -6,7 +6,7 @@ definePageMeta({ alias: '/set/:id' }); -defineProps<{ +const props = defineProps<{ mapset: APIMapSet; map: APIMap; }>(); @@ -15,8 +15,7 @@ defineProps<{ + \ No newline at end of file diff --git a/src/utils/api.ts b/src/utils/api.ts index 11faa1a..2eb0a4c 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -15,6 +15,8 @@ export default class API { static TokenCookie: CookieRef; static CurrentUser: CookieRef; + static DescriptionMaxCharLimit = 4000; + static Setup(dev: boolean = false) { this.APIUrl = dev ? 'http://localhost:2434' : 'https://fluxis.flux.moe/api'; this.AssetsUrl = dev ? 'http://localhost:2434/assets' : 'https://assets.flux.moe'; @@ -97,6 +99,39 @@ export default class API { this.CurrentUser.value = user; } + + static async UploadToCatbox(file: File): Promise> { + try { + const formData = new FormData(); + formData.append('reqtype', 'fileupload'); + formData.append('fileToUpload', file); + + const { data, error, status } = await useFetch('https://corsproxy.io/?' + encodeURIComponent('https://catbox.moe/user/api.php'), { + method: 'POST', + body: formData, + }); + + if (error.value || !data.value) { + return { + data: null, + error: { + _request: error.value?.message || 'Upload failed', + _status: status.value + } + }; + } + + const url = typeof data.value === 'string' ? data.value : String(data.value); + return { data: url.trim(), error: null }; + } catch (ex: any) { + return { + data: null, + error: { + _request: ex?.message || 'Unknown error during catbox upload' + } + }; + } + } } async function tryPerform(endpoint: string, method: string, body: any = {}): Promise> { diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index f09d14b..fa4a00c 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -1,16 +1,89 @@ -import { marked, type RendererObject } from 'marked'; +import { marked, type RendererObject, type Tokens } 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 class MarkdownTagFilter { + mode: 'blacklist' | 'whitelist'; + tags: string[]; + + constructor(mode: 'blacklist' | 'whitelist', tags: string[]) { + this.mode = mode; + this.tags = tags; + } + + isAllowed(tag: string): boolean { + return this.mode === 'blacklist' + ? !this.tags.includes(tag) + : this.tags.includes(tag); + } + + isBlocked(tag: string): boolean { + return !this.isAllowed(tag); + } +} export default class Markdown { static FootnoteRegex = /\[\^(\d{1,2})\]/g; static BlockquoteRegex = /\{: \.(\w+) \}/g; + static ImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + static GithubAlertRegex = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\n?/i; + + static AllowedImageDomains: string[] = [ + 'flux.moe', + 'catbox.moe', + 'imgur.com', + "singlecolorimage.com" + ]; + + static CustomTags: Record string }> = { + center: { render: (inner) => `
${inner}
` }, + spoiler: { render: (inner, attr) => `
${attr || 'Spoiler'}${inner}
` }, + color: { inline: true, render: (inner, attr) => `${inner}` }, + }; + + static GithubAlertIcons: Record = { + note: 'fa-circle-info', + tip: 'fa-lightbulb', + important: 'fa-flag', + warning: 'fa-triangle-exclamation', + caution: 'fa-ban', + }; + + 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 = []; + 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 @@ -31,53 +104,150 @@ 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 (this.isImageAllowed(src)) { + if (!data.images) { + data.images = []; + } + + data.images.push(new ParsedImage( + src, + alt, + index + 1 + )); + } + }); }); return data; } - static Render(md: string): string { + static async Render(md: string, sanitize: boolean = true, filter: MarkdownTagFilter = new MarkdownTagFilter('blacklist', [])): Promise { md = md.replaceAll('<', '<'); + const extracted_customtags: Array<{ placeholder: string; tag: string | null; inner: string; attr?: string }> = []; + + // extract custom tags + for (const [tag, def] of Object.entries(Markdown.CustomTags)) { + // bbcode style + const regex = new RegExp(`\\[${tag}(?:=([^\\]]+))?\\]([\\s\\S]*?)\\[\\/${tag}\\]`, 'gi'); + + if (def.inline) { + md = md.replace(regex, (_, attr, inner) => { + if (filter.isBlocked(tag)) return inner; + return def.render(inner, attr); + }); + } else { + md = md.replace(regex, (_, attr, inner) => { + if (filter.isBlocked(tag)) { + const placeholder = `CUSTOM_TAG_${tag.toUpperCase()}_${extracted_customtags.length}`; + extracted_customtags.push({ placeholder, tag: null, inner, attr }); + return placeholder; + } + const placeholder = `CUSTOM_TAG_${tag.toUpperCase()}_${extracted_customtags.length}`; + extracted_customtags.push({ placeholder, tag, inner, attr }); + return placeholder; + }); + } + } + + // we need to recurse nested lists + const renderList = (list: Tokens.List): string => { + const tag = list.ordered ? 'ol' : 'ul'; + + const items = list.items.map(item => { + let content = ''; + + item.tokens?.forEach(token => { + if (token.type === 'list') { + content += renderList(token as Tokens.List); + } else if (token.type === 'text') { + content += (token as Tokens.Text).text; + } + }); + + return `
  • ${content}
  • `; + }).join('\n'); + + return `<${tag}>${items}`; + }; + const config: RendererObject = { - /* heading: (head) => { - if (head.depth == 2 || head.depth == 3) - return ``; + heading: (head) => { + if (filter.isBlocked(`h${head.depth}`)) return head.text; - return false; - }, */ + return `${head.text}`; + }, + list: (list) => { + if (filter.isBlocked(list.ordered ? 'ol' : 'ul')) return list.raw; + return renderList(list); + }, link: (link) => { + if (filter.isBlocked('a')) return link.raw; + if (link.href.startsWith('/')) { return `${link.text}`; } return false; }, - /* code: (code) => { - const text = code.text; - const lines = text.split('\n'); - return `${lines.join('')}`; - }, */ - /* blockquote: (block) => { + code: (code) => { + if (filter.isBlocked('code')) return code.text; + + const lines = code.text.split('\n'); + return `
    ${lines.join('\n')}
    `; + }, + blockquote: (block) => { + if (filter.isBlocked('blockquote')) return block.text; + let content = block.text; let type = 'tip'; - const matches = [...content.matchAll(Markdown.BlockquoteRegex)]; - - if (matches.length > 0) { - const match = matches[0]; - type = match[1]; - content = content.replace(match[0], '').trim(); + // github style for codeblocks: [!NOTE], [!WARNING], etc + const githubAlertMatch = content.match(Markdown.GithubAlertRegex); + if (githubAlertMatch) { + type = githubAlertMatch[1].toLowerCase(); + content = content.replace(Markdown.GithubAlertRegex, '').trim(); + } else { + // normal {: .class } + const matches = [...content.matchAll(Markdown.BlockquoteRegex)]; + if (matches.length > 0) { + const match = matches[0]; + type = match[1]; + content = content.replace(match[0], '').trim(); + } } - return `${content}`; - }, */ - /* image: (image) => `` */ + const label = githubAlertMatch + ? ` ${type.toUpperCase()}` + : ''; + + return `
    ${label}${content}
    `; + }, + image: (image) => { + if (filter.isBlocked('img') || !this.isImageAllowed(image.href)) return ''; + + const escapedAlt = image.text.replace(/\"/g, '"'); + const escapedHref = image.href.replace(/\"/g, '"'); + return `${escapedAlt}`; + } }; marked.use({ renderer: config }); let html = marked.parse(md).toString(); + // replace the placeholders with html + for (const { placeholder, tag, inner, attr } of extracted_customtags) { + const innerHtml = await Markdown.Render(inner, false, filter); + const final = tag ? Markdown.CustomTags[tag].render(innerHtml, attr) : innerHtml; + html = html.replace(new RegExp(`

    \\s*${placeholder}\\s*<\\/p>|${placeholder}`), final); + } + // footnote stuff const matches = [...html.matchAll(Markdown.FootnoteRegex)]; @@ -136,10 +306,15 @@ export default class Markdown { const raw = m[0]; const num = m[1]; - html = html.replace(raw, ``); + html = html.replace(raw, `[${num}]`); }); } - return html; + let finalHtml = sanitize ? Sanitizer.Sanitize(html) : html; + + // important because style attr is blocked inside the sanitization + finalHtml = html.replace(/data-color="([^"]+)"/g, 'style="color:$1"'); + + return finalHtml; } -} +} \ No newline at end of file diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts new file mode 100644 index 0000000..4b676fd --- /dev/null +++ b/src/utils/sanitize.ts @@ -0,0 +1,86 @@ +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', + 'details', 'summary', + '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?):|[^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 diff --git a/tailwind.config.js b/tailwind.config.js index c3e68bc..bc87f89 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -4,6 +4,7 @@ module.exports = { theme: { colors: { white: '#FFF', + gray: '#AAA', black: '#000', transparent: 'transparent',