Skip to content

Commit 36c20d0

Browse files
committed
Added URL parser
1 parent 02aac49 commit 36c20d0

File tree

4 files changed

+176
-2
lines changed

4 files changed

+176
-2
lines changed

app/(main)/threads/[id]/thread-client-page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
1515
import { Button } from "@/components/ui/button"
1616
import { Clock, TrendingUp, Reply, ChevronDown, ChevronUp, Trash2 } from "lucide-react"
1717
import { BotLabel } from "@/components/bot-label"
18+
import { ParsedText } from "@/components/parsed-text"
1819

1920
interface ThreadClientPageProps {
2021
article: NewsItem
@@ -99,7 +100,9 @@ const CommentItem = memo<CommentItemProps>(({
99100
<span className="text-gray-500"></span>
100101
<span className="text-gray-500 text-xs">{comment.timeAgo}</span>
101102
</div>
102-
<div className="text-gray-700 text-sm mb-3 whitespace-pre-wrap">{comment.text}</div>
103+
<div className="mb-3">
104+
<ParsedText text={comment.text} />
105+
</div>
103106

104107
<div className="flex items-center gap-3">
105108
<CommentVote

components/comments-section.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { handleVote } from "@/lib/voteHandler"
1616
import { deleteComment } from "@/lib/commentHandler"
1717
import { ArrowUpDown, Clock, TrendingUp, Reply, X, Trash2 } from "lucide-react"
1818
import { BotLabel } from "@/components/bot-label"
19+
import { ParsedText } from "@/components/parsed-text"
1920

2021
interface CommentItemProps {
2122
comment: Comment
@@ -121,7 +122,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
121122
)}
122123

123124
</div>
124-
<p className={`text-gray-700 mt-1 text-sm ${isMobile ? 'mt-0.5 text-xs' : 'mt-1'}`}>{comment.text}</p>
125+
<ParsedText text={comment.text} />
125126

126127
{/* Action Buttons */}
127128
<div className={`flex items-center gap-3 mt-2 ${isMobile ? 'gap-1.5 mt-1' : 'gap-3 mt-2'}`}>

components/parsed-text.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"use client"
2+
3+
import { parseUrlsInText } from "@/lib/urlParser"
4+
5+
interface ParsedTextProps {
6+
text: string
7+
className?: string
8+
}
9+
10+
/**
11+
* React component for rendering parsed text with links
12+
*/
13+
export function ParsedText({ text, className = "text-gray-700 text-sm whitespace-pre-wrap" }: ParsedTextProps) {
14+
const parsedHtml = parseUrlsInText(text)
15+
16+
return (
17+
<div
18+
className={className}
19+
dangerouslySetInnerHTML={{ __html: parsedHtml }}
20+
/>
21+
)
22+
}

lib/urlParser.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* Secure URL parsing utility for comments
3+
* Handles URL detection, validation, and safe link generation with SEO protection
4+
*/
5+
6+
// Allowed URL schemes for security
7+
const ALLOWED_SCHEMES = ['http:', 'https:', 'mailto:']
8+
const ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']
9+
10+
// Maximum URL length to prevent abuse
11+
const MAX_URL_LENGTH = 2048
12+
13+
// Regex patterns for URL detection
14+
const URL_REGEX = /(https?:\/\/[^\s<>"{}|\\^`[\]]+)/gi
15+
const EMAIL_REGEX = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/gi
16+
17+
/**
18+
* Validates if a URL is safe and allowed
19+
*/
20+
function isValidUrl(url: string): boolean {
21+
try {
22+
const parsedUrl = new URL(url)
23+
24+
// Check if scheme is allowed
25+
if (!ALLOWED_SCHEMES.includes(parsedUrl.protocol)) {
26+
return false
27+
}
28+
29+
// Check URL length
30+
if (url.length > MAX_URL_LENGTH) {
31+
return false
32+
}
33+
34+
// Additional security checks
35+
// Prevent javascript: and data: URLs
36+
if (url.toLowerCase().startsWith('javascript:') ||
37+
url.toLowerCase().startsWith('data:') ||
38+
url.toLowerCase().startsWith('vbscript:') ||
39+
url.toLowerCase().startsWith('file:')) {
40+
return false
41+
}
42+
43+
// Check for suspicious patterns
44+
const suspiciousPatterns = [
45+
/javascript:/i,
46+
/data:/i,
47+
/vbscript:/i,
48+
/file:/i,
49+
/<script/i,
50+
/on\w+\s*=/i, // onclick, onload, etc.
51+
]
52+
53+
for (const pattern of suspiciousPatterns) {
54+
if (pattern.test(url)) {
55+
return false
56+
}
57+
}
58+
59+
return true
60+
} catch {
61+
return false
62+
}
63+
}
64+
65+
/**
66+
* Validates if an email address is safe
67+
*/
68+
function isValidEmail(email: string): boolean {
69+
// Basic email validation
70+
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
71+
return emailPattern.test(email) && email.length <= 254
72+
}
73+
74+
/**
75+
* Sanitizes text to prevent XSS attacks
76+
*/
77+
function sanitizeText(text: string): string {
78+
return text
79+
.replace(/&/g, '&amp;')
80+
.replace(/</g, '&lt;')
81+
.replace(/>/g, '&gt;')
82+
.replace(/"/g, '&quot;')
83+
.replace(/'/g, '&#x27;')
84+
.replace(/\//g, '&#x2F;')
85+
}
86+
87+
/**
88+
* Creates a safe link element with SEO protection
89+
*/
90+
function createSafeLink(url: string, text: string): string {
91+
const sanitizedUrl = sanitizeText(url)
92+
const sanitizedText = sanitizeText(text)
93+
94+
return `<a href="${sanitizedUrl}"
95+
target="_blank"
96+
rel="noopener noreferrer nofollow"
97+
class="text-blue-600 hover:text-blue-800 underline break-words"
98+
title="External link">${sanitizedText}</a>`
99+
}
100+
101+
/**
102+
* Creates a safe mailto link
103+
*/
104+
function createSafeMailtoLink(email: string): string {
105+
const sanitizedEmail = sanitizeText(email)
106+
107+
return `<a href="mailto:${sanitizedEmail}"
108+
class="text-blue-600 hover:text-blue-800 underline break-words"
109+
title="Send email">${sanitizedEmail}</a>`
110+
}
111+
112+
/**
113+
* Parses text and converts URLs and emails to safe clickable links
114+
*/
115+
export function parseUrlsInText(text: string): string {
116+
if (!text || typeof text !== 'string') {
117+
return ''
118+
}
119+
120+
let result = text
121+
122+
// First, handle URLs
123+
result = result.replace(URL_REGEX, (match) => {
124+
if (isValidUrl(match)) {
125+
return createSafeLink(match, match)
126+
}
127+
return match // Return original if invalid
128+
})
129+
130+
// Then, handle email addresses
131+
result = result.replace(EMAIL_REGEX, (match) => {
132+
if (isValidEmail(match)) {
133+
return createSafeMailtoLink(match)
134+
}
135+
return match // Return original if invalid
136+
})
137+
138+
return result
139+
}
140+
141+
/**
142+
* Hook for parsing URLs in text (for use in forms, etc.)
143+
*/
144+
export function useUrlParser() {
145+
return {
146+
parseUrls: parseUrlsInText
147+
}
148+
}

0 commit comments

Comments
 (0)