diff --git a/backend/Dockerfile b/backend/Dockerfile index c929818..1df4ada 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -18,6 +18,10 @@ RUN mkdir -p /app/uploads/documents # Make startup script executable RUN chmod +x /app/scripts/start.sh +# Run as non-root user (node user already exists in node:20-alpine) +RUN chown -R node:node /app +USER node + # Expose port EXPOSE 4000 diff --git a/backend/package-lock.json b/backend/package-lock.json index 7453bd2..87a420b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,7 +19,8 @@ "nodemailer": "^6.9.7", "openai": "^6.15.0", "pdfkit": "^0.15.0", - "resend": "^3.5.0" + "resend": "^3.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -9941,6 +9942,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 883ea4b..41298be 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,7 +23,8 @@ "nodemailer": "^6.9.7", "openai": "^6.15.0", "pdfkit": "^0.15.0", - "resend": "^3.5.0" + "resend": "^3.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/cors": "^2.8.17", diff --git a/backend/src/middleware/validate.ts b/backend/src/middleware/validate.ts new file mode 100644 index 0000000..70ef6e0 --- /dev/null +++ b/backend/src/middleware/validate.ts @@ -0,0 +1,22 @@ +import { Request, Response, NextFunction } from 'express'; +import { ZodSchema, ZodError } from 'zod'; + +/** + * Express middleware that validates req.body against a Zod schema. + * On success, replaces req.body with the parsed (and stripped) data. + * On failure, returns 400 with structured validation errors. + */ +export function validate(schema: ZodSchema) { + return (req: Request, res: Response, next: NextFunction) => { + const result = schema.safeParse(req.body); + if (!result.success) { + const formatted = (result.error as ZodError).flatten(); + return res.status(400).json({ + error: 'Validation failed', + details: formatted.fieldErrors, + }); + } + req.body = result.data; + next(); + }; +} diff --git a/backend/src/routes/access.ts b/backend/src/routes/access.ts index 410a224..9115fa8 100644 --- a/backend/src/routes/access.ts +++ b/backend/src/routes/access.ts @@ -1,10 +1,8 @@ import express from 'express'; import { supabase } from '../server'; import fs from 'fs'; -import path from 'path'; import { logActivity } from '../utils/activityLogger'; - -const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads'; +import { resolveUploadPath } from '../utils/safePath'; const router = express.Router(); @@ -65,7 +63,8 @@ router.get('/:token', async (req, res) => { }); } catch (error: any) { console.error('Access route error:', error.message); - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -112,8 +111,7 @@ router.get('/:token/download/:document_id', async (req, res) => { // agreed to NDA terms when submitting the document request form // Serve from local storage (consistent with documents.ts) - // Adjust path if file_url is relative - const filePath = path.join(UPLOADS_DIR, document.file_url); + const filePath = resolveUploadPath(document.file_url); if (!fs.existsSync(filePath)) { // In demo mode, generate a placeholder file since Railway's filesystem is ephemeral @@ -161,7 +159,8 @@ Thank you for testing the Trust Center! return res.sendFile(filePath); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -202,7 +201,8 @@ router.post('/:token/accept-nda', async (req, res) => { res.json({ message: 'NDA Accepted successfully' }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/admin/requests.ts b/backend/src/routes/admin/requests.ts index 1ec434f..f260e5f 100644 --- a/backend/src/routes/admin/requests.ts +++ b/backend/src/routes/admin/requests.ts @@ -34,22 +34,23 @@ router.get('/', requireAdmin, async (req, res) => { throw error; } - // Fetch document details for each request separately - const requestsWithDocs = await Promise.all( - (data || []).map(async (request) => { - let documents: { id: string; title: string }[] = []; - - if (request.document_ids && request.document_ids.length > 0) { - const { data: docs } = await supabase - .from('documents') - .select('id, title') - .in('id', request.document_ids); - documents = docs || []; - } + // Batch-fetch all referenced documents in a single query + const allDocIds = [...new Set((data || []).flatMap(r => r.document_ids || []))]; + let docMap = new Map(); + if (allDocIds.length > 0) { + const { data: allDocs } = await supabase + .from('documents') + .select('id, title') + .in('id', allDocIds); + docMap = new Map((allDocs || []).map(d => [d.id, d])); + } - return { ...request, documents }; - }) - ); + const requestsWithDocs = (data || []).map(request => ({ + ...request, + documents: (request.document_ids || []) + .map((id: string) => docMap.get(id)) + .filter(Boolean), + })); res.json(requestsWithDocs); } catch (error: any) { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 07e3721..88adc24 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -4,11 +4,13 @@ import { supabase } from '../server'; import { requireAdmin, AuthRequest } from '../middleware/auth'; import { logActivity } from '../utils/activityLogger'; import { emailRateLimit } from '../middleware/rateLimit'; +import { validate } from '../middleware/validate'; +import { loginSchema, signupSchema } from '../schemas/auth'; const router = express.Router(); // Admin signup (for initial setup) -router.post('/signup', emailRateLimit, async (req, res) => { +router.post('/signup', emailRateLimit, validate(signupSchema), async (req, res) => { try { const { email, password, full_name } = req.body; const allowAdminSignup = process.env.ALLOW_ADMIN_SIGNUP === 'true'; @@ -81,12 +83,13 @@ router.post('/signup', emailRateLimit, async (req, res) => { message: 'Admin user created successfully', }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); // Admin login -router.post('/login', emailRateLimit, async (req, res) => { +router.post('/login', emailRateLimit, validate(loginSchema), async (req, res) => { try { const { email, password } = req.body; @@ -173,7 +176,8 @@ router.post('/login', emailRateLimit, async (req, res) => { session: data.session, }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -194,7 +198,8 @@ router.post('/logout', requireAdmin, async (req: AuthRequest, res) => { await supabase.auth.signOut(); res.json({ message: 'Logged out successfully' }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -215,7 +220,8 @@ router.post('/logout/auto', requireAdmin, async (req: AuthRequest, res) => { await supabase.auth.signOut(); res.json({ message: 'Auto-logged out due to inactivity' }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -224,7 +230,8 @@ router.get('/session', requireAdmin, async (req: AuthRequest, res) => { try { res.json({ authenticated: true, admin: req.admin }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/categories.ts b/backend/src/routes/categories.ts index 39594fd..e53d030 100644 --- a/backend/src/routes/categories.ts +++ b/backend/src/routes/categories.ts @@ -47,7 +47,8 @@ router.get('/', async (req, res) => { res.json(categoriesWithCount); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -78,7 +79,8 @@ router.post('/', requireAdmin, async (req: AuthRequest, res) => { res.status(201).json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -120,7 +122,8 @@ router.patch('/:id', requireAdmin, async (req: AuthRequest, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -157,7 +160,8 @@ router.delete('/:id', requireAdmin, async (req: AuthRequest, res) => { res.json({ message: 'Category deleted successfully' }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/certifications.ts b/backend/src/routes/certifications.ts index 8c9bd67..5b78571 100644 --- a/backend/src/routes/certifications.ts +++ b/backend/src/routes/certifications.ts @@ -25,7 +25,8 @@ router.get('/', async (req, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -56,7 +57,8 @@ router.post('/', requireAdmin, async (req: AuthRequest, res) => { res.status(201).json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -79,7 +81,8 @@ router.patch('/reorder', requireAdmin, async (req: AuthRequest, res) => { res.json({ message: 'Reordered successfully' }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -121,7 +124,8 @@ router.patch('/:id', requireAdmin, async (req: AuthRequest, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -158,7 +162,8 @@ router.delete('/:id', requireAdmin, async (req: AuthRequest, res) => { res.json({ message: 'Certification deleted successfully' }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/contact.ts b/backend/src/routes/contact.ts index d0f925d..146a345 100644 --- a/backend/src/routes/contact.ts +++ b/backend/src/routes/contact.ts @@ -2,6 +2,7 @@ import express from 'express'; import { supabase } from '../server'; import { emailRateLimit } from '../middleware/rateLimit'; import { validateEmailAddress } from '../utils/emailValidation'; +import { notifyAdminsOfNewTicket } from '../utils/adminNotifications'; const router = express.Router(); @@ -55,9 +56,18 @@ router.post('/', emailRateLimit, async (req, res) => { if (error) throw error; + // Notify admins of new support ticket (if tickets enabled) + notifyAdminsOfNewTicket({ + name: trimmedName, + email: trimmedEmail, + organization: trimmedOrganization, + subject: trimmedSubject, + }).catch(err => console.error('Failed to send ticket notification:', err)); + res.status(201).json({ success: true, message: 'Contact form submitted successfully' }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/controls.ts b/backend/src/routes/controls.ts index 8d8d5ca..acea86a 100644 --- a/backend/src/routes/controls.ts +++ b/backend/src/routes/controls.ts @@ -15,7 +15,8 @@ router.get('/control-categories', async (req, res) => { res.json(data || []); } catch (error: any) { console.error('Get control categories error:', error); - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -31,7 +32,8 @@ router.get('/controls', async (req, res) => { res.json(data || []); } catch (error: any) { console.error('Get controls error:', error); - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/documentRequests.ts b/backend/src/routes/documentRequests.ts index e371fd0..39bd619 100644 --- a/backend/src/routes/documentRequests.ts +++ b/backend/src/routes/documentRequests.ts @@ -9,6 +9,7 @@ import { AppError } from '../middleware/errorHandler'; import { logActivity } from '../utils/activityLogger'; import { emailRateLimit } from '../middleware/rateLimit'; import { validateEmailAddress } from '../utils/emailValidation'; +import { notifyAdminsOfNewRequest } from '../utils/adminNotifications'; const router = express.Router(); @@ -209,6 +210,14 @@ router.post('/', emailRateLimit, async (req, res) => { // Dispatch webhook for pending request dispatchWebhook('request.created', pendingRequest); + + // Notify admins of new pending request + notifyAdminsOfNewRequest({ + requester_name: trimmedName, + requester_email: trimmedEmail, + requester_company: trimmedCompany, + document_count: pendingDocs.length, + }).catch(err => console.error('Failed to send admin notification:', err)); } res.json({ @@ -219,7 +228,8 @@ router.post('/', emailRateLimit, async (req, res) => { : 'Your request has been submitted and is under review.', }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -236,7 +246,8 @@ router.get('/history/:email', requireAdmin, async (req, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index aeb3f2c..af3febe 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -6,6 +6,9 @@ import { logActivity } from '../utils/activityLogger'; import fs from 'fs'; import path from 'path'; import { enforceDocumentUploadPolicy } from '../middleware/documentUploadSecurity'; +import { resolveUploadPath } from '../utils/safePath'; +import { validate } from '../middleware/validate'; +import { updateDocumentSchema, submitReviewSchema } from '../schemas/document'; // Local file storage configuration const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads'; @@ -21,10 +24,47 @@ function getSafeDownloadFileName(value: string): string { return sanitizeUploadFileName(value).replace(/"/g, ''); } +async function canIncludeAllDocumentStatuses(req: express.Request): Promise { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return false; + } + + const token = authHeader.slice('Bearer '.length).trim(); + if (!token) { + return false; + } + + const { data: { user }, error: authError } = await supabase.auth.getUser(token); + if (authError || !user) { + return false; + } + + const { data: adminUser, error: adminError } = await supabase + .from('admin_users') + .select('id') + .eq('id', user.id) + .maybeSingle(); + + if (adminError || !adminUser) { + return false; + } + + return true; +} + // Get all documents (public endpoint returns only published, admin can request all) router.get('/', async (req, res) => { try { const { category_id, access_level, include_all_status } = req.query; + const includeAllStatus = include_all_status === 'true'; + + if (includeAllStatus) { + const isAdmin = await canIncludeAllDocumentStatuses(req); + if (!isAdmin) { + return res.status(403).json({ error: 'Forbidden: Admin access required' }); + } + } let query = supabase .from('documents') @@ -34,7 +74,7 @@ router.get('/', async (req, res) => { // For admin panel: include_all_status=true returns all documents // For public: only return published documents - if (include_all_status !== 'true') { + if (!includeAllStatus) { query = query.eq('status', 'published'); } @@ -52,7 +92,8 @@ router.get('/', async (req, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -77,7 +118,8 @@ router.get('/admin/expiring', requireAdmin, async (req, res) => { if (error) throw error; res.json(data || []); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -85,6 +127,14 @@ router.get('/admin/expiring', requireAdmin, async (req, res) => { router.get('/:id', async (req, res) => { try { const { include_all_status } = req.query; + const includeAllStatus = include_all_status === 'true'; + + if (includeAllStatus) { + const isAdmin = await canIncludeAllDocumentStatuses(req); + if (!isAdmin) { + return res.status(403).json({ error: 'Forbidden: Admin access required' }); + } + } let query = supabase .from('documents') @@ -93,7 +143,7 @@ router.get('/:id', async (req, res) => { // For admin panel: include_all_status=true returns any document // For public: only return published documents - if (include_all_status !== 'true') { + if (!includeAllStatus) { query = query.eq('status', 'published'); } @@ -106,7 +156,8 @@ router.get('/:id', async (req, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -127,7 +178,7 @@ router.get('/:id/download', async (req, res) => { // Public documents can be downloaded directly if (document.access_level === 'public') { // Serve from local storage - const filePath = path.join(UPLOADS_DIR, document.file_url); + const filePath = resolveUploadPath(document.file_url); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 'File not found on server' }); @@ -142,7 +193,8 @@ router.get('/:id/download', async (req, res) => { // Restricted documents require magic link validation (handled in access route) return res.status(403).json({ error: 'Access denied. Please use your magic link.' }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -154,7 +206,7 @@ router.post('/', requireAdmin, enforceDocumentUploadPolicy, upload.single('file' } validateUploadedFileSignature(req.file); - const { title, description, category_id, access_level, version, replaces_document_id, requires_nda } = req.body; + const { title, description, category_id, access_level, version, replaces_document_id, requires_nda, expires_at } = req.body; if (!title || !access_level) { return res.status(400).json({ error: 'Title and access_level are required' }); @@ -166,7 +218,7 @@ router.post('/', requireAdmin, enforceDocumentUploadPolicy, upload.single('file' const safeOriginalName = sanitizeUploadFileName(req.file.originalname); const folderName = category_id ? `category-${category_id.substring(0, 8)}` : 'uncategorized'; const filePath = `${folderName}/${fileName}`; - const fullPath = path.join(UPLOADS_DIR, filePath); + const fullPath = resolveUploadPath(filePath); console.log('Saving file to:', fullPath); console.log('File size:', req.file.size, 'bytes'); @@ -214,6 +266,7 @@ router.post('/', requireAdmin, enforceDocumentUploadPolicy, upload.single('file' published_at: new Date().toISOString(), uploaded_by: req.admin!.id, requires_nda: requires_nda === 'true' || requires_nda === true, + ...(expires_at ? { expires_at: new Date(expires_at).toISOString() } : {}), }) .select() .single(); @@ -236,12 +289,13 @@ router.post('/', requireAdmin, enforceDocumentUploadPolicy, upload.single('file' res.status(201).json(document); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); // Update document (admin only) -router.patch('/:id', requireAdmin, async (req: AuthRequest, res) => { +router.patch('/:id', requireAdmin, validate(updateDocumentSchema), async (req: AuthRequest, res) => { try { // Get old document data for logging const { data: oldDoc } = await supabase @@ -279,7 +333,8 @@ router.patch('/:id', requireAdmin, async (req: AuthRequest, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -292,7 +347,7 @@ router.put('/:id/replace', requireAdmin, enforceDocumentUploadPolicy, upload.sin validateUploadedFileSignature(req.file); const { id } = req.params; - const { title, description, category_id, access_level, status, requires_nda } = req.body; + const { title, description, category_id, access_level, status, requires_nda, expires_at } = req.body; // Get the existing document const { data: existingDoc, error: fetchError } = await supabase @@ -307,7 +362,7 @@ router.put('/:id/replace', requireAdmin, enforceDocumentUploadPolicy, upload.sin // Delete old file from storage if (existingDoc.file_url) { - const oldFilePath = path.join(UPLOADS_DIR, existingDoc.file_url); + const oldFilePath = resolveUploadPath(existingDoc.file_url); if (fs.existsSync(oldFilePath)) { fs.unlinkSync(oldFilePath); console.log('Deleted old file:', oldFilePath); @@ -322,7 +377,7 @@ router.put('/:id/replace', requireAdmin, enforceDocumentUploadPolicy, upload.sin ? `category-${(category_id || existingDoc.category_id).substring(0, 8)}` : 'uncategorized'; const filePath = `${folderName}/${fileName}`; - const fullPath = path.join(UPLOADS_DIR, filePath); + const fullPath = resolveUploadPath(filePath); console.log('Saving replacement file to:', fullPath); @@ -346,6 +401,7 @@ router.put('/:id/replace', requireAdmin, enforceDocumentUploadPolicy, upload.sin access_level: access_level || existingDoc.access_level, status: status || existingDoc.status, requires_nda: requires_nda === 'true' || requires_nda === true, + ...(expires_at ? { expires_at: new Date(expires_at).toISOString() } : {}), file_url: filePath, file_name: safeOriginalName, file_size: req.file.size, @@ -376,7 +432,8 @@ router.put('/:id/replace', requireAdmin, enforceDocumentUploadPolicy, upload.sin res.json(updatedDoc); } catch (error: any) { console.error('File replacement error:', error); - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -391,7 +448,7 @@ router.delete('/:id', requireAdmin, async (req: AuthRequest, res) => { if (document && document.file_url) { // Delete file from local storage - const filePath = path.join(UPLOADS_DIR, document.file_url); + const filePath = resolveUploadPath(document.file_url); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); console.log('Deleted file:', filePath); @@ -421,7 +478,8 @@ router.delete('/:id', requireAdmin, async (req: AuthRequest, res) => { res.json({ message: 'Document archived successfully' }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -438,7 +496,8 @@ router.get('/:id/versions', requireAdmin, async (req, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -470,7 +529,7 @@ router.post('/:id/versions', requireAdmin, enforceDocumentUploadPolicy, upload.s const safeOriginalName = sanitizeUploadFileName(req.file.originalname); const folderName = currentDoc.category_id ? `category-${currentDoc.category_id.substring(0, 8)}` : 'uncategorized'; const filePath = `${folderName}/${fileName}`; - const fullPath = path.join(UPLOADS_DIR, filePath); + const fullPath = resolveUploadPath(filePath); const dirPath = path.dirname(fullPath); if (!fs.existsSync(dirPath)) { @@ -514,7 +573,8 @@ router.post('/:id/versions', requireAdmin, enforceDocumentUploadPolicy, upload.s res.status(201).json(updatedDoc); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -532,20 +592,17 @@ router.get('/:id/reviews', requireAdmin, async (req, res) => { if (error) throw error; res.json(data || []); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); // Submit a review -router.post('/:id/reviews', requireAdmin, async (req: AuthRequest, res) => { +router.post('/:id/reviews', requireAdmin, validate(submitReviewSchema), async (req: AuthRequest, res) => { try { const documentId = req.params.id; const { status, notes, next_review_date } = req.body; - if (!status || !['approved', 'needs_changes', 'pending'].includes(status)) { - return res.status(400).json({ error: 'Valid status (approved/needs_changes/pending) is required' }); - } - const { data: review, error } = await supabase .from('document_reviews') .insert({ @@ -585,7 +642,8 @@ router.post('/:id/reviews', requireAdmin, async (req: AuthRequest, res) => { res.status(201).json(review); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/frameworks.ts b/backend/src/routes/frameworks.ts index 41f6f92..8f0a0dd 100644 --- a/backend/src/routes/frameworks.ts +++ b/backend/src/routes/frameworks.ts @@ -15,7 +15,8 @@ router.get('/frameworks', async (req, res) => { res.json(data || []); } catch (error: any) { console.error('Get frameworks error:', error); - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/organizations.ts b/backend/src/routes/organizations.ts index ffedbe4..37830b0 100644 --- a/backend/src/routes/organizations.ts +++ b/backend/src/routes/organizations.ts @@ -26,7 +26,8 @@ router.get('/', requireAdmin, async (req, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -43,7 +44,8 @@ router.get('/:id', requireAdmin, async (req, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -81,7 +83,8 @@ router.patch('/:id', requireAdmin, async (req, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -117,7 +120,8 @@ router.patch('/:id/status', requireAdmin, async (req, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -144,7 +148,8 @@ router.delete('/:id', requireAdmin, async (req, res) => { organization: data }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/salesforce.ts b/backend/src/routes/salesforce.ts index 7d0fa14..f0634f5 100644 --- a/backend/src/routes/salesforce.ts +++ b/backend/src/routes/salesforce.ts @@ -44,7 +44,8 @@ router.get('/status', requireAdmin, async (req: AuthRequest, res) => { : null, }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -55,7 +56,8 @@ router.get('/connect-url', requireAdmin, async (req: AuthRequest, res) => { const authorizeUrl = await getSalesforceAuthorizeUrl(state, pkce); res.json({ authorizeUrl, state }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -64,7 +66,8 @@ router.get('/config', requireAdmin, async (req: AuthRequest, res) => { const config = await getSalesforceAdminConfig(); res.json(config); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -148,7 +151,8 @@ router.post('/sync', requireAdmin, async (req: AuthRequest, res) => { const result = await syncOrganizationsFromSalesforce(); res.json({ success: true, ...result }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -157,7 +161,8 @@ router.post('/disconnect', requireAdmin, async (req: AuthRequest, res) => { await disconnectSalesforceConnection(); res.json({ success: true }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/securityUpdates.ts b/backend/src/routes/securityUpdates.ts index 8231b58..4904ef4 100644 --- a/backend/src/routes/securityUpdates.ts +++ b/backend/src/routes/securityUpdates.ts @@ -32,7 +32,8 @@ router.get('/', async (req, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -66,7 +67,8 @@ router.post('/', requireAdmin, async (req: AuthRequest, res) => { res.status(201).json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -106,7 +108,8 @@ router.patch('/:id', requireAdmin, async (req: AuthRequest, res) => { res.json(data); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); @@ -143,7 +146,8 @@ router.delete('/:id', requireAdmin, async (req: AuthRequest, res) => { res.json({ message: 'Security update deleted successfully' }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 7b3d7ca..8f5a4c9 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -30,7 +30,8 @@ router.get('/', async (req, res) => { nda_url: data?.nda_url || DEFAULT_NDA_URL, }); } catch (error: any) { - res.status(500).json({ error: error.message }); + console.error('Route error:', error); + res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/schemas/auth.ts b/backend/src/schemas/auth.ts new file mode 100644 index 0000000..19c693f --- /dev/null +++ b/backend/src/schemas/auth.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const loginSchema = z.object({ + email: z.string().email().max(254), + password: z.string().min(1).max(256), +}); + +export const signupSchema = z.object({ + email: z.string().email().max(254), + password: z.string().min(8).max(256), + full_name: z.string().max(200).optional(), + signup_token: z.string().max(256).optional(), +}); diff --git a/backend/src/schemas/document.ts b/backend/src/schemas/document.ts new file mode 100644 index 0000000..e7e0fed --- /dev/null +++ b/backend/src/schemas/document.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const updateDocumentSchema = z.object({ + title: z.string().min(1).max(300).optional(), + description: z.string().max(5000).optional().nullable(), + category_id: z.string().uuid().optional().nullable(), + access_level: z.enum(['public', 'restricted']).optional(), + status: z.enum(['draft', 'published', 'archived']).optional(), + requires_nda: z.boolean().optional(), + expires_at: z.string().datetime().optional().nullable(), + review_cycle_days: z.number().int().min(1).max(365).optional().nullable(), +}).strict(); + +export const submitReviewSchema = z.object({ + status: z.enum(['approved', 'needs_changes', 'pending']), + notes: z.string().max(5000).optional().nullable(), + next_review_date: z.string().optional().nullable(), +}); diff --git a/backend/src/server.ts b/backend/src/server.ts index af3967b..f71d0a3 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -6,8 +6,19 @@ import { requireAdmin } from './middleware/auth'; dotenv.config(); +// Validate required environment variables in production +if (process.env.NODE_ENV === 'production') { + const required = ['SUPABASE_URL', 'SUPABASE_SERVICE_KEY', 'FRONTEND_URL']; + const missing = required.filter(k => !process.env[k]); + if (missing.length) { + console.error(`Fatal: missing required env vars: ${missing.join(', ')}`); + process.exit(1); + } +} + const app = express(); const PORT = process.env.PORT || 4000; +app.disable('x-powered-by'); // Initialize Supabase client with service role key const supabaseUrl = process.env.SUPABASE_URL || 'http://supabase-kong:8000'; @@ -30,6 +41,18 @@ console.log('Using service key:', !!supabaseServiceKey); // Middleware const isDemoEnv = process.env.DEMO_MODE === 'true'; +app.use((_req, res, next) => { + // Baseline hardening headers. Keep CSP out here to avoid breaking existing frontend behavior. + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + if (process.env.NODE_ENV === 'production') { + res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload'); + } + next(); +}); + app.use(cors({ origin: isDemoEnv ? true : (process.env.FRONTEND_URL || 'http://localhost:3000'), credentials: true, diff --git a/backend/src/utils/adminNotifications.ts b/backend/src/utils/adminNotifications.ts new file mode 100644 index 0000000..28a50d2 --- /dev/null +++ b/backend/src/utils/adminNotifications.ts @@ -0,0 +1,173 @@ +import { supabase } from '../server'; +import { sendEmail } from '../services/emailService'; + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function getNotificationRecipients(): Promise { + const { data: settings } = await supabase + .from('trust_center_settings') + .select('notify_on_new_request, notification_emails, support_email') + .limit(1) + .single(); + + if (!settings?.notify_on_new_request) return []; + + const recipients = + settings.notification_emails && settings.notification_emails.length > 0 + ? settings.notification_emails + : settings.support_email + ? [settings.support_email] + : []; + + return recipients; +} + +export async function notifyAdminsOfNewRequest(request: { + requester_name: string; + requester_email: string; + requester_company: string; + document_count: number; +}): Promise { + const recipients = await getNotificationRecipients(); + if (recipients.length === 0) return; + + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; + + const html = ` + + + + +
+

