From 96dca2a802b8495b1dc80a3e32144e5ac4c52066 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:43:07 +0000 Subject: [PATCH] =?UTF-8?q?Application=20compl=C3=A8te=20SLUC=20Business?= =?UTF-8?q?=20Club=20(React=20+=20Express=20+=20PostgreSQL,=20Docker)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémente l'intégralité de la maquette « SLUC Business Club.dc.html » : - Site public : accueil (héro administrable, carrousel membres, agenda avec inscription en ligne, rencontres passées, demande d'adhésion), annuaire avec recherche/filtres/fiche détaillée, page association - Espace membre : connexion, édition de fiche avec aperçu direct, upload logo/photo, statut d'adhésion par saison, changement de mot de passe - Espace admin : tableau de bord, membres (validation par saison), rencontres (CRUD + inscrits + impression + export Excel), inscriptions, catégories, contenu du site Architecture : 3 conteneurs Docker Compose — PostgreSQL 16 (réseau interne, rôle applicatif restreint), API Express (non-root, read-only fs), nginx non privilégié (frontend React + reverse-proxy + CSP stricte). Sécurité : requêtes 100 % paramétrées, bcrypt + JWT httpOnly SameSite=Strict, vérification d'Origin (CSRF), validation zod, rate limiting, uploads vérifiés par octets magiques avec noms aléatoires, aucun secret committé. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o --- .env.example | 26 + .gitignore | 6 + README.md | 137 ++ db/init/01-app-role.sh | 11 + db/init/02-schema.sql | 87 ++ db/init/03-seed.sql | 68 + docker-compose.yml | 77 + server/.dockerignore | 2 + server/Dockerfile | 18 + server/package-lock.json | 1313 ++++++++++++++++ server/package.json | 21 + server/src/bootstrap.js | 32 + server/src/config.js | 25 + server/src/db.js | 23 + server/src/index.js | 53 + server/src/middleware/auth.js | 40 + server/src/middleware/security.js | 45 + server/src/middleware/validate.js | 13 + server/src/routes/admin.js | 312 ++++ server/src/routes/auth.js | 71 + server/src/routes/member.js | 70 + server/src/routes/public.js | 100 ++ server/src/schemas.js | 74 + server/src/uploads.js | 49 + web/.dockerignore | 3 + web/Dockerfile | 13 + web/index.html | 19 + web/nginx.conf | 55 + web/package-lock.json | 1722 +++++++++++++++++++++ web/package.json | 20 + web/public/assets/logo.jpg | Bin 0 -> 29979 bytes web/src/App.jsx | 35 + web/src/components/Footer.jsx | 68 + web/src/components/Header.jsx | 47 + web/src/components/ImageSlot.jsx | 49 + web/src/components/MemberModal.jsx | 69 + web/src/components/Modal.jsx | 23 + web/src/components/admin/AdminContent.jsx | 199 +++ web/src/components/admin/AdminTabs.jsx | 608 ++++++++ web/src/lib/AuthContext.jsx | 39 + web/src/lib/api.js | 52 + web/src/lib/usePublic.js | 19 + web/src/main.jsx | 16 + web/src/pages/Admin.jsx | 152 ++ web/src/pages/Annuaire.jsx | 101 ++ web/src/pages/Association.jsx | 125 ++ web/src/pages/Espace.jsx | 277 ++++ web/src/pages/Home.jsx | 340 ++++ web/src/styles.css | 147 ++ web/vite.config.js | 16 + 50 files changed, 6887 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 db/init/01-app-role.sh create mode 100644 db/init/02-schema.sql create mode 100644 db/init/03-seed.sql create mode 100644 docker-compose.yml create mode 100644 server/.dockerignore create mode 100644 server/Dockerfile create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/bootstrap.js create mode 100644 server/src/config.js create mode 100644 server/src/db.js create mode 100644 server/src/index.js create mode 100644 server/src/middleware/auth.js create mode 100644 server/src/middleware/security.js create mode 100644 server/src/middleware/validate.js create mode 100644 server/src/routes/admin.js create mode 100644 server/src/routes/auth.js create mode 100644 server/src/routes/member.js create mode 100644 server/src/routes/public.js create mode 100644 server/src/schemas.js create mode 100644 server/src/uploads.js create mode 100644 web/.dockerignore create mode 100644 web/Dockerfile create mode 100644 web/index.html create mode 100644 web/nginx.conf create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/assets/logo.jpg create mode 100644 web/src/App.jsx create mode 100644 web/src/components/Footer.jsx create mode 100644 web/src/components/Header.jsx create mode 100644 web/src/components/ImageSlot.jsx create mode 100644 web/src/components/MemberModal.jsx create mode 100644 web/src/components/Modal.jsx create mode 100644 web/src/components/admin/AdminContent.jsx create mode 100644 web/src/components/admin/AdminTabs.jsx create mode 100644 web/src/lib/AuthContext.jsx create mode 100644 web/src/lib/api.js create mode 100644 web/src/lib/usePublic.js create mode 100644 web/src/main.jsx create mode 100644 web/src/pages/Admin.jsx create mode 100644 web/src/pages/Annuaire.jsx create mode 100644 web/src/pages/Association.jsx create mode 100644 web/src/pages/Espace.jsx create mode 100644 web/src/pages/Home.jsx create mode 100644 web/src/styles.css create mode 100644 web/vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4df1a6a --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Copy this file to .env and fill in strong secrets before running. +# cp .env.example .env +# openssl rand -hex 32 # use for JWT_SECRET +# openssl rand -hex 24 # use for each DB password + +# PostgreSQL superuser password (used only inside the db container) +POSTGRES_PASSWORD=change-me-postgres-superuser + +# Password of the restricted application role (sbc_app) the API connects with +APP_DB_PASSWORD=change-me-app-db-password + +# Secret used to sign session tokens (JWT). MUST be long and random. +JWT_SECRET=change-me-64-hex-chars-min + +# Initial password of the admin account (admin@sluc-businessclub.fr). +# Applied/updated at API startup. Change it after first login. +ADMIN_INITIAL_PASSWORD=ChangeMe-Admin-2026! + +# Initial password given to every seeded member account (demo data only) +MEMBER_INITIAL_PASSWORD=ChangeMe-Membre-2026! + +# Set to "true" when serving over HTTPS (adds Secure flag on cookies) +COOKIE_SECURE=false + +# Public port of the web frontend +WEB_PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d1dae7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +uploads/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9daadb5 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# Business Club SLUC Nancy + +Application web complète du réseau d'affaires des partenaires du SLUC Nancy Basket : +site public (accueil, annuaire des membres, association), espace membre et +back-office d'administration. + +## Architecture + +Trois conteneurs orchestrés par Docker Compose : + +| Service | Rôle | Exposition | +|---------|------|------------| +| `db` | PostgreSQL 16 (schéma + données de démo au premier démarrage) | réseau interne uniquement, aucun port publié | +| `api` | API REST Node.js 22 / Express (auth, membres, rencontres, inscriptions, catégories, contenu, uploads) | réseau interne uniquement | +| `web` | nginx (non-root) : frontend React compilé, reverse-proxy `/api`, service des images `/uploads` | port `8080` | + +``` +Navigateur ──> web (nginx :8080) ──> api (Express :3000) ──> db (PostgreSQL :5432) + │ /uploads (volume partagé, lecture seule) + └─ fichiers statiques React +``` + +- **Frontend** : React 18 + Vite + React Router — reproduction fidèle de la maquette + (`SLUC Business Club.dc.html`). +- **Backend** : Express, `pg` (requêtes paramétrées), `zod` (validation), `bcryptjs` + (hachage), JWT en cookie httpOnly, `helmet`, `express-rate-limit`, `multer` (uploads). +- **Base** : PostgreSQL 16, initialisée par `db/init/` (rôle applicatif restreint, + schéma, données de démonstration). + +## Démarrage + +```bash +cp .env.example .env +# Éditez .env : générez des secrets forts +# openssl rand -hex 32 → JWT_SECRET +# openssl rand -hex 24 → POSTGRES_PASSWORD et APP_DB_PASSWORD +# puis choisissez ADMIN_INITIAL_PASSWORD / MEMBER_INITIAL_PASSWORD + +docker compose up -d --build +``` + +L'application est disponible sur (port configurable via `WEB_PORT`). + +## Comptes + +Les comptes de démonstration sont créés **verrouillés** (aucun mot de passe en dur +dans le dépôt). Au démarrage, l'API leur applique le mot de passe des variables +d'environnement — uniquement s'ils n'en ont pas déjà un : + +| Rôle | Email | Mot de passe initial | +|------|-------|----------------------| +| Admin | `admin@sluc-businessclub.fr` | `ADMIN_INITIAL_PASSWORD` | +| Membre (×12) | email de contact de chaque entreprise de démo, ex. `contact@lorraine-assurances.fr` | `MEMBER_INITIAL_PASSWORD` | + +Changez le mot de passe admin après la première connexion (Espace membre/admin → +formulaire « Mot de passe », endpoint `POST /api/auth/change-password`). + +## Fonctionnalités + +**Site public** +- Accueil : héro éditorial (citation et photo administrables), carrousel des membres, + prochaines rencontres avec inscription en ligne (contrôle de capacité et de doublon), + rencontres passées, formulaire de demande d'adhésion. +- Annuaire : recherche plein texte, filtres par catégorie, fiche détaillée par entreprise. + Seuls les membres **validés pour la saison** apparaissent. +- L'association : mission, valeurs, mot du président, chronologie, chiffres clés. + +**Espace membre** (connexion email + mot de passe) +- Édition de sa fiche annuaire avec aperçu en direct. +- Upload du logo et de la photo dirigeant·e (JPEG/PNG/WebP, 2 Mo max, vérification + des octets magiques côté serveur). +- Bannière d'état d'adhésion (validée / à renouveler) et changement de mot de passe. + +**Espace admin** (rôle `admin`) +- Tableau de bord : indicateurs temps réel, dernières inscriptions, prochaines rencontres. +- Membres : création, édition, validation/suspension par saison (1er sept. → 31 août). +- Rencontres : création, édition, suppression ; liste des inscrits avec impression + et export Excel. +- Inscriptions : modification, confirmation, annulation avec confirmation. +- Catégories : ajout, renommage, suppression (les entreprises deviennent « Non classée »). +- Contenu du site : citation, signature et photo de la page d'accueil. + +## Sécurité + +- **Injection SQL** : 100 % de requêtes paramétrées (`pg`), aucun SQL concaténé. +- **Authentification** : bcrypt (coût 12), comparaison à temps constant même si + l'email est inconnu, JWT HS256 signé (secret ≥ 32 caractères exigé au démarrage), + session de 12 h. +- **Cookies** : `httpOnly`, `SameSite=Strict`, `Secure` activable (`COOKIE_SECURE=true` + derrière HTTPS). +- **CSRF** : cookie SameSite=Strict + vérification de l'en-tête `Origin` sur toutes + les mutations. +- **Autorisation** : middleware de rôles (`member` / `admin`) sur chaque route protégée ; + un membre ne peut modifier que sa propre fiche. +- **Validation** : schémas `zod` sur toutes les entrées (types, longueurs, formats), + contraintes `CHECK` en base en seconde ligne. +- **Rate limiting** : global (300/min), connexion (10 / 15 min), formulaires publics + (20 / h). +- **Uploads** : taille ≤ 2 Mo, type vérifié par octets magiques (jamais le MIME client), + nom de fichier aléatoire généré côté serveur (aucune traversée de chemin possible), + servis par nginx avec `X-Content-Type-Options: nosniff` et types MIME forcés. +- **En-têtes** : CSP stricte, `X-Frame-Options: DENY`, `Referrer-Policy`, + `Permissions-Policy`, `helmet` côté API, `server_tokens off`. +- **Conteneurs** : API en utilisateur non-root avec système de fichiers en lecture + seule (`read_only` + tmpfs), nginx non privilégié, `no-new-privileges`, PostgreSQL + sans port publié sur un réseau interne (`internal: true`). +- **Base de données** : l'API se connecte avec un rôle dédié `sbc_app` limité au DML + (pas de DDL, pas de superuser). +- **Secrets** : uniquement via `.env` (ignoré par git) ; `docker compose` refuse de + démarrer sans eux ; aucun hash ni mot de passe committé. +- **Erreurs** : les détails restent dans les logs serveur, les clients reçoivent un + message générique. + +### Pour la production + +- Placez l'application derrière HTTPS (reverse-proxy TLS) et passez `COOKIE_SECURE=true`. +- Changez immédiatement les mots de passe initiaux. +- Sauvegardez les volumes `db_data` (base) et `uploads` (images). + +## Développement local (sans Docker) + +```bash +# Terminal 1 — base de données PostgreSQL locale + variables d'env, puis : +cd server && npm install && npm start +# Terminal 2 +cd web && npm install && npm run dev # proxy /api → localhost:3000 +``` + +## Structure + +``` +├── docker-compose.yml +├── .env.example +├── db/init/ # 01 rôle applicatif · 02 schéma · 03 données de démo +├── server/ # API Express (src/routes, src/middleware, uploads) +└── web/ # React + Vite, nginx.conf, Dockerfile multi-étages +``` diff --git a/db/init/01-app-role.sh b/db/init/01-app-role.sh new file mode 100644 index 0000000..1d58766 --- /dev/null +++ b/db/init/01-app-role.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Creates the restricted application role the API connects with. +# Runs once, on first initialization of the data volume. +set -euo pipefail + +# Escape single quotes so an exotic password cannot break the SQL literal +ESCAPED_PASSWORD="${APP_DB_PASSWORD//\'/\'\'}" + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE ROLE sbc_app LOGIN PASSWORD '${ESCAPED_PASSWORD}' NOSUPERUSER NOCREATEDB NOCREATEROLE; +EOSQL diff --git a/db/init/02-schema.sql b/db/init/02-schema.sql new file mode 100644 index 0000000..1a378fb --- /dev/null +++ b/db/init/02-schema.sql @@ -0,0 +1,87 @@ +-- SLUC Business Club — schema +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE categories ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE CHECK (char_length(name) BETWEEN 1 AND 80) +); + +CREATE TABLE members ( + id SERIAL PRIMARY KEY, + nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120), + secteur TEXT NOT NULL DEFAULT '' CHECK (char_length(secteur) <= 120), + categorie_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + dirigeant TEXT NOT NULL DEFAULT '' CHECK (char_length(dirigeant) <= 120), + adhesion SMALLINT, + email CITEXT CHECK (char_length(email) <= 254), + tel TEXT CHECK (char_length(tel) <= 30), + site TEXT CHECK (char_length(site) <= 200), + presentation TEXT NOT NULL DEFAULT '' CHECK (char_length(presentation) <= 2000), + valide BOOLEAN NOT NULL DEFAULT false, + logo_path TEXT, + photo_path TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254), + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('member', 'admin')), + member_id INTEGER UNIQUE REFERENCES members(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE rencontres ( + id SERIAL PRIMARY KEY, + titre TEXT NOT NULL CHECK (char_length(titre) BETWEEN 1 AND 200), + date_renc DATE NOT NULL, + heure TEXT NOT NULL DEFAULT '' CHECK (char_length(heure) <= 20), + lieu TEXT NOT NULL DEFAULT '' CHECK (char_length(lieu) <= 200), + description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000), + places INTEGER NOT NULL CHECK (places >= 0 AND places <= 100000), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE inscriptions ( + id SERIAL PRIMARY KEY, + rencontre_id INTEGER NOT NULL REFERENCES rencontres(id) ON DELETE CASCADE, + nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120), + entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120), + email CITEXT CHECK (char_length(email) <= 254), + tel TEXT CHECK (char_length(tel) <= 30), + statut TEXT NOT NULL DEFAULT 'en_attente' CHECK (statut IN ('confirmee', 'en_attente')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_inscriptions_rencontre ON inscriptions(rencontre_id); + +CREATE TABLE rencontres_passees ( + id SERIAL PRIMARY KEY, + date_label TEXT NOT NULL, + lieu TEXT NOT NULL, + titre TEXT NOT NULL, + texte TEXT NOT NULL, + participants INTEGER NOT NULL DEFAULT 0, + nb_photos INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE demandes_adhesion ( + id SERIAL PRIMARY KEY, + nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120), + fonction TEXT NOT NULL DEFAULT '' CHECK (char_length(fonction) <= 120), + entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120), + email CITEXT NOT NULL CHECK (char_length(email) <= 254), + statut TEXT NOT NULL DEFAULT 'nouvelle' CHECK (statut IN ('nouvelle', 'traitee')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE site_content ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' +); + +-- Least-privilege grants for the application role (no DDL, no other schemas) +GRANT USAGE ON SCHEMA public TO sbc_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sbc_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO sbc_app; diff --git a/db/init/03-seed.sql b/db/init/03-seed.sql new file mode 100644 index 0000000..a2b577a --- /dev/null +++ b/db/init/03-seed.sql @@ -0,0 +1,68 @@ +-- SLUC Business Club — demo seed data (content from the design mock) +-- User passwords are NOT stored here: accounts are created with the +-- unusable placeholder hash '*seed*' and receive their real (bcrypt) +-- password from the API at startup, based on ADMIN_INITIAL_PASSWORD / +-- MEMBER_INITIAL_PASSWORD environment variables. + +INSERT INTO categories (name) VALUES + ('Conseil & Finance'), + ('Industrie & BTP'), + ('Communication & Digital'), + ('Services'), + ('Santé'); + +INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, presentation, valide) VALUES + ('Lorraine Assurances', 'Assurance & Prévoyance', 1, 'Sophie Marchand', 2014, 'contact@lorraine-assurances.fr', '+33 3 83 12 45 60', 'lorraine-assurances.fr', 'Cabinet de courtage indépendant accompagnant les entreprises et dirigeants du Grand Est sur l''ensemble de leurs besoins en assurance et prévoyance.', true), + ('Groupe Batigest', 'BTP & Construction', 2, 'Philippe Aubry', 2009, 'contact@batigest.fr', '+33 3 83 22 18 04', 'groupe-batigest.fr', 'Entreprise générale de construction et de rénovation, acteur majeur du bâtiment en Lorraine depuis plus de trente ans.', true), + ('Moselle Digital', 'Agence digitale', 3, 'Karim Benali', 2019, 'hello@moselle-digital.fr', '+33 3 83 55 71 22', 'moselle-digital.fr', 'Agence de communication digitale spécialisée dans la création de sites, le référencement et les stratégies social media pour les PME.', true), + ('Renard & Associés', 'Cabinet d''avocats', 1, 'Me Claire Renard', 2012, 'contact@renard-avocats.fr', '+33 3 83 30 09 15', 'renard-avocats.fr', 'Cabinet d''avocats d''affaires conseillant dirigeants et entreprises en droit des sociétés, droit social et droit commercial.', true), + ('NancyTech Solutions', 'Solutions informatiques', 3, 'Thomas Villeret', 2020, 'contact@nancytech.fr', '+33 3 83 41 88 90', 'nancytech.fr', 'Infogérance, cybersécurité et déploiement d''outils métiers pour accompagner la transformation numérique des entreprises régionales.', true), + ('Est Immobilier', 'Immobilier d''entreprise', 4, 'Nathalie Perrin', 2016, 'contact@est-immobilier.fr', '+33 3 83 17 62 30', 'est-immobilier.fr', 'Conseil en immobilier d''entreprise : bureaux, locaux commerciaux et investissement sur l''agglomération nancéienne.', true), + ('Vosges Logistique', 'Transport & Logistique', 4, 'Bruno Kieffer', 2011, 'contact@vosges-logistique.fr', '+33 3 29 34 12 78', 'vosges-logistique.fr', 'Solutions de transport, entreposage et distribution au service des industriels et distributeurs du Grand Est.', false), + ('Meurthe Industries', 'Industrie & Métallurgie', 2, 'Laurent Schmitt', 2008, 'contact@meurthe-industries.fr', '+33 3 83 49 05 11', 'meurthe-industries.fr', 'Sous-traitance industrielle de précision et travail des métaux pour les secteurs de l''automobile et de l''énergie.', true), + ('Grand Est Finance', 'Conseil financier', 1, 'Isabelle Fontaine', 2017, 'contact@grandest-finance.fr', '+33 3 83 25 44 90', 'grandest-finance.fr', 'Conseil en gestion de patrimoine et financement d''entreprise, aux côtés des dirigeants dans leurs projets de croissance.', true), + ('Atelier Lumière', 'Communication & Design', 3, 'Julie Mercier', 2021, 'bonjour@atelier-lumiere.fr', '+33 3 83 60 77 08', 'atelier-lumiere.fr', 'Studio de création graphique et d''identité de marque, du logo à la signalétique, pour donner du sens à votre image.', true), + ('Pharma Lorraine', 'Laboratoire pharmaceutique', 5, 'Dr Antoine Rousseau', 2013, 'contact@pharma-lorraine.fr', '+33 3 83 90 21 47', 'pharma-lorraine.fr', 'Laboratoire de production et de distribution de solutions de santé, engagé dans l''innovation et la qualité.', false), + ('Cristal Événements', 'Événementiel', 4, 'Émilie Colin', 2018, 'contact@cristal-events.fr', '+33 3 83 38 66 12', 'cristal-events.fr', 'Agence événementielle imaginant séminaires, soirées d''entreprise et événements sur-mesure clés en main.', true); + +INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places) VALUES + ('Afterwork Business & Basket', '2026-09-17', '18h30', 'Palais des Sports J. Weille', 'Networking en tribune VIP autour d''un cocktail, suivi du match SLUC Nancy – Cholet. Le rendez-vous incontournable de la rentrée.', 40), + ('Petit-déjeuner des Dirigeants', '2026-10-06', '08h00', 'Hôtel Mercure Nancy Centre', 'Échanges entre dirigeants autour d''un intervenant invité, dans un format intimiste propice aux affaires.', 25), + ('Conférence — L''esprit d''équipe', '2026-11-19', '19h00', 'Grand Salon, Hôtel de Ville', 'Un coach de haut niveau partage les ressorts de la performance collective, du terrain à l''entreprise.', 80); + +INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut) VALUES + (1, 'Sophie Marchand', 'Lorraine Assurances', 's.marchand@lorraine-assurances.fr', '+33 3 83 12 45 60', 'confirmee'), + (1, 'Karim Benali', 'Moselle Digital', 'k.benali@moselle-digital.fr', '+33 3 83 55 71 22', 'en_attente'), + (2, 'Thomas Villeret', 'NancyTech Solutions', 't.villeret@nancytech.fr', '+33 3 83 41 88 90', 'confirmee'), + (3, 'Nathalie Perrin', 'Est Immobilier', 'n.perrin@est-immobilier.fr', '+33 3 83 17 62 30', 'en_attente'), + (2, 'Bruno Kieffer', 'Vosges Logistique', 'b.kieffer@vosges-logistique.fr', '+33 3 29 34 12 78', 'confirmee'), + (1, 'Julie Mercier', 'Atelier Lumière', 'j.mercier@atelier-lumiere.fr', '+33 3 83 60 77 08', 'en_attente'), + (1, 'Laurent Schmitt', 'Meurthe Industries', 'l.schmitt@meurthe-industries.fr', '+33 3 83 49 05 11', 'confirmee'), + (3, 'Isabelle Fontaine', 'Grand Est Finance', 'i.fontaine@grandest-finance.fr', '+33 3 83 25 44 90', 'confirmee'), + (2, 'Me Claire Renard', 'Renard & Associés', 'c.renard@renard-avocats.fr', '+33 3 83 30 09 15', 'en_attente'), + (1, 'Émilie Colin', 'Cristal Événements', 'e.colin@cristal-events.fr', '+33 3 83 38 66 12', 'confirmee'), + (3, 'Philippe Aubry', 'Groupe Batigest', 'p.aubry@batigest.fr', '+33 3 83 22 18 04', 'confirmee'); + +INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants, nb_photos) VALUES + ('Juin 2026', 'Château de Rémicourt', 'Soirée de Gala annuelle', 'Plus de 200 convives réunis pour célébrer une saison d''exception. Une soirée d''élégance mêlant remise de trophées, dîner gastronomique et rencontres privilégiées entre membres du Club.', 210, 48), + ('Avril 2026', 'Site industriel, Florange', 'Visite privée — Usine ArcelorMittal', 'Immersion industrielle exclusive pour nos membres au cœur d''un fleuron de la métallurgie lorraine, suivie d''un échange avec la direction du site.', 34, 22), + ('Février 2026', 'La Filature, Nancy', 'Table ronde — Digitaliser sa PME', 'Retours d''expérience et bonnes pratiques : trois dirigeants membres ont partagé leur parcours de transformation numérique devant une salle comble.', 56, 15); + +INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES + ('Marc Dubois', 'Directeur général', 'Dubois Traiteur', 'm.dubois@dubois-traiteur.fr'), + ('Léa Hoffmann', 'Présidente', 'Hoffmann Conseil', 'l.hoffmann@hoffmann-conseil.fr'), + ('Julien Weber', 'Gérant', 'Weber Menuiserie', 'j.weber@weber-menuiserie.fr'); + +INSERT INTO site_content (key, value) VALUES + ('hero_quote_text', 'On ne réussit jamais seul.'), + ('hero_quote_author', 'L''esprit du Club'), + ('hero_photo', ''); + +-- Accounts: one admin + one portal account per member company. +-- '*seed*' is an intentionally unusable hash; real passwords are applied +-- by the API at startup from environment variables. +INSERT INTO users (email, password_hash, role, member_id) +SELECT email, '*seed*', 'member', id FROM members WHERE email IS NOT NULL; + +INSERT INTO users (email, password_hash, role) VALUES + ('admin@sluc-businessclub.fr', '*seed*', 'admin'); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4c3fcc6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,77 @@ +name: sluc-business-club + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: sbc + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set APP_DB_PASSWORD in .env} + volumes: + - db_data:/var/lib/postgresql/data + - ./db/init:/docker-entrypoint-initdb.d:ro + networks: + - backend + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d sbc"] + interval: 5s + timeout: 3s + retries: 12 + # No published ports: the database is reachable only from the backend network. + + api: + build: ./server + restart: unless-stopped + environment: + NODE_ENV: production + PORT: "3000" + DATABASE_URL: postgres://sbc_app:${APP_DB_PASSWORD}@db:5432/sbc + JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env} + ADMIN_INITIAL_PASSWORD: ${ADMIN_INITIAL_PASSWORD:-} + MEMBER_INITIAL_PASSWORD: ${MEMBER_INITIAL_PASSWORD:-} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + UPLOAD_DIR: /data/uploads + volumes: + - uploads:/data/uploads + depends_on: + db: + condition: service_healthy + networks: + - backend + - frontend + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + retries: 6 + + web: + build: ./web + restart: unless-stopped + ports: + - "${WEB_PORT:-8080}:8080" + volumes: + - uploads:/var/www/uploads:ro + depends_on: + - api + networks: + - frontend + security_opt: + - no-new-privileges:true + +networks: + backend: + # internal: the db network never routes to the outside world + internal: true + frontend: + +volumes: + db_data: + uploads: diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..eb03e3e --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,2 @@ +node_modules +*.log diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..368f4d5 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev + +FROM node:22-alpine +ENV NODE_ENV=production +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY package.json ./ +COPY src ./src +# Upload dir owned by the app user; a named volume mounted here inherits +# this ownership on first use +RUN mkdir -p /data/uploads && chown node:node /data/uploads +# Run as the unprivileged built-in user +USER node +EXPOSE 3000 +CMD ["node", "src/index.js"] diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..a778cd5 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1313 @@ +{ + "name": "sbc-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sbc-api", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "pg": "^8.12.0", + "zod": "^3.23.8" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..577549c --- /dev/null +++ b/server/package.json @@ -0,0 +1,21 @@ +{ + "name": "sbc-api", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "pg": "^8.12.0", + "zod": "^3.23.8" + } +} diff --git a/server/src/bootstrap.js b/server/src/bootstrap.js new file mode 100644 index 0000000..d94aa6f --- /dev/null +++ b/server/src/bootstrap.js @@ -0,0 +1,32 @@ +import bcrypt from 'bcryptjs'; +import { query } from './db.js'; +import { config } from './config.js'; + +// Seeded accounts carry the unusable placeholder hash '*seed*'. +// On startup we give them a real bcrypt hash from environment variables — +// but never overwrite a password that has already been changed. +export async function applyInitialPasswords() { + if (config.adminInitialPassword) { + const hash = await bcrypt.hash(config.adminInitialPassword, 12); + const r = await query( + `UPDATE users SET password_hash = $1 WHERE role = 'admin' AND password_hash = '*seed*'`, + [hash] + ); + if (r.rowCount > 0) console.log(`Initialized password for ${r.rowCount} admin account(s).`); + } + if (config.memberInitialPassword) { + const hash = await bcrypt.hash(config.memberInitialPassword, 12); + const r = await query( + `UPDATE users SET password_hash = $1 WHERE role = 'member' AND password_hash = '*seed*'`, + [hash] + ); + if (r.rowCount > 0) console.log(`Initialized password for ${r.rowCount} member account(s).`); + } + const locked = await query(`SELECT COUNT(*)::int AS n FROM users WHERE password_hash = '*seed*'`); + if (locked.rows[0].n > 0) { + console.warn( + `${locked.rows[0].n} account(s) still locked (no initial password provided). ` + + 'Set ADMIN_INITIAL_PASSWORD / MEMBER_INITIAL_PASSWORD to activate them.' + ); + } +} diff --git a/server/src/config.js b/server/src/config.js new file mode 100644 index 0000000..4b62821 --- /dev/null +++ b/server/src/config.js @@ -0,0 +1,25 @@ +const required = (name) => { + const v = process.env[name]; + if (!v) { + console.error(`Missing required environment variable: ${name}`); + process.exit(1); + } + return v; +}; + +export const config = { + port: Number(process.env.PORT || 3000), + databaseUrl: required('DATABASE_URL'), + jwtSecret: required('JWT_SECRET'), + cookieSecure: process.env.COOKIE_SECURE === 'true', + uploadDir: process.env.UPLOAD_DIR || '/data/uploads', + adminInitialPassword: process.env.ADMIN_INITIAL_PASSWORD || '', + memberInitialPassword: process.env.MEMBER_INITIAL_PASSWORD || '', + sessionTtlSeconds: 12 * 60 * 60, + cookieName: 'sbc_session', +}; + +if (config.jwtSecret.length < 32) { + console.error('JWT_SECRET must be at least 32 characters long.'); + process.exit(1); +} diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..bfae3cc --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,23 @@ +import pg from 'pg'; +import { config } from './config.js'; + +export const pool = new pg.Pool({ + connectionString: config.databaseUrl, + max: 10, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, +}); + +export const query = (text, params) => pool.query(text, params); + +export async function waitForDb(retries = 30, delayMs = 1000) { + for (let i = 1; i <= retries; i++) { + try { + await pool.query('SELECT 1'); + return; + } catch (err) { + if (i === retries) throw err; + await new Promise((r) => setTimeout(r, delayMs)); + } + } +} diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..5c5b2c8 --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,53 @@ +import express from 'express'; +import helmet from 'helmet'; +import cookieParser from 'cookie-parser'; +import { config } from './config.js'; +import { waitForDb } from './db.js'; +import { applyInitialPasswords } from './bootstrap.js'; +import { attachUser } from './middleware/auth.js'; +import { csrfOriginCheck, globalLimiter } from './middleware/security.js'; +import { publicRouter } from './routes/public.js'; +import { authRouter } from './routes/auth.js'; +import { memberRouter } from './routes/member.js'; +import { adminRouter } from './routes/admin.js'; + +const app = express(); + +app.disable('x-powered-by'); +app.set('trust proxy', 1); // behind nginx +app.use(helmet()); +app.use(express.json({ limit: '64kb' })); +app.use(cookieParser()); +app.use(globalLimiter); +app.use(csrfOriginCheck); +app.use(attachUser); + +app.get('/api/health', (_req, res) => res.json({ ok: true })); +app.use('/api/public', publicRouter); +app.use('/api/auth', authRouter); +app.use('/api/member', memberRouter); +app.use('/api/admin', adminRouter); + +app.use((_req, res) => res.status(404).json({ error: 'Introuvable' })); + +// Central error handler: log details server-side, never leak them to clients +app.use((err, _req, res, _next) => { + if (err.type === 'entity.too.large' || err.type === 'entity.parse.failed') { + return res.status(400).json({ error: 'Requête invalide' }); + } + if (err.code === '23503') return res.status(400).json({ error: 'Référence invalide' }); + if (err.code === '23505') return res.status(409).json({ error: 'Cette valeur existe déjà.' }); + console.error(err); + res.status(500).json({ error: 'Erreur interne du serveur' }); +}); + +try { + await waitForDb(); + await applyInitialPasswords(); + app.listen(config.port, () => { + console.log(`SBC API listening on :${config.port}`); + }); +} catch (err) { + console.error('Startup failed:', err); + process.exit(1); +} diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js new file mode 100644 index 0000000..bea8cec --- /dev/null +++ b/server/src/middleware/auth.js @@ -0,0 +1,40 @@ +import jwt from 'jsonwebtoken'; +import { config } from '../config.js'; + +export function issueSession(res, payload) { + const token = jwt.sign(payload, config.jwtSecret, { + expiresIn: config.sessionTtlSeconds, + algorithm: 'HS256', + }); + res.cookie(config.cookieName, token, { + httpOnly: true, + sameSite: 'strict', + secure: config.cookieSecure, + maxAge: config.sessionTtlSeconds * 1000, + path: '/', + }); +} + +export function clearSession(res) { + res.clearCookie(config.cookieName, { path: '/' }); +} + +export function attachUser(req, _res, next) { + const token = req.cookies?.[config.cookieName]; + if (token) { + try { + req.user = jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] }); + } catch { + req.user = null; + } + } + next(); +} + +export function requireAuth(role) { + return (req, res, next) => { + if (!req.user) return res.status(401).json({ error: 'Authentification requise' }); + if (role && req.user.role !== role) return res.status(403).json({ error: 'Accès refusé' }); + next(); + }; +} diff --git a/server/src/middleware/security.js b/server/src/middleware/security.js new file mode 100644 index 0000000..5f13e96 --- /dev/null +++ b/server/src/middleware/security.js @@ -0,0 +1,45 @@ +import rateLimit from 'express-rate-limit'; + +// CSRF defense-in-depth: session cookie is SameSite=Strict, and every +// state-changing request must additionally come from our own origin. +export function csrfOriginCheck(req, res, next) { + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); + const origin = req.headers.origin || ''; + const host = req.headers['x-forwarded-host'] || req.headers.host || ''; + if (origin) { + let originHost; + try { + originHost = new URL(origin).host; + } catch { + return res.status(403).json({ error: 'Origine invalide' }); + } + if (originHost !== host) { + return res.status(403).json({ error: 'Origine non autorisée' }); + } + } + next(); +} + +export const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Trop de tentatives de connexion. Réessayez dans 15 minutes.' }, +}); + +export const publicFormLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 20, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Trop de requêtes. Réessayez plus tard.' }, +}); + +export const globalLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 300, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Trop de requêtes.' }, +}); diff --git a/server/src/middleware/validate.js b/server/src/middleware/validate.js new file mode 100644 index 0000000..c0023e7 --- /dev/null +++ b/server/src/middleware/validate.js @@ -0,0 +1,13 @@ +export function validate(schema, source = 'body') { + return (req, res, next) => { + const result = schema.safeParse(req[source]); + if (!result.success) { + const first = result.error.issues[0]; + return res.status(400).json({ + error: `Donnée invalide : ${first.path.join('.')} — ${first.message}`, + }); + } + req[source === 'body' ? 'data' : 'params'] = result.data; + next(); + }; +} diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js new file mode 100644 index 0000000..77c67d8 --- /dev/null +++ b/server/src/routes/admin.js @@ -0,0 +1,312 @@ +import { Router } from 'express'; +import { query } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; +import { + adminMemberSchema, + rencontreSchema, + inscriptionAdminSchema, + categorySchema, + contentSchema, + idParam, +} from '../schemas.js'; +import { imageUpload, saveImage, deleteImage } from '../uploads.js'; + +export const adminRouter = Router(); + +adminRouter.use(requireAuth('admin')); + +const MEMBER_SQL = ` + SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant, + m.adhesion, m.email, m.tel, m.site, m.presentation, m.valide, + m.logo_path, m.photo_path + FROM members m LEFT JOIN categories c ON c.id = m.categorie_id`; + +const RENC_SQL = ` + SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places, + COUNT(i.id)::int AS inscrits + FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id`; + +const INSCR_SQL = ` + SELECT i.id, i.nom, i.entreprise, i.email, i.tel, i.statut, i.created_at, + i.rencontre_id, r.titre AS rencontre + FROM inscriptions i JOIN rencontres r ON r.id = i.rencontre_id`; + +// ---------- Dashboard ---------- +adminRouter.get('/dashboard', async (_req, res, next) => { + try { + const [kpis, latest, upcoming] = await Promise.all([ + query(` + SELECT + (SELECT COUNT(*)::int FROM members WHERE valide) AS membres_valides, + (SELECT COUNT(*)::int FROM members WHERE NOT valide) AS a_renouveler, + (SELECT COUNT(*)::int FROM rencontres WHERE date_renc >= CURRENT_DATE) AS rencontres_a_venir, + (SELECT COUNT(*)::int FROM inscriptions WHERE statut = 'en_attente') AS inscriptions_attente, + (SELECT COUNT(*)::int FROM demandes_adhesion WHERE statut = 'nouvelle') AS demandes_nouvelles, + (SELECT MIN(date_renc) FROM rencontres WHERE date_renc >= CURRENT_DATE) AS prochaine_date + `), + query(`${INSCR_SQL} ORDER BY i.created_at DESC, i.id DESC LIMIT 5`), + query(`${RENC_SQL} WHERE r.date_renc >= CURRENT_DATE GROUP BY r.id ORDER BY r.date_renc LIMIT 5`), + ]); + res.json({ kpis: kpis.rows[0], latestInscriptions: latest.rows, upcoming: upcoming.rows }); + } catch (err) { + next(err); + } +}); + +// ---------- Members ---------- +adminRouter.get('/members', async (_req, res, next) => { + try { + const result = await query(`${MEMBER_SQL} ORDER BY m.nom`); + res.json({ members: result.rows }); + } catch (err) { + next(err); + } +}); + +adminRouter.post('/members', validate(adminMemberSchema), async (req, res, next) => { + try { + const d = req.data; + const result = await query( + `INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, presentation, valide) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + [d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, new Date().getFullYear(), + d.email || null, d.tel, d.site, d.presentation, d.valide ?? true] + ); + res.status(201).json({ id: result.rows[0].id }); + } catch (err) { + next(err); + } +}); + +adminRouter.put('/members/:id', validate(idParam, 'params'), validate(adminMemberSchema), async (req, res, next) => { + try { + const d = req.data; + const result = await query( + `UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5, + tel=$6, site=$7, presentation=$8, valide=COALESCE($9, valide), updated_at=now() + WHERE id=$10 RETURNING id`, + [d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, d.email || null, + d.tel, d.site, d.presentation, d.valide ?? null, req.params.id] + ); + if (result.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +adminRouter.post('/members/:id/toggle-valide', validate(idParam, 'params'), async (req, res, next) => { + try { + const result = await query( + 'UPDATE members SET valide = NOT valide, updated_at = now() WHERE id = $1 RETURNING valide', + [req.params.id] + ); + if (result.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' }); + res.json({ valide: result.rows[0].valide }); + } catch (err) { + next(err); + } +}); + +// ---------- Rencontres ---------- +adminRouter.get('/rencontres', async (_req, res, next) => { + try { + const result = await query(`${RENC_SQL} GROUP BY r.id ORDER BY r.date_renc`); + res.json({ rencontres: result.rows }); + } catch (err) { + next(err); + } +}); + +adminRouter.post('/rencontres', validate(rencontreSchema), async (req, res, next) => { + try { + const d = req.data; + const result = await query( + `INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + [d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places] + ); + res.status(201).json({ id: result.rows[0].id }); + } catch (err) { + next(err); + } +}); + +adminRouter.put('/rencontres/:id', validate(idParam, 'params'), validate(rencontreSchema), async (req, res, next) => { + try { + const d = req.data; + const result = await query( + `UPDATE rencontres SET titre=$1, date_renc=$2, heure=$3, lieu=$4, description=$5, places=$6 + WHERE id=$7 RETURNING id`, + [d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places, req.params.id] + ); + if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +adminRouter.delete('/rencontres/:id', validate(idParam, 'params'), async (req, res, next) => { + try { + await query('DELETE FROM rencontres WHERE id = $1', [req.params.id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), async (req, res, next) => { + try { + const result = await query(`${INSCR_SQL} WHERE i.rencontre_id = $1 ORDER BY i.nom`, [req.params.id]); + res.json({ inscriptions: result.rows }); + } catch (err) { + next(err); + } +}); + +// ---------- Inscriptions ---------- +adminRouter.get('/inscriptions', async (_req, res, next) => { + try { + const result = await query(`${INSCR_SQL} ORDER BY i.created_at DESC, i.id DESC`); + res.json({ inscriptions: result.rows }); + } catch (err) { + next(err); + } +}); + +adminRouter.put('/inscriptions/:id', validate(idParam, 'params'), validate(inscriptionAdminSchema), async (req, res, next) => { + try { + const d = req.data; + const result = await query( + `UPDATE inscriptions SET nom=$1, entreprise=$2, email=$3, tel=$4, rencontre_id=$5, statut=$6 + WHERE id=$7 RETURNING id`, + [d.nom, d.entreprise, d.email || null, d.tel, d.rencontre_id, d.statut, req.params.id] + ); + if (result.rowCount === 0) return res.status(404).json({ error: 'Inscription introuvable' }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +adminRouter.post('/inscriptions/:id/confirm', validate(idParam, 'params'), async (req, res, next) => { + try { + const result = await query( + `UPDATE inscriptions SET statut = 'confirmee' WHERE id = $1 RETURNING id`, + [req.params.id] + ); + if (result.rowCount === 0) return res.status(404).json({ error: 'Inscription introuvable' }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +adminRouter.delete('/inscriptions/:id', validate(idParam, 'params'), async (req, res, next) => { + try { + await query('DELETE FROM inscriptions WHERE id = $1', [req.params.id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +// ---------- Categories ---------- +adminRouter.get('/categories', async (_req, res, next) => { + try { + const result = await query(` + SELECT c.id, c.name, COUNT(m.id)::int AS count + FROM categories c LEFT JOIN members m ON m.categorie_id = c.id + GROUP BY c.id ORDER BY c.id`); + res.json({ categories: result.rows }); + } catch (err) { + next(err); + } +}); + +adminRouter.post('/categories', validate(categorySchema), async (req, res, next) => { + try { + const result = await query( + 'INSERT INTO categories (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id', + [req.data.name] + ); + if (result.rowCount === 0) return res.status(409).json({ error: 'Cette catégorie existe déjà.' }); + res.status(201).json({ id: result.rows[0].id }); + } catch (err) { + next(err); + } +}); + +adminRouter.put('/categories/:id', validate(idParam, 'params'), validate(categorySchema), async (req, res, next) => { + try { + const result = await query('UPDATE categories SET name = $1 WHERE id = $2 RETURNING id', [ + req.data.name, + req.params.id, + ]); + if (result.rowCount === 0) return res.status(404).json({ error: 'Catégorie introuvable' }); + res.json({ ok: true }); + } catch (err) { + if (err.code === '23505') return res.status(409).json({ error: 'Ce nom existe déjà.' }); + next(err); + } +}); + +adminRouter.delete('/categories/:id', validate(idParam, 'params'), async (req, res, next) => { + try { + // members.categorie_id has ON DELETE SET NULL: they become "Non classée" + await query('DELETE FROM categories WHERE id = $1', [req.params.id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +// ---------- Site content ---------- +adminRouter.put('/content', validate(contentSchema), async (req, res, next) => { + try { + const entries = Object.entries(req.data).filter(([, v]) => v !== undefined); + for (const [key, value] of entries) { + await query( + `INSERT INTO site_content (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [key, value] + ); + } + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +adminRouter.post('/content/hero-photo', (req, res, next) => { + imageUpload(req, res, async (err) => { + if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' }); + try { + if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu.' }); + const publicPath = await saveImage(req.file.buffer); + if (!publicPath) return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' }); + const prev = await query(`SELECT value FROM site_content WHERE key = 'hero_photo'`); + await query( + `INSERT INTO site_content (key, value) VALUES ('hero_photo', $1) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [publicPath] + ); + await deleteImage(prev.rows[0]?.value); + res.json({ path: publicPath }); + } catch (e) { + next(e); + } + }); +}); + +// ---------- Demandes d'adhésion ---------- +adminRouter.get('/demandes', async (_req, res, next) => { + try { + const result = await query('SELECT * FROM demandes_adhesion ORDER BY created_at DESC'); + res.json({ demandes: result.rows }); + } catch (err) { + next(err); + } +}); diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100644 index 0000000..5fa6601 --- /dev/null +++ b/server/src/routes/auth.js @@ -0,0 +1,71 @@ +import { Router } from 'express'; +import bcrypt from 'bcryptjs'; +import { query } from '../db.js'; +import { issueSession, clearSession, requireAuth } from '../middleware/auth.js'; +import { loginLimiter } from '../middleware/security.js'; +import { validate } from '../middleware/validate.js'; +import { loginSchema, changePasswordSchema } from '../schemas.js'; + +export const authRouter = Router(); + +const publicUser = (u) => ({ id: u.id, email: u.email, role: u.role, memberId: u.member_id }); + +authRouter.post('/login', loginLimiter, validate(loginSchema), async (req, res, next) => { + try { + const { email, password } = req.data; + const result = await query( + 'SELECT id, email, password_hash, role, member_id FROM users WHERE email = $1', + [email] + ); + // Always run a bcrypt comparison to keep timing uniform + const user = result.rows[0]; + const hash = user?.password_hash?.startsWith('$2') ? user.password_hash : '$2a$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinva'; + const ok = await bcrypt.compare(password, hash); + if (!user || !ok || !user.password_hash.startsWith('$2')) { + return res.status(401).json({ error: 'Email ou mot de passe incorrect.' }); + } + issueSession(res, { sub: user.id, role: user.role, memberId: user.member_id }); + res.json({ user: publicUser(user) }); + } catch (err) { + next(err); + } +}); + +authRouter.post('/logout', (_req, res) => { + clearSession(res); + res.json({ ok: true }); +}); + +authRouter.get('/me', async (req, res, next) => { + try { + if (!req.user) return res.json({ user: null }); + const result = await query( + 'SELECT id, email, role, member_id FROM users WHERE id = $1', + [req.user.sub] + ); + if (result.rowCount === 0) return res.json({ user: null }); + res.json({ user: publicUser(result.rows[0]) }); + } catch (err) { + next(err); + } +}); + +authRouter.post( + '/change-password', + requireAuth(), + validate(changePasswordSchema), + async (req, res, next) => { + try { + const { currentPassword, newPassword } = req.data; + const result = await query('SELECT password_hash FROM users WHERE id = $1', [req.user.sub]); + if (result.rowCount === 0) return res.status(401).json({ error: 'Session invalide' }); + const ok = await bcrypt.compare(currentPassword, result.rows[0].password_hash); + if (!ok) return res.status(401).json({ error: 'Mot de passe actuel incorrect.' }); + const hash = await bcrypt.hash(newPassword, 12); + await query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, req.user.sub]); + res.json({ ok: true }); + } catch (err) { + next(err); + } + } +); diff --git a/server/src/routes/member.js b/server/src/routes/member.js new file mode 100644 index 0000000..e53cb54 --- /dev/null +++ b/server/src/routes/member.js @@ -0,0 +1,70 @@ +import { Router } from 'express'; +import { query } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; +import { memberProfileSchema } from '../schemas.js'; +import { imageUpload, saveImage, deleteImage } from '../uploads.js'; + +export const memberRouter = Router(); + +memberRouter.use(requireAuth('member')); + +const PROFILE_SQL = ` + SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant, + m.adhesion, m.email, m.tel, m.site, m.presentation, m.valide, + m.logo_path, m.photo_path + FROM members m LEFT JOIN categories c ON c.id = m.categorie_id + WHERE m.id = $1`; + +memberRouter.get('/profile', async (req, res, next) => { + try { + const result = await query(PROFILE_SQL, [req.user.memberId]); + if (result.rowCount === 0) return res.status(404).json({ error: 'Profil introuvable' }); + res.json({ member: result.rows[0] }); + } catch (err) { + next(err); + } +}); + +memberRouter.put('/profile', validate(memberProfileSchema), async (req, res, next) => { + try { + const { nom, secteur, categorie_id, dirigeant, email, tel, site, presentation } = req.data; + await query( + `UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5, + tel=$6, site=$7, presentation=$8, updated_at=now() + WHERE id=$9`, + [nom, secteur, categorie_id ?? null, dirigeant, email || null, tel, site, presentation, req.user.memberId] + ); + const result = await query(PROFILE_SQL, [req.user.memberId]); + res.json({ member: result.rows[0] }); + } catch (err) { + next(err); + } +}); + +function imageRoute(column) { + return (req, res, next) => { + imageUpload(req, res, async (err) => { + if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' }); + try { + if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu.' }); + const publicPath = await saveImage(req.file.buffer); + if (!publicPath) { + return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' }); + } + const prev = await query(`SELECT ${column} FROM members WHERE id = $1`, [req.user.memberId]); + await query(`UPDATE members SET ${column} = $1, updated_at = now() WHERE id = $2`, [ + publicPath, + req.user.memberId, + ]); + await deleteImage(prev.rows[0]?.[column]); + res.json({ path: publicPath }); + } catch (e) { + next(e); + } + }); + }; +} + +memberRouter.post('/profile/logo', imageRoute('logo_path')); +memberRouter.post('/profile/photo', imageRoute('photo_path')); diff --git a/server/src/routes/public.js b/server/src/routes/public.js new file mode 100644 index 0000000..b984931 --- /dev/null +++ b/server/src/routes/public.js @@ -0,0 +1,100 @@ +import { Router } from 'express'; +import { query } from '../db.js'; +import { validate } from '../middleware/validate.js'; +import { publicFormLimiter } from '../middleware/security.js'; +import { demandeSchema, inscriptionPublicSchema, idParam } from '../schemas.js'; + +export const publicRouter = Router(); + +// Single bootstrap payload for the public site +publicRouter.get('/bootstrap', async (_req, res, next) => { + try { + const [content, categories, members, rencontres, passees] = await Promise.all([ + query('SELECT key, value FROM site_content'), + query('SELECT id, name FROM categories ORDER BY id'), + query( + `SELECT m.id, m.nom, m.secteur, m.dirigeant, m.adhesion, m.email, m.tel, m.site, + m.presentation, m.logo_path, m.photo_path, c.name AS categorie + FROM members m LEFT JOIN categories c ON c.id = m.categorie_id + WHERE m.valide = true ORDER BY m.nom` + ), + query( + `SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places, + COUNT(i.id)::int AS inscrits + FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id + WHERE r.date_renc >= CURRENT_DATE + GROUP BY r.id ORDER BY r.date_renc` + ), + query('SELECT * FROM rencontres_passees ORDER BY id'), + ]); + const contentMap = Object.fromEntries(content.rows.map((r) => [r.key, r.value])); + res.json({ + content: contentMap, + categories: categories.rows, + members: members.rows, + rencontres: rencontres.rows, + rencontresPassees: passees.rows, + }); + } catch (err) { + next(err); + } +}); + +// Membership request (home page form) +publicRouter.post( + '/demandes-adhesion', + publicFormLimiter, + validate(demandeSchema), + async (req, res, next) => { + try { + const { nom, fonction, entreprise, email } = req.data; + await query( + 'INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES ($1, $2, $3, $4)', + [nom, fonction, entreprise, email] + ); + res.status(201).json({ ok: true }); + } catch (err) { + next(err); + } + } +); + +// Event registration (public modal) +publicRouter.post( + '/rencontres/:id/inscriptions', + publicFormLimiter, + validate(idParam, 'params'), + validate(inscriptionPublicSchema), + async (req, res, next) => { + try { + const { id } = req.params; + const { nom, entreprise, email, tel } = req.data; + const renc = await query( + `SELECT r.places, COUNT(i.id)::int AS inscrits + FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id + WHERE r.id = $1 AND r.date_renc >= CURRENT_DATE + GROUP BY r.id`, + [id] + ); + if (renc.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' }); + if (renc.rows[0].inscrits >= renc.rows[0].places) { + return res.status(409).json({ error: 'Cette rencontre est complète.' }); + } + const dup = await query( + 'SELECT 1 FROM inscriptions WHERE rencontre_id = $1 AND email = $2', + [id, email] + ); + if (dup.rowCount > 0) { + return res.status(409).json({ error: 'Une inscription existe déjà avec cet email.' }); + } + await query( + `INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut) + VALUES ($1, $2, $3, $4, $5, 'en_attente')`, + [id, nom, entreprise, email, tel] + ); + res.status(201).json({ ok: true }); + } catch (err) { + next(err); + } + } +); diff --git a/server/src/schemas.js b/server/src/schemas.js new file mode 100644 index 0000000..b7daa10 --- /dev/null +++ b/server/src/schemas.js @@ -0,0 +1,74 @@ +import { z } from 'zod'; + +const trimmed = (max, min = 0) => z.string().trim().min(min).max(max); + +export const idParam = z.object({ id: z.coerce.number().int().positive() }); + +export const loginSchema = z.object({ + email: trimmed(254, 3).toLowerCase(), + password: z.string().min(1).max(200), +}); + +export const changePasswordSchema = z.object({ + currentPassword: z.string().min(1).max(200), + newPassword: z + .string() + .min(10, 'au moins 10 caractères') + .max(200) + .regex(/[a-zA-Z]/, 'doit contenir une lettre') + .regex(/[0-9]/, 'doit contenir un chiffre'), +}); + +export const demandeSchema = z.object({ + nom: trimmed(120, 1), + fonction: trimmed(120).optional().default(''), + entreprise: trimmed(120, 1), + email: trimmed(254, 3).email(), +}); + +export const inscriptionPublicSchema = z.object({ + nom: trimmed(120, 1), + entreprise: trimmed(120, 1), + email: trimmed(254, 3).email(), + tel: trimmed(30).optional().default(''), +}); + +export const memberProfileSchema = z.object({ + nom: trimmed(120, 1), + secteur: trimmed(120, 1), + categorie_id: z.coerce.number().int().positive().nullable().optional(), + dirigeant: trimmed(120, 1), + email: trimmed(254).email().or(z.literal('')).optional().default(''), + tel: trimmed(30).optional().default(''), + site: trimmed(200).optional().default(''), + presentation: trimmed(2000).optional().default(''), +}); + +export const adminMemberSchema = memberProfileSchema.extend({ + valide: z.boolean().optional(), +}); + +export const rencontreSchema = z.object({ + titre: trimmed(200, 1), + date_renc: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'format AAAA-MM-JJ'), + heure: trimmed(20).optional().default(''), + lieu: trimmed(200).optional().default(''), + description: trimmed(2000).optional().default(''), + places: z.coerce.number().int().min(0).max(100000), +}); + +export const inscriptionAdminSchema = z.object({ + nom: trimmed(120, 1), + entreprise: trimmed(120, 1), + email: trimmed(254).email().or(z.literal('')).optional().default(''), + tel: trimmed(30).optional().default(''), + rencontre_id: z.coerce.number().int().positive(), + statut: z.enum(['confirmee', 'en_attente']), +}); + +export const categorySchema = z.object({ name: trimmed(80, 1) }); + +export const contentSchema = z.object({ + hero_quote_text: trimmed(300).optional(), + hero_quote_author: trimmed(120).optional(), +}); diff --git a/server/src/uploads.js b/server/src/uploads.js new file mode 100644 index 0000000..2e8ad40 --- /dev/null +++ b/server/src/uploads.js @@ -0,0 +1,49 @@ +import multer from 'multer'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { config } from './config.js'; + +const MAX_SIZE = 2 * 1024 * 1024; // 2 MB + +// magic-byte sniffing: never trust the client-provided MIME type +const SIGNATURES = [ + { ext: 'jpg', mime: 'image/jpeg', check: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff }, + { ext: 'png', mime: 'image/png', check: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 }, + { + ext: 'webp', + mime: 'image/webp', + check: (b) => + b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && + b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50, + }, +]; + +export const imageUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_SIZE, files: 1 }, +}).single('file'); + +export function detectImageType(buffer) { + if (!buffer || buffer.length < 12) return null; + return SIGNATURES.find((s) => s.check(buffer)) || null; +} + +// Saves a validated image with a random, non-guessable filename and +// returns the public path. The filename is fully server-generated: no +// user input ever reaches the filesystem path. +export async function saveImage(buffer) { + const type = detectImageType(buffer); + if (!type) return null; + const name = `${crypto.randomBytes(16).toString('hex')}.${type.ext}`; + await fs.mkdir(config.uploadDir, { recursive: true }); + await fs.writeFile(path.join(config.uploadDir, name), buffer, { flag: 'wx' }); + return `/uploads/${name}`; +} + +export async function deleteImage(publicPath) { + if (!publicPath || !publicPath.startsWith('/uploads/')) return; + const name = path.basename(publicPath); + // basename() strips any traversal; only delete inside the upload dir + await fs.rm(path.join(config.uploadDir, name), { force: true }); +} diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..d4d7e3f --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +*.log diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..d09f914 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci 2>/dev/null || npm install +COPY index.html vite.config.js ./ +COPY public ./public +COPY src ./src +RUN npm run build + +FROM nginxinc/nginx-unprivileged:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 8080 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0e95a47 --- /dev/null +++ b/web/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + Business Club SLUC Nancy + + +
+ + + diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..9d4c827 --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,55 @@ +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + server_tokens off; + client_max_body_size 3m; + + # Security headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + # API proxied to the backend container + location /api/ { + proxy_pass http://api:3000; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Uploaded images: served directly from the shared volume, never executed + location /uploads/ { + alias /var/www/uploads/; + types { image/jpeg jpg; image/png png; image/webp webp; } + default_type application/octet-stream; + # user-uploaded content: never allow it to script or be framed + add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Content-Disposition "inline" always; + add_header Cache-Control "public, max-age=86400" always; + } + + # Static assets (immutable hashed filenames from Vite) + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header X-Content-Type-Options "nosniff" always; + try_files $uri =404; + } + + # SPA fallback + location / { + try_files $uri /index.html; + } +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..4767cf7 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1722 @@ +{ + "name": "sbc-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sbc-web", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "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" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..5e07a76 --- /dev/null +++ b/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "sbc-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" + } +} diff --git a/web/public/assets/logo.jpg b/web/public/assets/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e04cec88f7f7a37be94a9564a776bf9c205d9b04 GIT binary patch literal 29979 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 M=Kue-kiS#^1IyziHvj+t literal 0 HcmV?d00001 diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000..6555443 --- /dev/null +++ b/web/src/App.jsx @@ -0,0 +1,35 @@ +import { Routes, Route, useLocation } from 'react-router-dom'; +import { useEffect } from 'react'; +import Header from './components/Header.jsx'; +import Footer from './components/Footer.jsx'; +import Home from './pages/Home.jsx'; +import Annuaire from './pages/Annuaire.jsx'; +import Association from './pages/Association.jsx'; +import Espace from './pages/Espace.jsx'; +import Admin from './pages/Admin.jsx'; + +export default function App() { + const location = useLocation(); + const isAdmin = location.pathname.startsWith('/admin'); + + useEffect(() => { + window.scrollTo(0, 0); + }, [location.pathname]); + + return ( +
+ {!isAdmin &&
} +
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
+ {!isAdmin &&
} +
+ ); +} diff --git a/web/src/components/Footer.jsx b/web/src/components/Footer.jsx new file mode 100644 index 0000000..4b1ea7a --- /dev/null +++ b/web/src/components/Footer.jsx @@ -0,0 +1,68 @@ +import { Link } from 'react-router-dom'; + +const linkStyle = { color: '#B7AFA6', fontSize: 14, textDecoration: 'none' }; + +export default function Footer() { + return ( +
+
+
+
+ SLUC Business Club + + Business Club SLUC Nancy + +
+

+ Le réseau d'affaires des partenaires du SLUC Nancy Basket. Le business se joue en équipe. +

+
+
+
+ Navigation +
+
+ Accueil + Annuaire + L'association +
+
+
+
+ Contact +
+
+ Palais des Sports J. Weille
Nancy (54)
+ contact@sluc-businessclub.fr + +33 3 83 00 00 00 +
+
+
+
+ Suivez-nous +
+
+ LinkedIn + Instagram + Facebook +
+
+
+
+ © {new Date().getFullYear()} Business Club SLUC Nancy + Mentions légales · Confidentialité +
+
+ ); +} diff --git a/web/src/components/Header.jsx b/web/src/components/Header.jsx new file mode 100644 index 0000000..a37eebf --- /dev/null +++ b/web/src/components/Header.jsx @@ -0,0 +1,47 @@ +import { Link, NavLink } from 'react-router-dom'; + +const navLinkClass = ({ isActive }) => `nav-btn${isActive ? ' active' : ''}`; + +export default function Header() { + return ( +
+
+ + SLUC Business Club Nancy + + Business Club + + SLUC Nancy + + + + +
+
+ ); +} diff --git a/web/src/components/ImageSlot.jsx b/web/src/components/ImageSlot.jsx new file mode 100644 index 0000000..9f6946f --- /dev/null +++ b/web/src/components/ImageSlot.jsx @@ -0,0 +1,49 @@ +import { useRef, useState } from 'react'; +import { api } from '../lib/api.js'; + +// Real replacement for the mock's : click or drop an image, +// it is uploaded to `endpoint` and the stored path is displayed. +export default function ImageSlot({ endpoint, value, onUploaded, placeholder, circle }) { + const inputRef = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const send = async (file) => { + if (!file) return; + setBusy(true); + setError(''); + try { + const d = await api.upload(endpoint, file); + onUploaded?.(d.path); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + }; + + return ( +
inputRef.current?.click()} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + send(e.dataTransfer.files?.[0]); + }} + role="button" + tabIndex={0} + > + {value && } + {!value && {busy ? 'Envoi…' : error || placeholder}} + send(e.target.files?.[0])} + /> +
+ ); +} diff --git a/web/src/components/MemberModal.jsx b/web/src/components/MemberModal.jsx new file mode 100644 index 0000000..bc6d8e2 --- /dev/null +++ b/web/src/components/MemberModal.jsx @@ -0,0 +1,69 @@ +import Modal from './Modal.jsx'; + +export default function MemberModal({ member, onClose }) { + if (!member) return null; + return ( + +
+ +
+ {member.logo_path ? ( + + ) : ('logo')} +
+
+
+ {member.secteur} +
+

{member.nom}

+
+ + {member.categorie || 'Non classée'} + +
+
+
+ +
+ + ); +} diff --git a/web/src/components/Modal.jsx b/web/src/components/Modal.jsx new file mode 100644 index 0000000..387a064 --- /dev/null +++ b/web/src/components/Modal.jsx @@ -0,0 +1,23 @@ +export default function Modal({ onClose, maxWidth = 560, children, header }) { + return ( +
+
e.stopPropagation()}> + {header && ( +
+ + {header.kicker && ( +
+ {header.kicker} +
+ )} + {header.title && ( +

{header.title}

+ )} + {header.meta &&
{header.meta}
} +
+ )} + {children} +
+
+ ); +} diff --git a/web/src/components/admin/AdminContent.jsx b/web/src/components/admin/AdminContent.jsx new file mode 100644 index 0000000..b198c33 --- /dev/null +++ b/web/src/components/admin/AdminContent.jsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from 'react'; +import { api } from '../../lib/api.js'; +import ImageSlot from '../ImageSlot.jsx'; + +export function CategoriesTab() { + const [categories, setCategories] = useState([]); + const [editId, setEditId] = useState(null); + const [draft, setDraft] = useState(''); + const [newCat, setNewCat] = useState(''); + const [error, setError] = useState(''); + + const reload = () => + api.get('/api/admin/categories').then((d) => setCategories(d.categories)).catch(() => {}); + + useEffect(() => { + reload(); + }, []); + + const save = async (id) => { + if (!draft.trim()) return setEditId(null); + try { + await api.put(`/api/admin/categories/${id}`, { name: draft.trim() }); + setEditId(null); + setDraft(''); + reload(); + } catch (err) { + setError(err.message); + } + }; + + const remove = async (c) => { + if (!window.confirm(`Supprimer la catégorie « ${c.name} » ? Les entreprises concernées deviendront « Non classée ».`)) return; + try { + await api.del(`/api/admin/categories/${c.id}`); + reload(); + } catch (err) { + setError(err.message); + } + }; + + const add = async (e) => { + e.preventDefault(); + if (!newCat.trim()) return; + setError(''); + try { + await api.post('/api/admin/categories', { name: newCat.trim() }); + setNewCat(''); + reload(); + } catch (err) { + setError(err.message); + } + }; + + return ( +
+
+
+

Catégories d'entreprises

+ {categories.length} catégories +
+ + + + + + {categories.map((c) => ( + + + + + + ))} + +
CatégorieEntreprises
+ {editId === c.id ? ( + setDraft(e.target.value)} + maxLength={80} + autoFocus + style={{ fontSize: 14, padding: '8px 12px', border: '1px solid var(--red)', borderRadius: 3, background: 'var(--bg)', width: '100%', maxWidth: 260 }} + /> + ) : ( + {c.name} + )} + {c.count} + {editId === c.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+ {error &&

{error}

} +
+
+

Nouvelle catégorie

+

+ Ajoutez une catégorie pour classer les entreprises de l'annuaire. +

+
+ setNewCat(e.target.value)} + placeholder="Ex. Restauration & Hôtellerie" + maxLength={80} + style={{ width: '100%', padding: '12px 14px', border: '1px solid var(--input-border)', borderRadius: 3, background: 'var(--bg)', marginBottom: 14 }} + /> + +
+
+
+ ); +} + +export function ContenuTab() { + const [content, setContent] = useState({ hero_quote_text: '', hero_quote_author: '', hero_photo: '' }); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + api.get('/api/public/bootstrap').then((d) => setContent({ hero_photo: '', ...d.content })).catch(() => {}); + }, []); + + const save = async (e) => { + e.preventDefault(); + setError(''); + try { + await api.put('/api/admin/content', { + hero_quote_text: content.hero_quote_text, + hero_quote_author: content.hero_quote_author, + }); + setSaved(true); + } catch (err) { + setError(err.message); + } + }; + + return ( +
+
+

Section « L'esprit du Club »

+

+ Photo, citation et signature affichées sur la page d'accueil. +

+
+