|
11 | 11 | </template> |
12 | 12 |
|
13 | 13 | <script lang="ts" setup> |
| 14 | +import DOMPurify from "dompurify"; |
14 | 15 | import { computed } from "vue"; |
15 | 16 |
|
16 | 17 | import { parseDiscogsMarkup } from "@/helpers/discogs"; |
17 | 18 | import { useArtist } from "@/views/artist/ArtistStore"; |
18 | 19 |
|
19 | 20 | const artistStore = useArtist(); |
20 | 21 |
|
| 22 | +const DISCOGS_BASE_URL = "https://www.discogs.com"; |
| 23 | +const LINK_ATTRS = 'target="_blank" rel="noopener noreferrer" class="discogs-link"'; |
| 24 | +
|
| 25 | +/** |
| 26 | + * Discogs entity types configuration |
| 27 | + * Maps entity type letter to URL path and display text |
| 28 | + */ |
| 29 | +const DISCOGS_ENTITIES: Record<string, { path: string; searchType: string; text: string }> = { |
| 30 | + a: { path: "artist", searchType: "artist", text: "artist" }, |
| 31 | + l: { path: "label", searchType: "label", text: "label" }, |
| 32 | + m: { path: "master", searchType: "master", text: "release" }, |
| 33 | + r: { path: "release", searchType: "release", text: "release" }, |
| 34 | +}; |
| 35 | +
|
| 36 | +/** |
| 37 | + * Text formatting tags configuration |
| 38 | + */ |
| 39 | +const TEXT_FORMATS: Array<{ pattern: RegExp; replacement: string }> = [ |
| 40 | + { pattern: /\[i\](.*?)\[\/i\]/gi, replacement: "<em>$1</em>" }, |
| 41 | + { pattern: /\[b\](.*?)\[\/b\]/gi, replacement: "<strong>$1</strong>" }, |
| 42 | + { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: "<u>$1</u>" }, |
| 43 | +]; |
| 44 | +
|
| 45 | +/** |
| 46 | + * Create a Discogs link by ID |
| 47 | + */ |
| 48 | +function createDiscogsLinkById(type: string, id: string): string { |
| 49 | + const entity = DISCOGS_ENTITIES[type.toLowerCase()]; |
| 50 | + if (!entity) return `[${type}${id}]`; |
| 51 | + return `<a href="${DISCOGS_BASE_URL}/${entity.path}/${id}" ${LINK_ATTRS}>${entity.text}</a>`; |
| 52 | +} |
| 53 | +
|
| 54 | +/** |
| 55 | + * Create a Discogs search link by name |
| 56 | + */ |
| 57 | +function createDiscogsSearchLink(type: string, name: string): string { |
| 58 | + const entity = DISCOGS_ENTITIES[type.toLowerCase()]; |
| 59 | + if (!entity) return `[${type}=${name}]`; |
| 60 | + // Sanitize the display name to prevent XSS |
| 61 | + const sanitizedName = DOMPurify.sanitize(name, { ALLOWED_TAGS: [] }); |
| 62 | + return `<a href="${DISCOGS_BASE_URL}/search/?q=${encodeURIComponent(name)}&type=${entity.searchType}" ${LINK_ATTRS}>${sanitizedName}</a>`; |
| 63 | +} |
| 64 | +
|
| 65 | +/** |
| 66 | + * Validate and sanitize a URL to prevent XSS attacks |
| 67 | + */ |
| 68 | +function sanitizeUrl(url: string): string { |
| 69 | + // Remove any HTML entities that might have been escaped |
| 70 | + const decodedUrl = url.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); |
| 71 | +
|
| 72 | + // Only allow http, https, and ftp protocols |
| 73 | + try { |
| 74 | + const urlObj = new URL(decodedUrl); |
| 75 | + if (!["http:", "https:", "ftp:"].includes(urlObj.protocol)) { |
| 76 | + return "#"; |
| 77 | + } |
| 78 | + return urlObj.href; |
| 79 | + } catch { |
| 80 | + // If URL is invalid, return a safe placeholder |
| 81 | + return "#"; |
| 82 | + } |
| 83 | +} |
| 84 | +
|
| 85 | +/** |
| 86 | + * Parse Discogs markup and convert to HTML |
| 87 | + * Supported tags: |
| 88 | + * - [i], [b], [u] -> text formatting |
| 89 | + * - [a], [l], [r], [m] -> Discogs entity links (by ID or name) |
| 90 | + * - [url] -> external links |
| 91 | + */ |
| 92 | +function parseDiscogsMarkup(text: string): string { |
| 93 | + let result = text; |
| 94 | +
|
| 95 | + // Escape HTML to prevent XSS |
| 96 | + result = result.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); |
| 97 | +
|
| 98 | + // Apply text formatting |
| 99 | + for (const { pattern, replacement } of TEXT_FORMATS) { |
| 100 | + result = result.replace(pattern, replacement); |
| 101 | + } |
| 102 | +
|
| 103 | + // [x123456] or [x=123456] -> link to Discogs entity by ID |
| 104 | + result = result.replace(/\[([almr])=?(\d+)\]/gi, (_, type, id) => createDiscogsLinkById(type, id)); |
| 105 | +
|
| 106 | + // [x=Name] -> link to Discogs search by name (non-numeric) |
| 107 | + result = result.replace(/\[([al])=([^\]]+)\]/gi, (_, type, name) => createDiscogsSearchLink(type, name)); |
| 108 | +
|
| 109 | + // [url=http://...]text[/url] -> <a href="...">text</a> |
| 110 | + result = result.replace(/\[url=(.*?)\](.*?)\[\/url\]/gi, (_, url, text) => { |
| 111 | + const sanitizedUrl = sanitizeUrl(url); |
| 112 | + // Sanitize the link text to prevent XSS |
| 113 | + const sanitizedText = DOMPurify.sanitize(text, { ALLOWED_TAGS: [] }); |
| 114 | + return `<a href="${sanitizedUrl}" target="_blank" rel="noopener noreferrer">${sanitizedText}</a>`; |
| 115 | + }); |
| 116 | +
|
| 117 | + // [url]http://...[/url] -> <a href="...">...</a> |
| 118 | + result = result.replace(/\[url\](.*?)\[\/url\]/gi, (_, url) => { |
| 119 | + const sanitizedUrl = sanitizeUrl(url); |
| 120 | + // Display the sanitized URL to prevent XSS |
| 121 | + return `<a href="${sanitizedUrl}" target="_blank" rel="noopener noreferrer">${sanitizedUrl}</a>`; |
| 122 | + }); |
| 123 | +
|
| 124 | + return result; |
| 125 | +} |
| 126 | +
|
21 | 127 | /** |
22 | 128 | * Extract the first sentence from text, but limit to max characters |
23 | 129 | */ |
|
0 commit comments