New Document Request

+

A new document request has been submitted and requires your review.

+ + + + + + + + + + + + + + + + + +
Name${escapeHtml(request.requester_name)}
Email${escapeHtml(request.requester_email)}
Company${escapeHtml(request.requester_company)}
Documents${request.document_count} document(s)
+ + Review Request + +

+ This is an automated notification from your Trust Center. +

+
+ + + `; + + const from = process.env.SMTP_FROM || 'noreply@trustcenter.com'; + + for (const to of recipients) { + try { + await sendEmail({ + to, + from, + subject: `New document request from ${request.requester_name}`, + html, + }); + console.log(`Admin notification sent to ${to}`); + } catch (err) { + console.error(`Failed to send admin notification to ${to}:`, err); + } + } +} + +export async function notifyAdminsOfNewTicket(ticket: { + name: string; + email: string; + organization?: string | null; + subject: string; +}): Promise { + // Check if support tickets are enabled + const { data: settings } = await supabase + .from('trust_center_settings') + .select('support_tickets_enabled, notify_on_new_request, notification_emails, support_email') + .limit(1) + .single(); + + if (!settings?.support_tickets_enabled || !settings?.notify_on_new_request) return; + + const recipients = + settings.notification_emails && settings.notification_emails.length > 0 + ? settings.notification_emails + : settings.support_email + ? [settings.support_email] + : []; + + if (recipients.length === 0) return; + + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; + + const html = ` + + + + +
+

