Skip to content

Commit c3bf7e8

Browse files
authored
Merge pull request #183 from BeardedBear/copilot/sub-pr-180-another-one
Add URL validation and DOMPurify sanitization to Discogs markup parser
2 parents 22ff3cc + fcf6ea5 commit c3bf7e8

File tree

3 files changed

+136
-0
lines changed

3 files changed

+136
-0
lines changed

package-lock.json

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@vueuse/motion": "^3.0.3",
1515
"crypto-js": "^4.2.0",
1616
"date-fns": "^4.1.0",
17+
"dompurify": "^3.3.1",
1718
"form-urlencoded": "^6.1.6",
1819
"ky": "^1.13.0",
1920
"pinia": "^3.0.3",
@@ -26,6 +27,7 @@
2627
"devDependencies": {
2728
"@eslint/js": "^9.38.0",
2829
"@rushstack/eslint-patch": "^1.14.0",
30+
"@types/dompurify": "^3.0.5",
2931
"@types/node": "^24.9.1",
3032
"@typescript-eslint/eslint-plugin": "^8.46.2",
3133
"@typescript-eslint/parser": "^8.46.2",

src/components/artist/ArtistProfile.vue

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,119 @@
1111
</template>
1212

1313
<script lang="ts" setup>
14+
import DOMPurify from "dompurify";
1415
import { computed } from "vue";
1516
1617
import { parseDiscogsMarkup } from "@/helpers/discogs";
1718
import { useArtist } from "@/views/artist/ArtistStore";
1819
1920
const artistStore = useArtist();
2021
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(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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+
21127
/**
22128
* Extract the first sentence from text, but limit to max characters
23129
*/

0 commit comments

Comments
 (0)