From 46e6edcd9c7438c7831163883e9753cd37fa36b7 Mon Sep 17 00:00:00 2001 From: Van Leemput Dayron Date: Tue, 24 Feb 2026 08:25:37 +0100 Subject: [PATCH] feat: add profile setup, verification, and authentication context - Implement ProfileSetup component for user profile creation with username validation. - Create ProtectedRoute component to guard routes based on authentication status. - Add VerifyAuthCode component for email verification with resend functionality. - Establish AuthContext for managing user authentication state and actions. - Enhance CookieContext for consent management with server-side verification. - Set up Firebase configuration for authentication and analytics. - Update styles for authentication components and header layout. - Configure Vite proxy for API requests during development. --- .DS_Store | Bin 8196 -> 8196 bytes backend/.DS_Store | Bin 6148 -> 6148 bytes backend/.gitignore | 6 +- backend/package-lock.json | 78 +- backend/package.json | 9 +- backend/src/config/authDb.ts | 18 + backend/src/config/db.ts | 1 + backend/src/index.ts | 32 +- backend/src/middlewares/security.ts | 70 ++ backend/src/routes/authCode.ts | 404 +++++++ backend/src/routes/cookieConsent.ts | 129 +++ frontend/index.html | 12 +- frontend/package-lock.json | 995 +++++++++++++++++- frontend/package.json | 1 + frontend/public/google.png | Bin 0 -> 20103 bytes frontend/src/App.tsx | 26 +- .../components/CookieBanner/CookieBanner.tsx | 210 ++-- .../CookieBanner/cookie-banner.scss | 200 ++-- frontend/src/components/Header.tsx | 139 ++- frontend/src/components/Login.tsx | 272 +++++ frontend/src/components/ProfileSetup.tsx | 109 ++ frontend/src/components/ProtectedRoute.tsx | 35 + frontend/src/components/VerifyAuthCode.tsx | 130 +++ frontend/src/contexts/AuthContext.tsx | 348 ++++++ frontend/src/contexts/CookieContext.tsx | 126 ++- frontend/src/lib/firebase.ts | 26 + frontend/src/styles/components/_auth.scss | 136 +++ frontend/src/styles/components/_header.scss | 272 +++-- frontend/src/styles/main.scss | 3 +- frontend/vite.config.ts | 6 + 30 files changed, 3469 insertions(+), 324 deletions(-) create mode 100644 backend/src/config/authDb.ts create mode 100644 backend/src/middlewares/security.ts create mode 100644 backend/src/routes/authCode.ts create mode 100644 backend/src/routes/cookieConsent.ts create mode 100644 frontend/public/google.png create mode 100644 frontend/src/components/Login.tsx create mode 100644 frontend/src/components/ProfileSetup.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/components/VerifyAuthCode.tsx create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/lib/firebase.ts create mode 100644 frontend/src/styles/components/_auth.scss diff --git a/.DS_Store b/.DS_Store index dfda25d98589457b70efb441dbef5a22bc788424..feac0d7a30ee126d5221ee10b77923c8dc20cefd 100644 GIT binary patch delta 298 zcmZp1XmOa}&nU4mU^hRb#AF@;saUH(1_lNe20ey!hD?T%+W6i|S zWyc*+@%BSF!FUT+qPR`FQ0P102FbLtWZoz=b2L(kp&lSyLnphyanO))=%Va50X|j!w0swY+V=n*z diff --git a/backend/.DS_Store b/backend/.DS_Store index 525ee8dc4d878fd9f14a9b7ecd39357982aed240..22176a9bd142a88cbaca1f5acdacde05931e01f4 100644 GIT binary patch delta 87 zcmZoMXffDe!o=qaaMLYEzPgi54fmEbSPFbq!4 S&n*DzU|@<$*nF7DQ4|2EtQv6u delta 87 zcmZoMXffDe!o*nF7DQ4|2Hdm3~A diff --git a/backend/.gitignore b/backend/.gitignore index 0c491f6..9498b19 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,4 +1,6 @@ node_modules .env -.env.example -.env.local \ No newline at end of file +.env.local +dist + +.env.example \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 402f4ce..81eeb7f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,12 +12,17 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", - "mysql2": "^3.11.3" + "express-rate-limit": "^8.2.1", + "express-slow-down": "^3.0.1", + "helmet": "^8.1.0", + "mysql2": "^3.11.3", + "nodemailer": "^8.0.1" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.8.6", + "@types/nodemailer": "^7.0.11", "nodemon": "^3.1.7", "ts-node": "^10.9.2", "typescript": "^5.6.3" @@ -166,6 +171,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -608,6 +623,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -649,6 +665,39 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-slow-down": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/express-slow-down/-/express-slow-down-3.0.1.tgz", + "integrity": "sha512-Syl+K1o/z8zSTsMWbDpJEOF5WzSj923ktkv/WbNq6E3tmNwPoEcKIhqRxMLWq3a/4OudRciylEqEZvzMBdzn8w==", + "license": "MIT", + "dependencies": { + "express-rate-limit": "8" + }, + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "express": "4 || 5 || ^5.0.0-beta.1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -827,6 +876,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -872,6 +930,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1106,6 +1173,15 @@ "node": ">= 0.6" } }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", diff --git a/backend/package.json b/backend/package.json index 2ad7760..06936a5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,14 +15,19 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", - "mysql2": "^3.11.3" + "express-rate-limit": "^8.2.1", + "express-slow-down": "^3.0.1", + "helmet": "^8.1.0", + "mysql2": "^3.11.3", + "nodemailer": "^8.0.1" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.8.6", + "@types/nodemailer": "^7.0.11", "nodemon": "^3.1.7", "ts-node": "^10.9.2", "typescript": "^5.6.3" } -} \ No newline at end of file +} diff --git a/backend/src/config/authDb.ts b/backend/src/config/authDb.ts new file mode 100644 index 0000000..e69794d --- /dev/null +++ b/backend/src/config/authDb.ts @@ -0,0 +1,18 @@ +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const authPool = mysql.createPool({ + host: process.env.DB_HOST, + user: process.env.DB_AUTH_USER, + password: process.env.DB_AUTH_PASSWORD, + database: process.env.DB_AUTH_NAME, + port: Number(process.env.DB_PORT) || 3306, + connectTimeout: Number(process.env.DB_CONNECT_TIMEOUT_MS) || 3500, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 +}); + +export default authPool; diff --git a/backend/src/config/db.ts b/backend/src/config/db.ts index 4a76168..60456e3 100644 --- a/backend/src/config/db.ts +++ b/backend/src/config/db.ts @@ -9,6 +9,7 @@ const pool = mysql.createPool({ password: process.env.DB_PASSWORD, database: process.env.DB_NAME, port: Number(process.env.DB_PORT) || 3306, + connectTimeout: Number(process.env.DB_CONNECT_TIMEOUT_MS) || 3500, waitForConnections: true, connectionLimit: 10, queueLimit: 0 diff --git a/backend/src/index.ts b/backend/src/index.ts index dfac28c..c23ec12 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,20 +2,46 @@ import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; import pool from './config/db'; +import { + authLimiter, + authSlowDown, + globalLimiter, + requestTimeout, + securityHeaders +} from './middlewares/security'; import messagesRouter from './routes/messages'; +import authCodeRouter from './routes/authCode'; +import supportRouter from './routes/support'; +import cookieConsentRouter from './routes/cookieConsent'; dotenv.config(); const app = express(); const port = process.env.PORT || 3000; +const allowedOrigins = String(process.env.CORS_ORIGINS || '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); -app.use(cors()); -app.use(express.json()); +app.set('trust proxy', 1); +app.use(securityHeaders); +app.use(globalLimiter); +app.use( + cors({ + origin: allowedOrigins.length > 0 ? allowedOrigins : true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + credentials: false + }) +); +app.use(express.json({ limit: '20kb' })); +app.use(express.urlencoded({ extended: false, limit: '20kb' })); +app.use(requestTimeout(15_000)); app.use('/api/messages', messagesRouter); -import supportRouter from './routes/support'; app.use('/api/support', supportRouter); +app.use('/api/auth', requestTimeout(8_000), authSlowDown, authLimiter, authCodeRouter); +app.use('/api/cookies', requestTimeout(8_000), cookieConsentRouter); // Basic health check app.get('/api/health', (req, res) => { diff --git a/backend/src/middlewares/security.ts b/backend/src/middlewares/security.ts new file mode 100644 index 0000000..ded7f95 --- /dev/null +++ b/backend/src/middlewares/security.ts @@ -0,0 +1,70 @@ +import { Request, Response, NextFunction } from 'express'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import slowDown from 'express-slow-down'; + +export const securityHeaders = helmet({ + crossOriginResourcePolicy: { policy: 'cross-origin' } +}); + +export const globalLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 300, + standardHeaders: true, + legacyHeaders: false, + message: { + status: 'error', + message: 'Trop de requêtes. Réessayez plus tard.' + } +}); + +export const authLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { + status: 'error', + message: 'Trop de tentatives d’authentification. Réessayez plus tard.' + } +}); + +export const verifyCodeLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { + status: 'error', + message: 'Trop de tentatives de validation de code.' + } +}); + +export const authSlowDown = slowDown({ + windowMs: 10 * 60 * 1000, + delayAfter: 5, + delayMs: (hits) => Math.min(1000 * (hits - 5), 10000), + validate: { delayMs: false } +}); + +export const verifyCodeSlowDown = slowDown({ + windowMs: 10 * 60 * 1000, + delayAfter: 3, + delayMs: (hits) => Math.min(1500 * (hits - 3), 12000), + validate: { delayMs: false } +}); + +export const requestTimeout = (timeoutMs: number) => { + return (req: Request, res: Response, next: NextFunction) => { + req.setTimeout(timeoutMs); + res.setTimeout(timeoutMs, () => { + if (!res.headersSent) { + res.status(408).json({ + status: 'error', + message: 'Délai de requête dépassé.' + }); + } + }); + next(); + }; +}; diff --git a/backend/src/routes/authCode.ts b/backend/src/routes/authCode.ts new file mode 100644 index 0000000..7332ad8 --- /dev/null +++ b/backend/src/routes/authCode.ts @@ -0,0 +1,404 @@ +import { Router, Request, Response } from 'express'; +import crypto from 'crypto'; +import nodemailer from 'nodemailer'; +import { ResultSetHeader, RowDataPacket } from 'mysql2'; +import authPool from '../config/authDb'; +import { verifyCodeLimiter, verifyCodeSlowDown } from '../middlewares/security'; + +const router = Router(); + +const CODE_TTL_MS = 10 * 60 * 1000; +const SESSION_TTL_HOURS = Math.min(Number(process.env.AUTH_SESSION_TTL_HOURS || 12), 24); +const SESSION_TTL_MS = SESSION_TTL_HOURS * 60 * 60 * 1000; +const RESEND_COOLDOWN_MS = 60 * 1000; +const MAX_ATTEMPTS = 5; +const HASH_PEPPER = process.env.AUTH_HASH_PEPPER || 'change-this-pepper'; + +const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const usernameRegex = /^(?=.{3,24}$)[a-zA-Z0-9._-]+$/; + +const normalizeEmail = (email: string) => email.trim().toLowerCase(); + +const hashCode = (email: string, code: string) => { + return crypto.createHash('sha256').update(`${email}:${code}:${HASH_PEPPER}`).digest('hex'); +}; + +const normalizeUsername = (username: string) => username.trim().toLowerCase(); + +const hashSessionToken = (token: string) => { + return crypto.createHash('sha256').update(`${token}:${HASH_PEPPER}`).digest('hex'); +}; + +const resolveAuthDbError = (error: any) => { + const code = String(error?.code || ''); + if (code === 'ETIMEDOUT' || code === 'ECONNREFUSED' || code === 'PROTOCOL_CONNECTION_LOST') { + return { status: 503, message: 'Base auth indisponible.' }; + } + if (code === 'ER_ACCESS_DENIED_ERROR' || code === 'ER_BAD_DB_ERROR') { + return { status: 503, message: 'Configuration DB auth invalide.' }; + } + if (code === 'ER_NO_SUCH_TABLE') { + return { status: 503, message: 'Tables auth manquantes.' }; + } + return null; +}; + +const cleanupExpiredEntries = async () => { + await authPool.query('DELETE FROM auth_code_challenges WHERE expires_at <= NOW() OR consumed_at IS NOT NULL'); + await authPool.query('DELETE FROM auth_verified_sessions WHERE expires_at <= NOW() OR revoked_at IS NOT NULL'); +}; + +const getTransporter = () => { + const host = process.env.MAIL_HOST; + const port = Number(process.env.MAIL_PORT || 587); + const secure = String(process.env.MAIL_SECURE || 'false') === 'true'; + const user = process.env.MAIL_USER; + const pass = process.env.MAIL_PASS; + + if (!host || !user || !pass) { + return null; + } + + return nodemailer.createTransport({ + host, + port, + secure, + connectionTimeout: Number(process.env.MAIL_CONNECTION_TIMEOUT_MS) || 4000, + greetingTimeout: Number(process.env.MAIL_GREETING_TIMEOUT_MS) || 4000, + socketTimeout: Number(process.env.MAIL_SOCKET_TIMEOUT_MS) || 6000, + auth: { + user, + pass + } + }); +}; + +router.post('/send-code', async (req: Request, res: Response): Promise => { + const incomingEmail = String(req.body?.email || ''); + const email = normalizeEmail(incomingEmail); + + if (!email || !emailRegex.test(email)) { + res.status(400).json({ status: 'error', message: 'Email invalide.' }); + return; + } + + const transporter = getTransporter(); + if (!transporter) { + res.status(500).json({ status: 'error', message: 'SMTP non configuré.' }); + return; + } + + try { + await cleanupExpiredEntries(); + + const [existingRows] = await authPool.query( + `SELECT id, last_sent_at + FROM auth_code_challenges + WHERE email = ? AND consumed_at IS NULL AND expires_at > NOW() + ORDER BY id DESC + LIMIT 1`, + [email] + ); + + if (existingRows.length > 0) { + const lastSent = new Date(existingRows[0].last_sent_at as string | Date).getTime(); + const remainingMs = RESEND_COOLDOWN_MS - (Date.now() - lastSent); + if (remainingMs > 0) { + res.status(429).json({ + status: 'error', + message: 'Veuillez patienter avant de demander un nouveau code.', + retryAfterSeconds: Math.ceil(remainingMs / 1000) + }); + return; + } + } + + const code = String(crypto.randomInt(100000, 1000000)); + const codeHash = hashCode(email, code); + const fromAddress = process.env.MAIL_FROM || process.env.MAIL_USER || 'no-reply@xeewy.be'; + const subject = process.env.MAIL_AUTH_SUBJECT || 'Votre code de verification'; + const text = `Votre code de verification est: ${code}\nCe code expire dans 10 minutes.`; + + await transporter.sendMail({ + from: fromAddress, + to: email, + subject, + text + }); + + await authPool.query( + `INSERT INTO auth_code_challenges (email, code_hash, attempts, expires_at, last_sent_at) + VALUES (?, ?, 0, DATE_ADD(NOW(), INTERVAL ? SECOND), NOW())`, + [email, codeHash, Math.floor(CODE_TTL_MS / 1000)] + ); + + res.json({ status: 'success', message: 'Code envoyé.' }); + } catch (error) { + console.error('Failed to send auth code email:', error); + res.status(500).json({ status: 'error', message: "Impossible d'envoyer le code." }); + } +}); + +router.get('/username-available', async (req: Request, res: Response): Promise => { + const incomingUsername = String(req.query.username || ''); + const username = normalizeUsername(incomingUsername); + + if (!usernameRegex.test(username)) { + res.status(400).json({ + status: 'error', + message: 'Pseudo invalide (3-24, lettres/chiffres/._-).' + }); + return; + } + + try { + const [rows] = await authPool.query( + 'SELECT id FROM auth_users WHERE username = ? LIMIT 1', + [username] + ); + + res.json({ + status: 'success', + available: rows.length === 0 + }); + } catch (error) { + console.error('Failed to check username availability:', error); + res.status(500).json({ status: 'error', message: 'Erreur serveur.' }); + } +}); + +router.get('/profile-status', async (req: Request, res: Response): Promise => { + const incomingEmail = String(req.query.email || ''); + const email = normalizeEmail(incomingEmail); + + if (!email || !emailRegex.test(email)) { + res.status(400).json({ status: 'error', message: 'Email invalide.' }); + return; + } + + try { + const [rows] = await authPool.query( + 'SELECT id, username FROM auth_users WHERE email = ? LIMIT 1', + [email] + ); + + if (rows.length === 0) { + res.json({ status: 'success', hasProfile: false }); + return; + } + + res.json({ + status: 'success', + hasProfile: true, + username: String(rows[0].username || '') + }); + } catch (error) { + console.error('Failed to get profile status:', error); + const dbError = resolveAuthDbError(error); + if (dbError) { + res.status(dbError.status).json({ status: 'error', message: dbError.message }); + return; + } + res.status(500).json({ status: 'error', message: 'Erreur serveur.' }); + } +}); + +router.post('/register-profile', async (req: Request, res: Response): Promise => { + const incomingEmail = String(req.body?.email || ''); + const incomingUid = String(req.body?.firebaseUid || ''); + const incomingUsername = String(req.body?.username || ''); + + const email = normalizeEmail(incomingEmail); + const firebaseUid = incomingUid.trim(); + const username = normalizeUsername(incomingUsername); + + if (!email || !emailRegex.test(email)) { + res.status(400).json({ status: 'error', message: 'Email invalide.' }); + return; + } + + if (!firebaseUid || firebaseUid.length < 10) { + res.status(400).json({ status: 'error', message: 'UID Firebase invalide.' }); + return; + } + + if (!usernameRegex.test(username)) { + res.status(400).json({ + status: 'error', + message: 'Pseudo invalide (3-24, lettres/chiffres/._-).' + }); + return; + } + + try { + const [existingByEmail] = await authPool.query( + 'SELECT id, username FROM auth_users WHERE email = ? LIMIT 1', + [email] + ); + + if (existingByEmail.length > 0) { + const currentUsername = String(existingByEmail[0].username || ''); + if (currentUsername === username) { + res.json({ status: 'success', message: 'Profil déjà enregistré.' }); + return; + } + + res.status(409).json({ + status: 'error', + message: 'Un profil existe déjà pour cet email avec un autre pseudo.' + }); + return; + } + + await authPool.query( + 'INSERT INTO auth_users (firebase_uid, email, username) VALUES (?, ?, ?)', + [firebaseUid, email, username] + ); + + res.status(201).json({ status: 'success', message: 'Profil enregistré.' }); + } catch (error: any) { + if (error?.code === 'ER_DUP_ENTRY') { + res.status(409).json({ status: 'error', message: 'Ce pseudo est déjà utilisé.' }); + return; + } + console.error('Failed to register auth profile:', error); + res.status(500).json({ status: 'error', message: 'Erreur serveur.' }); + } +}); + +router.post('/verify-code', verifyCodeSlowDown, verifyCodeLimiter, async (req: Request, res: Response): Promise => { + const incomingEmail = String(req.body?.email || ''); + const incomingCode = String(req.body?.code || '').trim(); + const email = normalizeEmail(incomingEmail); + + if (!email || !emailRegex.test(email) || !/^\d{6}$/.test(incomingCode)) { + res.status(400).json({ status: 'error', message: 'Données invalides.' }); + return; + } + + try { + await cleanupExpiredEntries(); + + const [rows] = await authPool.query( + `SELECT id, code_hash, attempts, expires_at + FROM auth_code_challenges + WHERE email = ? AND consumed_at IS NULL + ORDER BY id DESC + LIMIT 1`, + [email] + ); + + if (rows.length === 0) { + res.status(404).json({ status: 'error', message: 'Aucun code actif pour cet email.' }); + return; + } + + const challenge = rows[0]; + const challengeId = Number(challenge.id); + const attempts = Number(challenge.attempts || 0); + const expiresAt = new Date(challenge.expires_at as string | Date).getTime(); + const storedHash = String(challenge.code_hash || ''); + + if (expiresAt <= Date.now()) { + await authPool.query('UPDATE auth_code_challenges SET consumed_at = NOW() WHERE id = ?', [challengeId]); + res.status(410).json({ status: 'error', message: 'Code expiré.' }); + return; + } + + if (attempts >= MAX_ATTEMPTS) { + await authPool.query('UPDATE auth_code_challenges SET consumed_at = NOW() WHERE id = ?', [challengeId]); + res.status(429).json({ status: 'error', message: 'Trop de tentatives.' }); + return; + } + + const providedHash = hashCode(email, incomingCode); + const validCode = crypto.timingSafeEqual(Buffer.from(storedHash), Buffer.from(providedHash)); + + if (!validCode) { + await authPool.query('UPDATE auth_code_challenges SET attempts = attempts + 1 WHERE id = ?', [challengeId]); + res.status(401).json({ status: 'error', message: 'Code invalide.' }); + return; + } + + await authPool.query('UPDATE auth_code_challenges SET consumed_at = NOW() WHERE id = ?', [challengeId]); + + const token = crypto.randomBytes(32).toString('hex'); + const tokenHash = hashSessionToken(token); + + await authPool.query( + `INSERT INTO auth_verified_sessions (token_hash, email, expires_at) + VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))`, + [tokenHash, email, Math.floor(SESSION_TTL_MS / 1000)] + ); + + res.json({ + status: 'success', + token, + expiresAt: Date.now() + SESSION_TTL_MS + }); + } catch (error) { + console.error('Failed to verify code:', error); + res.status(500).json({ status: 'error', message: 'Erreur serveur.' }); + } +}); + +router.post('/check-session', async (req: Request, res: Response): Promise => { + const incomingEmail = String(req.body?.email || ''); + const token = String(req.body?.token || '').trim(); + const email = normalizeEmail(incomingEmail); + + if (!email || !emailRegex.test(email) || !token) { + res.status(400).json({ status: 'error', message: 'Données invalides.' }); + return; + } + + try { + await cleanupExpiredEntries(); + + const tokenHash = hashSessionToken(token); + const [rows] = await authPool.query( + `SELECT id + FROM auth_verified_sessions + WHERE token_hash = ? AND email = ? AND revoked_at IS NULL AND expires_at > NOW() + LIMIT 1`, + [tokenHash, email] + ); + + if (rows.length === 0) { + res.status(401).json({ status: 'error', message: 'Session invalide.' }); + return; + } + + res.json({ status: 'success' }); + } catch (error) { + console.error('Failed to check session:', error); + res.status(500).json({ status: 'error', message: 'Erreur serveur.' }); + } +}); + +router.post('/logout-session', async (req: Request, res: Response): Promise => { + const incomingEmail = String(req.body?.email || ''); + const token = String(req.body?.token || '').trim(); + const email = normalizeEmail(incomingEmail); + + if (!email || !emailRegex.test(email) || !token) { + res.status(400).json({ status: 'error', message: 'Données invalides.' }); + return; + } + + try { + const tokenHash = hashSessionToken(token); + await authPool.query( + `UPDATE auth_verified_sessions + SET revoked_at = NOW() + WHERE token_hash = ? AND email = ? AND revoked_at IS NULL`, + [tokenHash, email] + ); + res.json({ status: 'success' }); + } catch (error) { + console.error('Failed to revoke session:', error); + res.status(500).json({ status: 'error', message: 'Erreur serveur.' }); + } +}); + +export default router; diff --git a/backend/src/routes/cookieConsent.ts b/backend/src/routes/cookieConsent.ts new file mode 100644 index 0000000..2c3a96e --- /dev/null +++ b/backend/src/routes/cookieConsent.ts @@ -0,0 +1,129 @@ +import { Request, Response, Router } from 'express'; +import crypto from 'crypto'; + +const router = Router(); +const CONSENT_VERSION = 2; +const CONSENT_TTL_SECONDS = 180 * 24 * 60 * 60; + +type ConsentPayload = { + essential: boolean; + preferences: boolean; + analytics: boolean; +}; + +const getSecret = () => { + return process.env.CONSENT_COOKIE_SECRET || process.env.AUTH_HASH_PEPPER || ''; +}; + +const base64UrlEncode = (value: string) => { + return Buffer.from(value, 'utf-8').toString('base64url'); +}; + +const base64UrlDecode = (value: string) => { + return Buffer.from(value, 'base64url').toString('utf-8'); +}; + +const signValue = (payloadB64: string, secret: string) => { + return crypto.createHmac('sha512', secret).update(payloadB64).digest('hex'); +}; + +const isValidConsent = (consent: any): consent is ConsentPayload => { + return ( + typeof consent === 'object' && + consent !== null && + typeof consent.essential === 'boolean' && + typeof consent.preferences === 'boolean' && + typeof consent.analytics === 'boolean' + ); +}; + +router.post('/sign-consent', (req: Request, res: Response): void => { + const secret = getSecret(); + if (!secret || secret.length < 32) { + res.status(500).json({ status: 'error', message: 'CONSENT_COOKIE_SECRET manquant ou trop court.' }); + return; + } + + const consent = req.body?.consent; + if (!isValidConsent(consent)) { + res.status(400).json({ status: 'error', message: 'Consent payload invalide.' }); + return; + } + + const now = Math.floor(Date.now() / 1000); + const payload = { + consent: { + essential: true, + preferences: Boolean(consent.preferences), + analytics: Boolean(consent.analytics) + }, + version: CONSENT_VERSION, + iat: now, + exp: now + CONSENT_TTL_SECONDS, + nonce: crypto.randomBytes(16).toString('hex') + }; + + const payloadB64 = base64UrlEncode(JSON.stringify(payload)); + const signature = signValue(payloadB64, secret); + const token = `${payloadB64}.${signature}`; + + res.json({ + status: 'success', + token, + maxAgeSeconds: CONSENT_TTL_SECONDS + }); +}); + +router.post('/verify-consent', (req: Request, res: Response): void => { + const secret = getSecret(); + if (!secret || secret.length < 32) { + res.status(500).json({ status: 'error', message: 'CONSENT_COOKIE_SECRET manquant ou trop court.' }); + return; + } + + const token = String(req.body?.token || ''); + const [payloadB64, providedSignature] = token.split('.'); + if (!payloadB64 || !providedSignature) { + res.json({ status: 'success', valid: false }); + return; + } + + try { + const expectedSignature = signValue(payloadB64, secret); + + if ( + expectedSignature.length !== providedSignature.length || + !crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(providedSignature)) + ) { + res.json({ status: 'success', valid: false }); + return; + } + + const payload = JSON.parse(base64UrlDecode(payloadB64)) as { + consent?: ConsentPayload; + version?: number; + exp?: number; + }; + + if (!payload || payload.version !== CONSENT_VERSION || !isValidConsent(payload.consent)) { + res.json({ status: 'success', valid: false }); + return; + } + + const now = Math.floor(Date.now() / 1000); + if (!payload.exp || payload.exp <= now) { + res.json({ status: 'success', valid: false }); + return; + } + + res.json({ + status: 'success', + valid: true, + consent: payload.consent + }); + } catch { + res.json({ status: 'success', valid: false }); + } +}); + +export default router; diff --git a/frontend/index.html b/frontend/index.html index 1c926e9..9ec5af0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,15 @@ xeewy.be + content="default-src 'self'; + script-src 'self' 'unsafe-inline' 'unsafe-eval' https://apis.google.com https://www.gstatic.com https://www.googletagmanager.com https://www.google.com; + script-src-elem 'self' 'unsafe-inline' https://apis.google.com https://www.gstatic.com https://www.googletagmanager.com https://www.google.com; + style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; + style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com; + font-src 'self' data: https://fonts.gstatic.com; + img-src 'self' data: https:; + connect-src 'self' ws://localhost:* http://localhost:* https://api.emailjs.com https://*.googleapis.com https://*.firebaseio.com https://*.gstatic.com https://www.googletagmanager.com https://www.google-analytics.com https://region1.google-analytics.com; + frame-src 'self' https://accounts.google.com https://*.google.com https://*.firebaseapp.com;"> @@ -15,4 +23,4 @@ - \ No newline at end of file + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0285b15..a9d9d5d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@emailjs/browser": "^4.4.1", "axios": "^1.13.2", + "firebase": "^12.9.0", "framer-motion": "^12.23.24", "lucide-react": "^0.553.0", "react": "^19.2.0", @@ -923,6 +924,649 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@firebase/ai": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.8.0.tgz", + "integrity": "sha512-grWYGFPsSo+pt+6CYeKR0kWnUfoLLS3xgWPvNrhAS5EPxl6xWq7+HjDZqX24yLneETyl45AVgDsTbVgxeWeRfg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.19", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.19.tgz", + "integrity": "sha512-3wU676fh60gaiVYQEEXsbGS4HbF2XsiBphyvvqDbtC1U4/dO4coshbYktcCHq+HFaGIK07iHOh4pME0hEq1fcg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.25.tgz", + "integrity": "sha512-fdzoaG0BEKbqksRDhmf4JoyZf16Wosrl0Y7tbZtJyVDOOwziE0vrFjmZuTdviL0yhak+Nco6rMsUUbkbD+qb6Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.19", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.14.8", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.8.tgz", + "integrity": "sha512-WiE9uCGRLUnShdjb9iP20sA3ToWrBbNXr14/N5mow7Nls9dmKgfGaGX5cynLvrltxq2OrDLh1VDNaUgsnS/k/g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.0.tgz", + "integrity": "sha512-XAvALQayUMBJo58U/rxW02IhsesaxxfWVmVkauZvGEz3vOAjMEQnzFlyblqkc2iAaO82uJ2ZVyZv9XzPfxjJ6w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.0.tgz", + "integrity": "sha512-UfK2Q8RJNjYM/8MFORltZRG9lJj11k0nW84rrffiKvcJxLf1jf6IEjCIkCamykHE73C6BwqhVfhIBs69GXQV0g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.11.0", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.8.tgz", + "integrity": "sha512-4De6SUZ36zozl9kh5rZSxKWULpgty27rMzZ6x+xkoo7+NWyhWyFdsdvhFsWhTw/9GGj0wXIcbTjwHYCUIUuHyg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/app": "0.14.8", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@firebase/auth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.12.0.tgz", + "integrity": "sha512-zkvLpsrxynWHk07qGrUDfCSqKf4AvfZGEqJ7mVCtYGjNNDbGE71k0Yn84rg8QEZu4hQw1BC0qDEHzpNVBcSVmA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.2.tgz", + "integrity": "sha512-8UhCzF6pav9bw/eXA8Zy1QAKssPRYEYXaWagie1ewLTwHkXv6bKp/j6/IwzSYQP67sy/BMFXIFaCCsoXzFLr7A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.12.0", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", + "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", + "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.12.tgz", + "integrity": "sha512-baPddcoNLj/+vYo+HSJidJUdr5W4OkhT109c5qhR8T1dJoZcyJpkv/dFpYlw/VJ3dV66vI8GHQFrmAZw/xUS4g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", + "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", + "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", + "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.11.0.tgz", + "integrity": "sha512-Zb88s8rssBd0J2Tt+NUXMPt2sf+Dq7meatKiJf5t9oto1kZ8w9gK59Koe1uPVbaKfdgBp++N/z0I4G/HamyEhg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "@firebase/webchannel-wrapper": "1.0.5", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.5.tgz", + "integrity": "sha512-yVX1CkVvqBI4qbA56uZo42xFA4TNU0ICQ+9AFDvYq9U9Xu6iAx9lFDAk/tN+NGereQQXXCSnpISwc/oxsQqPLA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/firestore": "4.11.0", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.1.tgz", + "integrity": "sha512-sUeWSb0rw5T+6wuV2o9XNmh9yHxjFI9zVGFnjFi+n7drTEWpl7ZTz1nROgGrSu472r+LAaj+2YaSicD4R8wfbw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.1.tgz", + "integrity": "sha512-AxxUBXKuPrWaVNQ8o1cG1GaCAtXT8a0eaTDfqgS5VsRYLAR0ALcfqDLwo/QyijZj1w8Qf8n3Qrfy/+Im245hOQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/functions": "0.13.1", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.19", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.19.tgz", + "integrity": "sha512-nGDmiwKLI1lerhwfwSHvMR9RZuIH5/8E3kgUWnVRqqL7kGVSktjLTWEMva7oh5yxQ3zXfIlIwJwMcaM5bK5j8Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.19", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.19.tgz", + "integrity": "sha512-khfzIY3EI5LePePo7vT19/VEIH1E3iYsHknI/6ek9T8QCozAZshWT9CjlwOzZrKvTHMeNcbpo/VSOSIWDSjWdQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.23", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.23.tgz", + "integrity": "sha512-cfuzv47XxqW4HH/OcR5rM+AlQd1xL/VhuaeW/wzMW1LFrsFcTn0GND/hak1vkQc2th8UisBcrkVcQAnOnKwYxg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.23.tgz", + "integrity": "sha512-SN857v/kBUvlQ9X/UjAqBoQ2FEaL1ZozpnmL1ByTe57iXkmnVVFm9KqAsTfmf+OEwWI4kJJe9NObtN/w22lUgg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/messaging": "0.12.23", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.9", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.9.tgz", + "integrity": "sha512-UzybENl1EdM2I1sjYm74xGt/0JzRnU/0VmfMAKo2LSpHJzaj77FCLZXmYQ4oOuE+Pxtt8Wy2BVJEENiZkaZAzQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.22.tgz", + "integrity": "sha512-xLKxaSAl/FVi10wDX/CHIYEUP13jXUjinL+UaNXT9ByIvxII5Ne5150mx6IgM8G6Q3V+sPiw9C8/kygkyHUVxg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/performance": "0.7.9", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.8.0.tgz", + "integrity": "sha512-sJz7C2VACeE257Z/3kY9Ap2WXbFsgsDLfaGfZmmToKAK39ipXxFan+vzB9CSbF6mP7bzjyzEnqPcMXhAnYE6fQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.21", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.21.tgz", + "integrity": "sha512-9+lm0eUycxbu8GO25JfJe4s6R2xlDqlVt0CR6CvN9E6B4AFArEV4qfLoDVRgIEB7nHKwvH2nYRocPWfmjRQTnw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/remote-config": "0.8.0", + "@firebase/remote-config-types": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz", + "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.0.tgz", + "integrity": "sha512-xWWbb15o6/pWEw8H01UQ1dC5U3rf8QTAzOChYyCpafV6Xki7KVp3Yaw2nSklUwHEziSWE9KoZJS7iYeyqWnYFA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.0.tgz", + "integrity": "sha512-vDzhgGczr1OfcOy285YAPur5pWDEvD67w4thyeCUh6Ys0izN9fNYtA1MJERmNBfqjqu0lg0FM5GLbw0Il21M+g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/storage": "0.14.0", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", + "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz", + "integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1373,6 +2017,70 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.43", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", @@ -1751,9 +2459,7 @@ "version": "24.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2113,11 +2819,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2306,11 +3020,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2323,7 +3050,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2456,6 +3182,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2547,7 +3279,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2805,6 +3536,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2848,6 +3591,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.9.0.tgz", + "integrity": "sha512-CwwTYoqZg6KxygPOaaJqIc4aoLvo0RCRrXoln9GoxLE8QyAwTydBaSLGVlR4WPcuOgN3OEL0tJLT1H4IU/dv7w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "2.8.0", + "@firebase/analytics": "0.10.19", + "@firebase/analytics-compat": "0.2.25", + "@firebase/app": "0.14.8", + "@firebase/app-check": "0.11.0", + "@firebase/app-check-compat": "0.4.0", + "@firebase/app-compat": "0.5.8", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.12.0", + "@firebase/auth-compat": "0.6.2", + "@firebase/data-connect": "0.3.12", + "@firebase/database": "1.1.0", + "@firebase/database-compat": "2.1.0", + "@firebase/firestore": "4.11.0", + "@firebase/firestore-compat": "0.4.5", + "@firebase/functions": "0.13.1", + "@firebase/functions-compat": "0.4.1", + "@firebase/installations": "0.6.19", + "@firebase/installations-compat": "0.2.19", + "@firebase/messaging": "0.12.23", + "@firebase/messaging-compat": "0.2.23", + "@firebase/performance": "0.7.9", + "@firebase/performance-compat": "0.2.22", + "@firebase/remote-config": "0.8.0", + "@firebase/remote-config-compat": "0.2.21", + "@firebase/storage": "0.14.0", + "@firebase/storage-compat": "0.4.0", + "@firebase/util": "1.13.0" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -2966,6 +3745,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3097,6 +3885,18 @@ "node": ">= 0.4" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3151,6 +3951,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3288,6 +4097,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3295,6 +4110,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3586,6 +4407,30 @@ "node": ">= 0.8.0" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3708,6 +4553,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3795,6 +4649,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/sass": { "version": "1.94.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.0.tgz", @@ -3872,6 +4746,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4035,7 +4935,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -4187,6 +5086,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4213,6 +5141,32 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4220,6 +5174,33 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0ff9a7f..8144b15 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@emailjs/browser": "^4.4.1", "axios": "^1.13.2", + "firebase": "^12.9.0", "framer-motion": "^12.23.24", "lucide-react": "^0.553.0", "react": "^19.2.0", diff --git a/frontend/public/google.png b/frontend/public/google.png new file mode 100644 index 0000000000000000000000000000000000000000..001e1a21d74893211d3b4e37350a42bb56e44715 GIT binary patch literal 20103 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 } /> - - + + + + + } /> @@ -61,6 +69,11 @@ function AppContent() { document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light'); }, [darkMode, consent]); + useEffect(() => { + if (!consent.analytics) return; + void initFirebaseAnalytics(); + }, [consent.analytics]); + const toggleDarkMode = () => { setDarkMode(!darkMode); }; @@ -75,11 +88,14 @@ function AppContent() { } /> } /> } /> - } /> + } /> } /> } /> } /> - } /> + } /> + } /> + } /> + } />