New Support Ticket

+

A new support ticket has been submitted.

+ + + + + + + + + + ${ticket.organization ? ` + + + + + ` : ''} +
Subject${escapeHtml(ticket.subject)}
From${escapeHtml(ticket.name)} (${escapeHtml(ticket.email)})
Organization${escapeHtml(ticket.organization)}
+ + View Ticket + +

+ This is an automated notification from your Trust Center. +

+
+ + + `; + + const from = process.env.SMTP_FROM || 'noreply@trustcenter.com'; + + for (const to of recipients) { + try { + await sendEmail({ + to, + from, + subject: `New support ticket: ${ticket.subject}`, + html, + }); + console.log(`Ticket notification sent to ${to}`); + } catch (err) { + console.error(`Failed to send ticket notification to ${to}:`, err); + } + } +} diff --git a/backend/src/utils/email.ts b/backend/src/utils/email.ts index a69a280..b4d4468 100644 --- a/backend/src/utils/email.ts +++ b/backend/src/utils/email.ts @@ -1,7 +1,16 @@ import fs from 'fs'; -import path from 'path'; import { sendEmail, EmailAttachment } from '../services/emailService'; import { validateEmailAddress, sanitizeEmailForLogging } from './emailValidation'; +import { resolveUploadPath } from './safePath'; + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} export interface MagicLinkEmailData { requesterName: string; @@ -26,7 +35,6 @@ export const sendMagicLinkEmail = async (data: MagicLinkEmailData): Promise - ${doc.title} - ${doc.fileName ? `
${doc.fileName}` : ''} + ${escapeHtml(doc.title)} + ${doc.fileName ? `
${escapeHtml(doc.fileName)}` : ''} Download @@ -110,7 +110,7 @@ export const sendMagicLinkEmail = async (data: MagicLinkEmailData): Promise

Your Document Request Has Been Approved

-

Hi ${data.requesterName},

+

Hi ${escapeHtml(data.requesterName)},

Your request has been approved. Click the download buttons below to get your documents:

${attachmentInfo} ${attachmentWarning} @@ -174,9 +174,9 @@ export const sendRejectionEmail = async (

Document Request Status Update

-

Hi ${requesterName},

+

Hi ${escapeHtml(requesterName)},

We regret to inform you that your document request has been denied.

- ${reason ? `

