mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 10:51:13 -05:00
Approve tool use
This commit is contained in:
@@ -12,6 +12,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { format } from 'date-fns';
|
||||
import type { ModerationItem } from '@/types/moderation';
|
||||
import { sanitizeURL, sanitizePlainText } from '@/lib/sanitize';
|
||||
|
||||
interface QueueItemActionsProps {
|
||||
item: ModerationItem;
|
||||
@@ -166,12 +167,12 @@ export const QueueItemActions = memo(({
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-blue-900 dark:text-blue-100">Source: </span>
|
||||
<a
|
||||
href={item.submission_items[0].item_data.source_url}
|
||||
href={sanitizeURL(item.submission_items[0].item_data.source_url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline dark:text-blue-400 inline-flex items-center gap-1"
|
||||
>
|
||||
{item.submission_items[0].item_data.source_url}
|
||||
{sanitizePlainText(item.submission_items[0].item_data.source_url)}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -181,7 +182,7 @@ export const QueueItemActions = memo(({
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-blue-900 dark:text-blue-100">Submitter Notes: </span>
|
||||
<p className="mt-1 whitespace-pre-wrap text-blue-800 dark:text-blue-200">
|
||||
{item.submission_items[0].item_data.submission_notes}
|
||||
{sanitizePlainText(item.submission_items[0].item_data.submission_notes)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -366,12 +367,12 @@ export const QueueItemActions = memo(({
|
||||
<div className="text-sm mb-2">
|
||||
<span className="font-medium">Source: </span>
|
||||
<a
|
||||
href={item.submission_items[0].item_data.source_url}
|
||||
href={sanitizeURL(item.submission_items[0].item_data.source_url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{item.submission_items[0].item_data.source_url}
|
||||
{sanitizePlainText(item.submission_items[0].item_data.source_url)}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -380,7 +381,7 @@ export const QueueItemActions = memo(({
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">Submitter Notes: </span>
|
||||
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">
|
||||
{item.submission_items[0].item_data.submission_notes}
|
||||
{sanitizePlainText(item.submission_items[0].item_data.submission_notes)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1198,6 +1198,53 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
moderation_audit_log: {
|
||||
Row: {
|
||||
action: string
|
||||
created_at: string
|
||||
id: string
|
||||
is_test_data: boolean | null
|
||||
metadata: Json | null
|
||||
moderator_id: string
|
||||
new_status: string | null
|
||||
notes: string | null
|
||||
previous_status: string | null
|
||||
submission_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
action: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
is_test_data?: boolean | null
|
||||
metadata?: Json | null
|
||||
moderator_id: string
|
||||
new_status?: string | null
|
||||
notes?: string | null
|
||||
previous_status?: string | null
|
||||
submission_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
action?: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
is_test_data?: boolean | null
|
||||
metadata?: Json | null
|
||||
moderator_id?: string
|
||||
new_status?: string | null
|
||||
notes?: string | null
|
||||
previous_status?: string | null
|
||||
submission_id?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "moderation_audit_log_submission_id_fkey"
|
||||
columns: ["submission_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "content_submissions"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
notification_channels: {
|
||||
Row: {
|
||||
channel_type: string
|
||||
@@ -4708,6 +4755,17 @@ export type Database = {
|
||||
Returns: undefined
|
||||
}
|
||||
log_cleanup_results: { Args: never; Returns: undefined }
|
||||
log_moderation_action: {
|
||||
Args: {
|
||||
_action: string
|
||||
_metadata?: Json
|
||||
_new_status?: string
|
||||
_notes?: string
|
||||
_previous_status?: string
|
||||
_submission_id: string
|
||||
}
|
||||
Returns: string
|
||||
}
|
||||
log_request_metadata: {
|
||||
Args: {
|
||||
p_client_version?: string
|
||||
@@ -4788,6 +4846,10 @@ export type Database = {
|
||||
Args: { target_ride_id: string }
|
||||
Returns: undefined
|
||||
}
|
||||
validate_moderation_action: {
|
||||
Args: { _action: string; _submission_id: string; _user_id: string }
|
||||
Returns: boolean
|
||||
}
|
||||
}
|
||||
Enums: {
|
||||
account_deletion_status:
|
||||
|
||||
98
src/lib/sanitize.ts
Normal file
98
src/lib/sanitize.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Input Sanitization Utilities
|
||||
*
|
||||
* Provides XSS protection for user-generated content.
|
||||
* All user input should be sanitized before rendering to prevent injection attacks.
|
||||
*/
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Sanitize HTML content to prevent XSS attacks
|
||||
*
|
||||
* @param html - Raw HTML string from user input
|
||||
* @returns Sanitized HTML safe for rendering
|
||||
*/
|
||||
export function sanitizeHTML(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize URL to prevent javascript: and data: protocol injection
|
||||
*
|
||||
* @param url - URL from user input
|
||||
* @returns Sanitized URL or '#' if invalid
|
||||
*/
|
||||
export function sanitizeURL(url: string): string {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return '#';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Only allow http, https, and mailto protocols
|
||||
const allowedProtocols = ['http:', 'https:', 'mailto:'];
|
||||
|
||||
if (!allowedProtocols.includes(parsed.protocol)) {
|
||||
console.warn(`Blocked potentially dangerous URL protocol: ${parsed.protocol}`);
|
||||
return '#';
|
||||
}
|
||||
|
||||
return url;
|
||||
} catch {
|
||||
// Invalid URL format
|
||||
console.warn(`Invalid URL format: ${url}`);
|
||||
return '#';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize plain text to prevent any HTML rendering
|
||||
* Escapes all HTML entities
|
||||
*
|
||||
* @param text - Plain text from user input
|
||||
* @returns Escaped text safe for rendering
|
||||
*/
|
||||
export function sanitizePlainText(text: string): string {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\//g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string contains potentially dangerous content
|
||||
* Used for validation before sanitization
|
||||
*
|
||||
* @param input - User input to check
|
||||
* @returns true if input contains suspicious patterns
|
||||
*/
|
||||
export function containsSuspiciousContent(input: string): boolean {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const suspiciousPatterns = [
|
||||
/<script/i,
|
||||
/javascript:/i,
|
||||
/on\w+\s*=/i, // Event handlers like onclick=
|
||||
/<iframe/i,
|
||||
/<object/i,
|
||||
/<embed/i,
|
||||
/data:text\/html/i,
|
||||
];
|
||||
|
||||
return suspiciousPatterns.some(pattern => pattern.test(input));
|
||||
}
|
||||
Reference in New Issue
Block a user