Reason: ${reason}

` : ''} + ${reason ? `

Reason: ${escapeHtml(reason)}

` : ''}

If you have any questions, please contact our support team.

diff --git a/backend/src/utils/safePath.ts b/backend/src/utils/safePath.ts new file mode 100644 index 0000000..54f7994 --- /dev/null +++ b/backend/src/utils/safePath.ts @@ -0,0 +1,16 @@ +import path from 'path'; + +const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads'; + +/** + * Resolves a file URL against the uploads directory and ensures the result + * stays within that directory, preventing path traversal attacks. + */ +export function resolveUploadPath(fileUrl: string): string { + const base = path.resolve(UPLOADS_DIR); + const resolved = path.resolve(base, fileUrl); + if (!resolved.startsWith(base + path.sep) && resolved !== base) { + throw new Error('Invalid file path'); + } + return resolved; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b8c58e8..2363cf6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@types/react": "^18.2.46", "@types/react-dom": "^18.2.18", "autoprefixer": "^10.4.16", + "isomorphic-dompurify": "^3.7.1", "lucide-react": "^0.562.0", "next": "^15.0.0", "postcss": "^8.4.32", @@ -40,6 +41,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -49,6 +88,185 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "dev": true, @@ -104,6 +322,23 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -232,8 +467,102 @@ "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -246,74 +575,523 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "15.5.9", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.9", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.7", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ - "arm64" + "x64" ], - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "node": ">= 10" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@next/env": { - "version": "15.5.9", - "license": "MIT" - }, - "node_modules/@next/eslint-plugin-next": { - "version": "15.5.9", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "3.3.1" + "node": ">= 10" } }, - "node_modules/@next/swc-linux-arm64-musl": { + "node_modules/@next/swc-win32-x64-msvc": { "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ - "arm64" + "x64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": ">= 10" @@ -576,6 +1354,17 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "dev": true, @@ -611,6 +1400,13 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -794,92 +1590,347 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.50.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.50.1", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.50.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript-eslint/utils": { - "version": "8.50.1", + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.50.1", - "@typescript-eslint/types": "8.50.1", - "@typescript-eslint/typescript-estree": "8.50.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.50.1", + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@typescript-eslint/types": "8.50.1", - "eslint-visitor-keys": "^4.2.1" + "@napi-rs/wasm-runtime": "^0.2.11" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=14.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "ISC" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ] }, "node_modules/acorn": { @@ -1203,6 +2254,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "license": "MIT", @@ -1457,6 +2517,19 @@ "tiny-invariant": "^1.0.6" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "license": "MIT", @@ -1476,6 +2549,19 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "dev": true, @@ -1540,6 +2626,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -1604,6 +2696,15 @@ "node": ">=6.0.0" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "dev": true, @@ -1626,6 +2727,18 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "dev": true, @@ -2336,6 +3449,20 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -2602,6 +3729,18 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "license": "MIT", @@ -2904,6 +4043,12 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "dev": true, @@ -3041,6 +4186,19 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-dompurify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.7.1.tgz", + "integrity": "sha512-ChhzwwCm7k8h8ANiq1Vc7geCWeHGaAPusgXU5N4mu7Y2wChgn2JHvbUe6aH/XQOUG3+KV+GmqSq95MntW/V1ng==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.3.3", + "jsdom": "^29.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "dev": true, @@ -3080,6 +4238,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "dev": true, @@ -3200,6 +4398,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lucide-react": { "version": "0.562.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", @@ -3217,6 +4424,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "license": "MIT", @@ -3582,6 +4795,18 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -3808,7 +5033,6 @@ }, "node_modules/punycode": { "version": "2.3.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3971,6 +5195,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "license": "MIT", @@ -4097,6 +5330,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "license": "MIT" @@ -4502,6 +5747,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", @@ -4634,6 +5885,24 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "license": "MIT", @@ -4644,6 +5913,30 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "dev": true, @@ -4794,6 +6087,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "license": "MIT" @@ -4880,6 +6182,50 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -5007,6 +6353,21 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 1086ed8..4a1a8e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@types/react": "^18.2.46", "@types/react-dom": "^18.2.18", "autoprefixer": "^10.4.16", + "isomorphic-dompurify": "^3.7.1", "lucide-react": "^0.562.0", "next": "^15.0.0", "postcss": "^8.4.32", diff --git a/frontend/src/app/admin/documents/[id]/page.tsx b/frontend/src/app/admin/documents/[id]/page.tsx index 97d6624..3b09fd0 100644 --- a/frontend/src/app/admin/documents/[id]/page.tsx +++ b/frontend/src/app/admin/documents/[id]/page.tsx @@ -31,6 +31,7 @@ export default function EditDocumentPage() { access_level: 'restricted', status: 'published', requires_nda: false, + expires_at: '', }); useEffect(() => { @@ -67,6 +68,7 @@ export default function EditDocumentPage() { access_level: doc.access_level || 'restricted', status: doc.status || 'published', requires_nda: doc.requires_nda || false, + expires_at: doc.expires_at ? doc.expires_at.split('T')[0] : '', }); // Load categories @@ -136,6 +138,9 @@ export default function EditDocumentPage() { formDataToSend.append('access_level', formData.access_level); formDataToSend.append('status', formData.status); formDataToSend.append('requires_nda', String(formData.requires_nda)); + if (formData.expires_at) { + formDataToSend.append('expires_at', formData.expires_at); + } const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'}/api/documents/${documentId}/replace`, @@ -397,6 +402,23 @@ export default function EditDocumentPage() {
+
+ + + setFormData({ ...formData, expires_at: e.target.value }) + } + className="w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 bg-white focus:ring-2 focus:ring-blue-500" + /> +

+ Optional. Documents past their expiry date will be flagged for review. +

+
+
+ + {/* Expiring Documents */} + {expiringDocs.length > 0 && ( +
+
+ + + +

Documents Expiring Soon

+
+
+ {expiringDocs.map((doc) => { + const expiresAt = new Date(doc.expires_at); + const now = new Date(); + const daysLeft = Math.ceil((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const isExpired = daysLeft <= 0; + const isUrgent = daysLeft <= 7; + + return ( + + {doc.title} + + {isExpired ? 'Expired' : `${daysLeft} day${daysLeft === 1 ? '' : 's'} left`} + + + ); + })} +
+
+ )} ); } diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index 6cadf5f..815182a 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -14,6 +14,7 @@ const DEFAULT_NAV_ITEMS = [ { key: 'certifications', label: 'Certifications', icon: 'badge' }, { key: 'security-updates', label: 'Security Updates', icon: 'bell' }, { key: 'requests', label: 'Requests', icon: 'clipboard' }, + { key: 'tickets', label: 'Support Tickets', icon: 'chat' }, { key: 'organizations', label: 'Organizations', icon: 'building' }, { key: 'users', label: 'Users', icon: 'users' }, { key: 'activity', label: 'Activity Logs', icon: 'log' }, @@ -59,7 +60,7 @@ export default function SettingsAdminPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [token, setToken] = useState(null); - const [activeTab, setActiveTab] = useState<'general' | 'navigation' | 'branding' | 'footer'>('general'); + const [activeTab, setActiveTab] = useState<'general' | 'navigation' | 'branding' | 'footer' | 'features'>('general'); // Navigation ordering state const [navOrder, setNavOrder] = useState(NAV_DEFAULT_KEYS); @@ -235,6 +236,7 @@ export default function SettingsAdminPage() { { id: 'navigation', label: 'Navigation' }, { id: 'branding', label: 'Branding & Colors' }, { id: 'footer', label: 'Footer & Links' }, + { id: 'features', label: 'Features' }, ].map((tab) => ( + ))} + + + + + + {/* Tickets Table */} +
+ {paginatedTickets.length === 0 ? ( +
+
+ + + +
+

No tickets found

+

No support tickets match your filters.

+
+ ) : ( +
+ + + + + + + + + + + + + {paginatedTickets.map((ticket) => ( + +
SubjectFromPriorityStatusMessagesDate
+ {/* Ticket Row */} + + + {/* Expanded Detail */} + {expandedId === ticket.id && ( +
+ {detailLoading ? ( +
+
+
+ ) : ticketDetail ? ( +
+ {/* Original Message */} +
+
+
+

{ticketDetail.name}

+

{ticketDetail.email}{ticketDetail.organization ? ` - ${ticketDetail.organization}` : ''}

+
+ {formatDate(ticketDetail.created_at)} +
+

{ticketDetail.message}

+
+ + {/* Message Thread */} + {ticketDetail.messages.length > 0 && ( +
+

Conversation

+ {ticketDetail.messages.map((msg) => ( +
+
+ + {msg.sender_name || (msg.sender?.full_name || msg.sender?.email || 'Admin')} + + {formatDate(msg.created_at)} +
+

{msg.message}

+
+ ))} +
+ )} + + {/* Actions + Reply */} +
+
+ + +
+
+ + {/* Reply Box */} + {ticketDetail.status !== 'resolved' && ( +
+