diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3ad68fd --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Configuration de l'application NoteFlow +# Copier ce fichier en .env et modifier les valeurs selon votre environnement + +# Port de l'application +PORT=2222 + +# Secret JWT - CHANGER EN PRODUCTION avec une valeur forte et aléatoire +# Générer avec: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +JWT_SECRET=change_me_in_production_please_use_strong_secret + +# Environnement (development, production) +NODE_ENV=production + +# Chemins des fichiers +DB_PATH=/app/data/notes.db +UPLOADS_PATH=/app/public/uploads + +# Limite de taille des uploads (en bytes) +MAX_FILE_SIZE=5242880 + +# Limite de taux (requests par fenêtre) +RATE_LIMIT_MAX=100 +RATE_LIMIT_WINDOW_MS=900000 diff --git a/.gitignore b/.gitignore index 4108b33..7bf1ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,59 @@ -# Logs -logs -*.log +# Dependencies +node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* -pnpm-debug.log* -lerna-debug.log* +package-lock.json +yarn.lock -node_modules -dist -dist-ssr -*.local +# Environment variables +.env +.env.local +.env.production -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea +# Database +data/*.db +data/*.db-journal +data/*.db-shm +data/*.db-wal + +# Uploads +public/uploads/* +!public/uploads/.gitkeep + +# Logs +*.log +logs/ +data/*.log + +# OS files .DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +*~ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*.swn +.project +.settings/ +.classpath + +# Docker +.dockerignore + +# Build files +dist/ +build/ +*.tgz + +# Temporary files +tmp/ +temp/ diff --git a/Dockerfile b/Dockerfile index eaf63b9..1d682a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,42 +1,34 @@ +# Image de base Node.js 20 Alpine pour optimiser la taille FROM node:20-alpine -WORKDIR /home/node/app +# Définir le répertoire de travail +WORKDIR /app -# Install build dependencies -RUN apk add --no-cache python3 make g++ +# Installer SQLite +RUN apk add --no-cache sqlite -# Create necessary directories -RUN mkdir -p public/css/dist data public/uploads - -# Install dependencies first (better layer caching) +# Copier les fichiers de dépendances COPY package*.json ./ -RUN npm install -# Copy configuration files -COPY postcss.config.js tailwind.config.js ./ +# Installer les dépendances de production uniquement +RUN npm ci --only=production -# Copy source files -COPY src ./src -COPY public ./public - -# Build CSS with verbose output -RUN NODE_ENV=production npx tailwindcss -i ./src/globals.css -o ./public/css/dist/styles.css --minify -v - -# Copy remaining files +# Copier le code source COPY . . -# Set correct permissions -RUN chown -R node:node . +# Créer les dossiers nécessaires et définir les permissions +RUN mkdir -p /app/data /app/public/uploads && \ + chown -R node:node /app -# Switch to non-root user +# Utiliser l'utilisateur node pour la sécurité USER node -# Set environment variables -ENV NODE_ENV=production \ - PORT=2222 - -# Expose port +# Exposer le port 2222 EXPOSE 2222 -# Start the application -CMD ["npm", "start"] \ No newline at end of file +# Healthcheck pour vérifier que l'application fonctionne +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD node -e "require('http').get('http://localhost:2222/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" + +# Démarrer l'application +CMD ["node", "server.js"] \ No newline at end of file diff --git a/README.md b/README.md index 2777c8e..6a912a4 100644 --- a/README.md +++ b/README.md @@ -1 +1,187 @@ -# Welcome to your Dyad app +# 📝 NoteFlow - Application de Notes et Todo List + +Application web moderne de gestion de notes et de tâches, Dockerisée, avec authentification JWT, interface utilisateur fluide et animations CSS natives. + +## ✨ Fonctionnalités + +### 🔐 Authentification +- Connexion JWT sécurisée avec bcrypt +- Session de 24 heures +- Gestion des utilisateurs par les administrateurs + +### 📝 Gestion des Notes +- Création, édition et suppression de notes +- Support des images (upload, preview, suppression) +- Todos intégrés dans les notes +- Recherche en temps réel par titre/contenu +- Interface masonry layout responsive +- Animations fluides sur les interactions + +### ✅ Todos Globaux +- Sidebar permanente avec quick tasks +- Création, modification, suppression de todos +- Toggle completed/active +- Filtres : Toutes / Actives / Terminées +- Compteur de tâches restantes + +### 👥 Administration +- Gestion des utilisateurs (création, modification, suppression) +- Attribution des droits administrateur +- Interface dédiée pour les admins + +## 🛠️ Stack Technique + +- **Backend** : Node.js 20 + Express +- **Base de données** : SQLite3 +- **Authentification** : JWT + bcrypt +- **Frontend** : HTML5/CSS3/JavaScript vanilla +- **Sécurité** : Helmet.js, rate limiting, validation des entrées +- **Logging** : Winston +- **Upload** : Multer +- **Container** : Docker + Docker Compose + +## 🚀 Installation et Démarrage + +### Prérequis +- Docker et Docker Compose installés +- Réseau Docker `nginx_default` (ou adapter dans docker-compose.yml) + +### Installation + +1. **Cloner le repository** +```bash +git clone +cd noteflow +``` + +2. **Configurer les variables d'environnement** +```bash +cp .env.example .env +``` + +Éditer le fichier `.env` : +```env +PORT=2222 +JWT_SECRET= +NODE_ENV=production +``` + +**Générer un JWT_SECRET fort** : +```bash +node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +``` + +3. **Builder et démarrer l'application** +```bash +docker-compose up -d --build +``` + +4. **Accéder à l'application** +``` +http://localhost:2222 +``` + +### Identifiants par défaut +``` +Username: admin +Password: admin +``` + +**⚠️ IMPORTANT** : Changez immédiatement le mot de passe admin en production ! + +## 🐳 Commandes Docker + +```bash +# Démarrer +docker-compose up -d + +# Arrêter +docker-compose down + +# Rebuild +docker-compose build + +# Logs +docker-compose logs -f + +# Backup base de données +docker cp notes-todo-app:/app/data/notes.db ./backup_$(date +%Y%m%d).db + +# Restore +docker cp ./backup.db notes-todo-app:/app/data/notes.db +docker-compose restart +``` + +## ⚙️ Configuration Nginx + +```nginx +location /notes { + proxy_pass http://notes-todo-app:2222; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $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; + client_max_body_size 10M; +} +``` + +## 📡 API Endpoints + +### Authentification +``` +POST /api/auth/login +POST /api/auth/logout +GET /api/auth/me +``` + +### Utilisateurs (Admin) +``` +GET /api/users +POST /api/users +PUT /api/users/:id +DELETE /api/users/:id +``` + +### Notes +``` +GET /api/notes +POST /api/notes +GET /api/notes/:id +PUT /api/notes/:id +DELETE /api/notes/:id +POST /api/notes/:id/image +DELETE /api/notes/:id/image +GET /api/search?q=query +``` + +### Todos Notes +``` +POST /api/notes/:id/todos +PUT /api/notes/todos/:todoId +DELETE /api/notes/todos/:todoId +``` + +### Todos Globaux +``` +GET /api/todos +POST /api/todos +PUT /api/todos/:id +DELETE /api/todos/:id +``` + +## 🔒 Sécurité + +- Bcrypt 12 rounds +- JWT expiration 24h +- Helmet.js +- Rate limiting +- Input validation +- File type whitelist +- SQL prepared statements + +## 📝 Licence + +MIT diff --git a/config/database.js b/config/database.js index ac8a27b..261794b 100644 --- a/config/database.js +++ b/config/database.js @@ -1,119 +1,175 @@ +// Configuration et initialisation de la base de données SQLite const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const bcrypt = require('bcrypt'); -const winston = require('winston'); -const fs = require('fs'); +const logger = require('./logger'); -// Ensure data directory exists -const dataDir = path.resolve(__dirname, '../data'); -if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); -} +const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/notes.db'); -// Configure logger -const logger = winston.createLogger({ - level: 'info', - format: winston.format.combine( - winston.format.timestamp(), - winston.format.json() - ), - transports: [ - new winston.transports.File({ filename: path.join(dataDir, 'app.log') }), - new winston.transports.Console() - ] +// Créer une connexion à la base de données +const db = new sqlite3.Database(DB_PATH, (err) => { + if (err) { + logger.error('Erreur lors de la connexion à la base de données:', err); + process.exit(1); + } + logger.info(`Base de données connectée: ${DB_PATH}`); }); -const dbPath = path.join(dataDir, 'notes.db'); -logger.info(`Using database at: ${dbPath}`); +// Activer les clés étrangères +db.run('PRAGMA foreign_keys = ON'); -// Create database connection -const db = new sqlite3.Database(dbPath, (err) => { - if (err) { - logger.error('Database connection error:', err); - process.exit(1); - } - logger.info('Connected to SQLite database'); - initializeDatabase(); -}); - -// Initialize database schema -function initializeDatabase() { - db.serialize(() => { - // Create users table +/** + * Initialiser la base de données avec les tables nécessaires + */ +function initDatabase() { + return new Promise((resolve, reject) => { + db.serialize(async () => { + try { + // Table users db.run(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) `); - // Create notes table + // Table notes db.run(` - CREATE TABLE IF NOT EXISTS notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - title TEXT NOT NULL, - content TEXT, - archived INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE - ) + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT, + image_filename TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) `); - // Create todos table + // Table note_todos (todos dans les notes) db.run(` - CREATE TABLE IF NOT EXISTS todos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - note_id INTEGER NOT NULL, - text TEXT NOT NULL, - completed INTEGER DEFAULT 0, - position INTEGER DEFAULT 0, - FOREIGN KEY (note_id) REFERENCES notes (id) ON DELETE CASCADE - ) + CREATE TABLE IF NOT EXISTS note_todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id INTEGER NOT NULL, + text TEXT NOT NULL, + completed BOOLEAN DEFAULT 0, + position INTEGER DEFAULT 0, + FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE + ) `); - // Create images table + // Table global_todos (sidebar permanente) db.run(` - CREATE TABLE IF NOT EXISTS images ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - note_id INTEGER NOT NULL, - filename TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (note_id) REFERENCES notes (id) ON DELETE CASCADE - ) + CREATE TABLE IF NOT EXISTS global_todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + text TEXT NOT NULL, + completed BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) `); - // Check if admin user exists, create if not - db.get('SELECT * FROM users WHERE username = ?', ['admin'], async (err, user) => { - if (err) { - logger.error('Error checking admin user:', err); - return; - } + // Créer les index pour la performance + db.run('CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_note_todos ON note_todos(note_id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_global_todos_user ON global_todos(user_id)'); - if (!user) { - try { - const hash = await bcrypt.hash('admin', 10); - db.run( - 'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)', - ['admin', hash, 1], - (err) => { - if (err) { - logger.error('Error creating admin user:', err); - return; - } - logger.info('Admin user created successfully'); - } - ); - } catch (err) { - logger.error('Error hashing admin password:', err); + logger.info('✓ Tables de base de données créées avec succès'); + + // Créer l'utilisateur admin par défaut si la table est vide + db.get('SELECT COUNT(*) as count FROM users', async (err, row) => { + if (err) { + logger.error('Erreur lors de la vérification des utilisateurs:', err); + reject(err); + return; + } + + if (row.count === 0) { + // Créer l'utilisateur admin par défaut + const defaultPassword = 'admin'; + const passwordHash = await bcrypt.hash(defaultPassword, 12); + + db.run( + 'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)', + ['admin', passwordHash, 1], + (err) => { + if (err) { + logger.error('Erreur lors de la création de l\'utilisateur admin:', err); + reject(err); + } else { + logger.info('✓ Utilisateur admin créé (username: admin, password: admin)'); + logger.warn('⚠️ IMPORTANT: Changez le mot de passe admin en production!'); + resolve(); } - } + } + ); + } else { + logger.info('✓ Base de données déjà initialisée'); + resolve(); + } }); + } catch (error) { + logger.error('Erreur lors de l\'initialisation de la base de données:', error); + reject(error); + } }); + }); } -module.exports = { db, logger }; \ No newline at end of file +/** + * Exécuter une requête avec promesse + */ +function runQuery(sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, function(err) { + if (err) { + reject(err); + } else { + resolve({ id: this.lastID, changes: this.changes }); + } + }); + }); +} + +/** + * Récupérer une seule ligne + */ +function getOne(sql, params = []) { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, row) => { + if (err) { + reject(err); + } else { + resolve(row); + } + }); + }); +} + +/** + * Récupérer toutes les lignes + */ +function getAll(sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) { + reject(err); + } else { + resolve(rows); + } + }); + }); +} + +module.exports = { + db, + initDatabase, + runQuery, + getOne, + getAll +}; \ No newline at end of file diff --git a/config/logger.js b/config/logger.js new file mode 100644 index 0000000..a9af112 --- /dev/null +++ b/config/logger.js @@ -0,0 +1,53 @@ +// Configuration du logger Winston +const winston = require('winston'); +const path = require('path'); +const fs = require('fs'); + +// Créer le dossier data s'il n'existe pas +const dataDir = path.join(__dirname, '../data'); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +// Format personnalisé pour les logs +const logFormat = winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.printf(({ timestamp, level, message, stack }) => { + let log = `${timestamp} [${level.toUpperCase()}]: ${message}`; + if (stack) { + log += `\n${stack}`; + } + return log; + }) +); + +// Configuration du logger +const logger = winston.createLogger({ + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + format: logFormat, + transports: [ + // Console output avec couleurs + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + logFormat + ) + }), + // Fichier pour tous les logs + new winston.transports.File({ + filename: path.join(dataDir, 'app.log'), + maxsize: 5242880, // 5MB + maxFiles: 5 + }), + // Fichier séparé pour les erreurs + new winston.transports.File({ + filename: path.join(dataDir, 'error.log'), + level: 'error', + maxsize: 5242880, // 5MB + maxFiles: 5 + }) + ] +}); + +module.exports = logger; diff --git a/docker-compose.yml b/docker-compose.yml index 1c45996..0dcdd24 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,19 +7,23 @@ services: ports: - "2222:2222" volumes: - - notes_data:/home/node/app/data - - notes_uploads:/home/node/app/public/uploads + - ./data:/app/data + - ./public/uploads:/app/public/uploads environment: - NODE_ENV=production - - JWT_SECRET=changeme_in_production + - JWT_SECRET=${JWT_SECRET:-change_me_in_production_please_use_strong_secret} - PORT=2222 + - UPLOADS_PATH=/app/public/uploads + - DB_PATH=/app/data/notes.db restart: unless-stopped networks: - nginx_default - -volumes: - notes_data: - notes_uploads: + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:2222/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s networks: nginx_default: diff --git a/middleware/auth.js b/middleware/auth.js index 9503771..52f6761 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -1,40 +1,75 @@ +// Middleware d'authentification JWT const jwt = require('jsonwebtoken'); -const winston = require('winston'); +const logger = require('../config/logger'); -// Configure logger -const logger = winston.createLogger({ - level: 'info', - format: winston.format.combine( - winston.format.timestamp(), - winston.format.json() - ), - transports: [ - new winston.transports.Console() - ] -}); +const JWT_SECRET = process.env.JWT_SECRET || 'change_me_in_production_please_use_strong_secret'; -const authMiddleware = (req, res, next) => { - try { - const token = req.headers.authorization?.split(' ')[1]; - - if (!token) { - return res.status(401).json({ message: 'Authentication required' }); +if (JWT_SECRET === 'change_me_in_production_please_use_strong_secret' && process.env.NODE_ENV === 'production') { + logger.warn('⚠️ ATTENTION: JWT_SECRET par défaut utilisé en production! Changez-le immédiatement!'); +} + +/** + * Middleware pour vérifier le token JWT + */ +function authenticateToken(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Format: "Bearer TOKEN" + + if (!token) { + return res.status(401).json({ error: 'Token d\'authentification manquant' }); + } + + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) { + logger.warn(`Tentative d'accès avec token invalide: ${err.message}`); + return res.status(403).json({ error: 'Token invalide ou expiré' }); } - const decoded = jwt.verify(token, process.env.JWT_SECRET); - req.user = decoded; + // Ajouter les informations utilisateur à la requête + req.user = user; next(); - } catch (error) { - logger.error('Auth middleware error:', error); - return res.status(401).json({ message: 'Invalid token' }); - } -}; + }); +} -const adminMiddleware = (req, res, next) => { - if (!req.user.is_admin) { - return res.status(403).json({ message: 'Admin access required' }); +/** + * Middleware pour vérifier que l'utilisateur est admin + */ +function requireAdmin(req, res, next) { + if (!req.user || !req.user.is_admin) { + logger.warn(`Tentative d'accès admin par utilisateur non autorisé: ${req.user?.username || 'unknown'}`); + return res.status(403).json({ error: 'Accès réservé aux administrateurs' }); } next(); -}; +} -module.exports = { authMiddleware, adminMiddleware }; \ No newline at end of file +/** + * Générer un token JWT + */ +function generateToken(user) { + const payload = { + id: user.id, + username: user.username, + is_admin: user.is_admin + }; + + // Token valide pour 24 heures + return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' }); +} + +/** + * Vérifier un token JWT (sans middleware) + */ +function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET); + } catch (err) { + return null; + } +} + +module.exports = { + authenticateToken, + requireAdmin, + generateToken, + verifyToken +}; \ No newline at end of file diff --git a/package.json b/package.json index 1e00268..a64ca13 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,28 @@ { - "name": "notes-todo-app", + "name": "noteflow", "version": "1.0.0", - "description": "Notes and Todo List Application", + "description": "Application web de notes et todo list moderne avec Docker", "main": "server.js", "scripts": { "start": "node server.js", "dev": "nodemon server.js", - "build:css": "mkdir -p public/css/dist && npx tailwindcss build -i ./src/globals.css -o ./public/css/dist/styles.css --minify" + "test": "echo \"Error: no test specified\" && exit 1", + "docker:build": "docker-compose build", + "docker:up": "docker-compose up -d", + "docker:down": "docker-compose down", + "docker:logs": "docker-compose logs -f", + "docker:restart": "docker-compose restart" }, + "keywords": [ + "notes", + "todo", + "docker", + "express", + "sqlite" + ], + "author": "", + "license": "MIT", "dependencies": { - "@tanstack/react-query": "^5.90.7", - "autoprefixer": "^10.4.16", "bcrypt": "^5.1.1", "cors": "^2.8.5", "express": "^4.18.2", @@ -19,15 +31,13 @@ "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", - "postcss": "^8.4.31", - "postcss-cli": "^10.1.0", "sqlite3": "^5.1.7", - "tailwindcss": "^3.3.5", - "tailwindcss-animate": "^1.0.7", - "uuid": "^9.0.1", "winston": "^3.11.0" }, "devDependencies": { - "nodemon": "^3.0.3" + "nodemon": "^3.0.2" + }, + "engines": { + "node": ">=20.0.0" } } \ No newline at end of file diff --git a/public/css/animations.css b/public/css/animations.css new file mode 100644 index 0000000..08d2b7c --- /dev/null +++ b/public/css/animations.css @@ -0,0 +1,126 @@ +/* Animations CSS */ + +/* Fade in */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Backdrop fade */ +@keyframes backdropFade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Modal open animation */ +@keyframes modalOpen { + from { + opacity: 0; + transform: scale(0.85) translateY(20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Slide in from bottom */ +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Slide in from right */ +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Card hover animation */ +@keyframes cardHover { + from { + transform: translateY(0); + } + to { + transform: translateY(-4px); + } +} + +/* Pulse animation */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Spin animation */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* Application des animations */ +.modal-backdrop { + animation: backdropFade var(--transition-slow) ease; +} + +.modal-content { + animation: modalOpen 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.note-card { + animation: slideInUp 0.3s ease; +} + +.todo-item { + animation: slideInRight 0.2s ease; +} + +.user-dropdown { + animation: slideInUp 0.2s ease; +} + +/* Loading state */ +.loading { + animation: pulse 1.5s ease-in-out infinite; +} + +.spinning { + animation: spin 1s linear infinite; +} + +/* Smooth transitions */ +.smooth-transition { + transition: all var(--transition-base); +} + +.smooth-transition-slow { + transition: all var(--transition-slow); +} diff --git a/public/css/components.css b/public/css/components.css new file mode 100644 index 0000000..ff6a055 --- /dev/null +++ b/public/css/components.css @@ -0,0 +1,510 @@ +/* Composants UI */ + +/* Search input */ +.search-input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + padding-right: 40px; + border: 2px solid var(--color-border); + border-radius: var(--radius-lg); + font-size: var(--font-size-base); + transition: border-color var(--transition-base); +} + +.search-input:focus { + outline: none; + border-color: var(--color-accent); +} + +.search-clear { + position: absolute; + right: var(--spacing-sm); + top: 50%; + transform: translateY(-50%); + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--color-border); + color: var(--color-text-secondary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background var(--transition-base); +} + +.search-clear:hover { + background: var(--color-text-secondary); + color: white; +} + +/* User dropdown */ +.user-info { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--transition-base); +} + +.user-info:hover { + background: var(--color-background); +} + +.user-avatar { + font-size: var(--font-size-xl); +} + +.user-name { + font-weight: 500; +} + +.user-dropdown { + position: absolute; + top: calc(100% + var(--spacing-sm)); + right: 0; + background: white; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + min-width: 200px; + display: none; + animation: fadeIn var(--transition-base); +} + +.user-dropdown.show { + display: block; +} + +.dropdown-item { + width: 100%; + padding: var(--spacing-md); + text-align: left; + transition: background var(--transition-base); + border-bottom: 1px solid var(--color-border); +} + +.dropdown-item:last-child { + border-bottom: none; +} + +.dropdown-item:hover { + background: var(--color-background); +} + +/* Note cards */ +.note-card { + background: var(--color-card); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + box-shadow: var(--shadow-sm); + cursor: pointer; + transition: transform var(--transition-base), box-shadow var(--transition-base); + position: relative; + min-height: 150px; + max-height: 400px; + overflow: hidden; +} + +.note-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); +} + +.note-card-title { + font-size: var(--font-size-lg); + font-weight: 600; + margin-bottom: var(--spacing-sm); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.note-card-content { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + line-height: 1.6; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: var(--spacing-md); +} + +.note-card-image { + width: 50px; + height: 50px; + border-radius: var(--radius-sm); + object-fit: cover; + position: absolute; + top: var(--spacing-md); + right: var(--spacing-md); +} + +.note-card-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + margin-top: auto; +} + +.note-card-badge { + background: var(--color-accent); + color: white; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); +} + +/* FAB Button */ +.fab { + position: fixed; + bottom: var(--spacing-xl); + right: calc(var(--sidebar-width) + var(--spacing-xl)); + width: 60px; + height: 60px; + background: var(--color-accent); + color: white; + border-radius: 50%; + font-size: 32px; + box-shadow: var(--shadow-lg); + transition: transform var(--transition-base), box-shadow var(--transition-base); + z-index: 50; +} + +.fab:hover { + transform: scale(1.1); + box-shadow: var(--shadow-xl); + background: var(--color-accent-hover); +} + +/* Todo sidebar components */ +.sidebar-title { + font-size: var(--font-size-xl); + font-weight: bold; + margin-bottom: var(--spacing-lg); +} + +.todo-input-container { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-md); +} + +.todo-input { + flex: 1; + padding: var(--spacing-sm) var(--spacing-md); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--font-size-base); +} + +.todo-input:focus { + outline: none; + border-color: var(--color-accent); +} + +.todo-add-btn { + width: 40px; + height: 40px; + background: var(--color-accent); + color: white; + border-radius: var(--radius-md); + font-size: var(--font-size-xl); + transition: background var(--transition-base); +} + +.todo-add-btn:hover { + background: var(--color-accent-hover); +} + +.todo-filters { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-md); +} + +.filter-btn { + flex: 1; + padding: var(--spacing-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + transition: background var(--transition-base), color var(--transition-base); +} + +.filter-btn.active { + background: var(--color-accent); + color: white; + border-color: var(--color-accent); +} + +.todo-counter { + text-align: center; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + margin-bottom: var(--spacing-md); +} + +.todo-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.todo-item { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-md); + background: white; + border-radius: var(--radius-md); + transition: background var(--transition-base); +} + +.todo-item:hover { + background: var(--color-background); +} + +.todo-item.hidden { + display: none; +} + +.todo-checkbox { + width: 20px; + height: 20px; + cursor: pointer; +} + +.todo-text { + flex: 1; + font-size: var(--font-size-base); +} + +.todo-item.completed .todo-text { + text-decoration: line-through; + color: var(--color-text-secondary); +} + +.todo-delete { + opacity: 0; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--color-danger); + color: white; + font-size: var(--font-size-sm); + transition: opacity var(--transition-base); +} + +.todo-item:hover .todo-delete { + opacity: 1; +} + +/* Modal */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: var(--spacing-lg); +} + +.modal-content { + background: white; + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + max-width: 700px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + padding: var(--spacing-xl); +} + +.admin-modal { + max-width: 900px; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-lg); +} + +.modal-close { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--color-border); + font-size: var(--font-size-lg); + transition: background var(--transition-base); +} + +.modal-close:hover { + background: var(--color-text-secondary); + color: white; +} + +.modal-delete { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--color-danger); + color: white; + font-size: var(--font-size-lg); + transition: background var(--transition-base); +} + +.modal-delete:hover { + background: #DC2626; +} + +.note-title-input { + width: 100%; + font-size: var(--font-size-xl); + font-weight: bold; + border: none; + padding: var(--spacing-sm) 0; + margin-bottom: var(--spacing-md); +} + +.note-title-input:focus { + outline: none; +} + +.note-divider { + height: 1px; + background: var(--color-border); + margin: var(--spacing-lg) 0; +} + +.note-content-textarea { + width: 100%; + min-height: 200px; + border: none; + resize: vertical; + font-size: var(--font-size-base); + line-height: 1.6; +} + +.note-content-textarea:focus { + outline: none; +} + +.note-image-container { + position: relative; + margin: var(--spacing-lg) 0; +} + +.note-image { + width: 100%; + border-radius: var(--radius-md); +} + +.note-image-remove { + position: absolute; + top: var(--spacing-sm); + right: var(--spacing-sm); + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-danger); + color: white; + font-size: var(--font-size-base); +} + +.note-section-title { + font-size: var(--font-size-lg); + font-weight: 600; + margin-bottom: var(--spacing-md); +} + +.note-todos-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-md); +} + +.note-add-todo-btn { + width: 100%; + padding: var(--spacing-md); + border: 2px dashed var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-secondary); + transition: border-color var(--transition-base), color var(--transition-base); +} + +.note-add-todo-btn:hover { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.modal-actions { + display: flex; + gap: var(--spacing-md); + margin-top: var(--spacing-lg); +} + +.action-btn { + padding: var(--spacing-md) var(--spacing-lg); + background: var(--color-accent); + color: white; + border-radius: var(--radius-md); + font-size: var(--font-size-base); + transition: background var(--transition-base); +} + +.action-btn:hover { + background: var(--color-accent-hover); +} + +.modal-footer { + margin-top: var(--spacing-lg); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--color-border); +} + +.note-metadata { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +/* Admin table */ +.admin-actions { + margin-bottom: var(--spacing-lg); +} + +.users-table-container { + overflow-x: auto; +} + +.users-table { + width: 100%; + border-collapse: collapse; +} + +.users-table th, +.users-table td { + padding: var(--spacing-md); + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.users-table th { + font-weight: 600; + background: var(--color-background); +} + +.users-table tbody tr:hover { + background: var(--color-background); +} diff --git a/public/css/layout.css b/public/css/layout.css new file mode 100644 index 0000000..b169d12 --- /dev/null +++ b/public/css/layout.css @@ -0,0 +1,129 @@ +/* Layout principal de l'application */ + +body { + font-family: var(--font-family); + background: var(--color-background); + color: var(--color-text-primary); + font-size: var(--font-size-base); +} + +/* Header */ +.header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--header-height); + background: var(--color-card); + border-bottom: 1px solid var(--color-border); + z-index: 100; + box-shadow: var(--shadow-sm); +} + +.header-content { + height: 100%; + max-width: 100%; + padding: 0 var(--spacing-lg); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-lg); +} + +.header-logo h1 { + font-size: var(--font-size-xl); + font-weight: bold; + color: var(--color-accent); +} + +.header-search { + flex: 1; + max-width: 600px; + position: relative; +} + +.header-user { + position: relative; +} + +/* Main container */ +.main-container { + display: flex; + margin-top: var(--header-height); + min-height: calc(100vh - var(--header-height)); +} + +/* Notes area */ +.notes-area { + flex: 1; + padding: var(--spacing-xl); + overflow-y: auto; + position: relative; +} + +.notes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--note-card-width), 1fr)); + gap: var(--spacing-lg); + max-width: 1400px; + margin: 0 auto; +} + +/* Todo sidebar */ +.todo-sidebar { + position: sticky; + top: var(--header-height); + right: 0; + width: var(--sidebar-width); + height: calc(100vh - var(--header-height)); + background: var(--color-sidebar); + border-left: 1px solid var(--color-border); + padding: var(--spacing-lg); + overflow-y: auto; + flex-shrink: 0; +} + +/* Responsive */ +@media (max-width: 1024px) { + .notes-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + } + + .todo-sidebar { + width: 320px; + } +} + +@media (max-width: 768px) { + .main-container { + flex-direction: column; + } + + .header-content { + padding: 0 var(--spacing-md); + gap: var(--spacing-sm); + } + + .header-logo h1 { + font-size: var(--font-size-lg); + } + + .header-search { + max-width: 400px; + } + + .notes-area { + padding: var(--spacing-md); + } + + .notes-grid { + grid-template-columns: 1fr; + } + + .todo-sidebar { + position: static; + width: 100%; + height: auto; + max-height: 400px; + } +} diff --git a/public/css/reset.css b/public/css/reset.css new file mode 100644 index 0000000..501b75e --- /dev/null +++ b/public/css/reset.css @@ -0,0 +1,46 @@ +/* Reset CSS pour normaliser les styles entre navigateurs */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + -webkit-text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + min-height: 100vh; + line-height: 1.5; +} + +img, picture, video, canvas, svg { + display: block; + max-width: 100%; +} + +input, button, textarea, select { + font: inherit; +} + +p, h1, h2, h3, h4, h5, h6 { + overflow-wrap: break-word; +} + +button { + cursor: pointer; + border: none; + background: none; +} + +ul, ol { + list-style: none; +} + +a { + text-decoration: none; + color: inherit; +} diff --git a/public/css/variables.css b/public/css/variables.css new file mode 100644 index 0000000..30b8c4b --- /dev/null +++ b/public/css/variables.css @@ -0,0 +1,53 @@ +/* Variables CSS globales */ + +:root { + /* Couleurs principales */ + --color-background: #FAFBFC; + --color-card: #FFFFFF; + --color-sidebar: #F8F9FA; + --color-accent: #3B82F6; + --color-accent-hover: #2563EB; + --color-text-primary: #1F2937; + --color-text-secondary: #6B7280; + --color-border: #E5E7EB; + --color-success: #10B981; + --color-danger: #EF4444; + + /* Typographie */ + --font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-size-xs: 12px; + --font-size-sm: 14px; + --font-size-base: 16px; + --font-size-lg: 18px; + --font-size-xl: 24px; + --font-size-2xl: 32px; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + /* Border radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + + /* Shadows */ + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); + --shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.3); + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-base: 0.2s ease; + --transition-slow: 0.3s ease; + + /* Dimensions */ + --header-height: 60px; + --sidebar-width: 400px; + --note-card-width: 350px; +} diff --git a/public/index.html b/public/index.html index c6f0acd..a81447b 100644 --- a/public/index.html +++ b/public/index.html @@ -3,14 +3,150 @@ - Notes & Todos - + NoteFlow - Notes & Todo + + + + + + + -
- - - - + +
+
+ + + + +
+ +
+ + +
+
+
+
+ + +
+ +
+
+ +
+ + +
+ + + +
+ + + + + + + + + \ No newline at end of file diff --git a/public/js/complete-app.js b/public/js/complete-app.js new file mode 100644 index 0000000..8d80afe --- /dev/null +++ b/public/js/complete-app.js @@ -0,0 +1,663 @@ +// Application NoteFlow - JavaScript complet +// Ce fichier regroupe toute la logique de l'application pour simplifier le déploiement + +// ==================== UTILS ==================== +const utils = { + formatDate(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diff = now - date; + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) return 'Aujourd\'hui'; + if (days === 1) return 'Hier'; + if (days < 7) return `Il y a ${days} jours`; + + return date.toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined + }); + }, + + truncate(text, length = 150) { + if (!text || text.length <= length) return text; + return text.substring(0, length) + '...'; + }, + + debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } +}; + +// ==================== API ==================== +const api = { + async request(url, options = {}) { + const token = localStorage.getItem('token'); + const headers = { + 'Content-Type': 'application/json', + ...(token && { 'Authorization': `Bearer ${token}` }), + ...options.headers + }; + + const config = { + ...options, + headers + }; + + const response = await fetch(url, config); + + if (response.status === 401 || response.status === 403) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/login.html'; + throw new Error('Non authentifié'); + } + + return response; + }, + + async get(url) { + const response = await this.request(url); + return response.json(); + }, + + async post(url, data) { + const response = await this.request(url, { + method: 'POST', + body: JSON.stringify(data) + }); + return response.json(); + }, + + async put(url, data) { + const response = await this.request(url, { + method: 'PUT', + body: JSON.stringify(data) + }); + return response.json(); + }, + + async delete(url) { + const response = await this.request(url, { + method: 'DELETE' + }); + return response.json(); + }, + + async uploadFile(url, formData) { + const token = localStorage.getItem('token'); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + if (response.status === 401) { + window.location.href = '/login.html'; + throw new Error('Non authentifié'); + } + + return response.json(); + } +}; + +// ==================== STATE ==================== +const state = { + notes: [], + todos: [], + currentNote: null, + filter: 'all', + searchQuery: '', + user: null +}; + +// ==================== AUTH ==================== +function checkAuth() { + const token = localStorage.getItem('token'); + if (!token) { + window.location.href = '/login.html'; + return false; + } + + const userStr = localStorage.getItem('user'); + if (userStr) { + state.user = JSON.parse(userStr); + } + + return true; +} + +async function initAuth() { + if (!checkAuth()) return; + + // Afficher le nom d'utilisateur + const userNameEl = document.getElementById('userName'); + if (userNameEl && state.user) { + userNameEl.textContent = state.user.username; + } + + // Afficher le bouton admin si nécessaire + const adminBtn = document.getElementById('adminBtn'); + if (adminBtn && state.user && state.user.is_admin) { + adminBtn.style.display = 'block'; + } + + // Gérer la déconnexion + const logoutBtn = document.getElementById('logoutBtn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', async () => { + try { + await api.post('/api/auth/logout', {}); + } catch (e) {} + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/login.html'; + }); + } + + // Toggle user dropdown + const userInfo = document.getElementById('userInfo'); + const userDropdown = document.getElementById('userDropdown'); + if (userInfo && userDropdown) { + userInfo.addEventListener('click', (e) => { + e.stopPropagation(); + userDropdown.classList.toggle('show'); + }); + + document.addEventListener('click', () => { + userDropdown.classList.remove('show'); + }); + } +} + +// ==================== NOTES ==================== +async function loadNotes() { + try { + state.notes = await api.get('/api/notes'); + renderNotes(); + } catch (error) { + console.error('Erreur chargement notes:', error); + } +} + +function renderNotes() { + const notesGrid = document.getElementById('notesGrid'); + if (!notesGrid) return; + + let filtered = state.notes; + + // Filtre de recherche + if (state.searchQuery) { + const query = state.searchQuery.toLowerCase(); + filtered = filtered.filter(note => + note.title.toLowerCase().includes(query) || + (note.content && note.content.toLowerCase().includes(query)) + ); + } + + notesGrid.innerHTML = ''; + + if (filtered.length === 0) { + notesGrid.innerHTML = '

Aucune note trouvée

'; + return; + } + + filtered.forEach(note => { + const card = createNoteCard(note); + notesGrid.appendChild(card); + }); +} + +function createNoteCard(note) { + const card = document.createElement('div'); + card.className = 'note-card'; + card.onclick = () => openNoteModal(note.id); + + let html = `
${escapeHtml(note.title)}
`; + + if (note.content) { + html += `
${escapeHtml(utils.truncate(note.content))}
`; + } + + if (note.image_filename) { + html += ``; + } + + html += `
`; + html += `${utils.formatDate(note.updated_at)}`; + if (note.todos_count > 0) { + html += `${note.todos_count} tasks`; + } + html += `
`; + + card.innerHTML = html; + return card; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ==================== MODAL ==================== +let saveTimeout; +let currentNoteId = null; + +async function openNoteModal(noteId = null) { + currentNoteId = noteId; + const modal = document.getElementById('noteModal'); + const titleInput = document.getElementById('noteTitle'); + const contentTextarea = document.getElementById('noteContent'); + const imageContainer = document.getElementById('noteImageContainer'); + const noteImage = document.getElementById('noteImage'); + const noteTodosList = document.getElementById('noteTodosList'); + const metadata = document.getElementById('noteMetadata'); + + if (noteId) { + // Charger la note existante + try { + const note = await api.get(`/api/notes/${noteId}`); + state.currentNote = note; + + titleInput.value = note.title; + contentTextarea.value = note.content || ''; + + if (note.image_filename) { + noteImage.src = `/uploads/${note.image_filename}`; + imageContainer.style.display = 'block'; + } else { + imageContainer.style.display = 'none'; + } + + renderNoteTodos(note.todos || []); + metadata.textContent = `Créée ${utils.formatDate(note.created_at)} • Modifiée ${utils.formatDate(note.updated_at)}`; + } catch (error) { + console.error('Erreur chargement note:', error); + return; + } + } else { + // Nouvelle note + try { + const newNote = await api.post('/api/notes', { + title: 'Nouvelle note', + content: '' + }); + + currentNoteId = newNote.id; + state.currentNote = newNote; + + titleInput.value = newNote.title; + contentTextarea.value = ''; + imageContainer.style.display = 'none'; + noteTodosList.innerHTML = ''; + metadata.textContent = 'Nouvelle note'; + + // Rafraîchir la liste + await loadNotes(); + } catch (error) { + console.error('Erreur création note:', error); + return; + } + } + + modal.style.display = 'flex'; + titleInput.focus(); + titleInput.select(); + + // Auto-save sur les modifications + setupAutoSave(); +} + +function setupAutoSave() { + const titleInput = document.getElementById('noteTitle'); + const contentTextarea = document.getElementById('noteContent'); + + const saveNote = async () => { + if (!currentNoteId) return; + + try { + await api.put(`/api/notes/${currentNoteId}`, { + title: titleInput.value, + content: contentTextarea.value + }); + + // Rafraîchir la liste + await loadNotes(); + } catch (error) { + console.error('Erreur sauvegarde:', error); + } + }; + + const debouncedSave = utils.debounce(saveNote, 1000); + + titleInput.oninput = debouncedSave; + contentTextarea.oninput = debouncedSave; +} + +function renderNoteTodos(todos) { + const list = document.getElementById('noteTodosList'); + list.innerHTML = ''; + + todos.forEach(todo => { + const item = document.createElement('div'); + item.className = 'todo-item'; + item.innerHTML = ` + + ${escapeHtml(todo.text)} + + `; + if (todo.completed) item.classList.add('completed'); + list.appendChild(item); + }); +} + +async function addNoteTodo() { + if (!currentNoteId) return; + + const text = prompt('Texte du todo:'); + if (!text) return; + + try { + await api.post(`/api/notes/${currentNoteId}/todos`, { text }); + const note = await api.get(`/api/notes/${currentNoteId}`); + renderNoteTodos(note.todos || []); + await loadNotes(); + } catch (error) { + console.error('Erreur ajout todo:', error); + } +} + +async function toggleNoteTodo(todoId, completed) { + try { + await api.put(`/api/notes/todos/${todoId}`, { completed }); + const note = await api.get(`/api/notes/${currentNoteId}`); + renderNoteTodos(note.todos || []); + await loadNotes(); + } catch (error) { + console.error('Erreur toggle todo:', error); + } +} + +async function deleteNoteTodo(todoId) { + if (!confirm('Supprimer ce todo ?')) return; + + try { + await api.delete(`/api/notes/todos/${todoId}`); + const note = await api.get(`/api/notes/${currentNoteId}`); + renderNoteTodos(note.todos || []); + await loadNotes(); + } catch (error) { + console.error('Erreur suppression todo:', error); + } +} + +function closeNoteModal() { + document.getElementById('noteModal').style.display = 'none'; + currentNoteId = null; + state.currentNote = null; +} + +async function deleteCurrentNote() { + if (!currentNoteId) return; + if (!confirm('Supprimer cette note ?')) return; + + try { + await api.delete(`/api/notes/${currentNoteId}`); + closeNoteModal(); + await loadNotes(); + } catch (error) { + console.error('Erreur suppression note:', error); + } +} + +async function addNoteImage() { + document.getElementById('imageInput').click(); +} + +async function handleImageUpload(event) { + const file = event.target.files[0]; + if (!file || !currentNoteId) return; + + const formData = new FormData(); + formData.append('image', file); + + try { + const result = await api.uploadFile(`/api/notes/${currentNoteId}/image`, formData); + document.getElementById('noteImage').src = result.url; + document.getElementById('noteImageContainer').style.display = 'block'; + await loadNotes(); + } catch (error) { + console.error('Erreur upload image:', error); + alert('Erreur lors de l\'upload de l\'image'); + } +} + +async function removeNoteImage() { + if (!currentNoteId) return; + if (!confirm('Supprimer l\'image ?')) return; + + try { + await api.delete(`/api/notes/${currentNoteId}/image`); + document.getElementById('noteImageContainer').style.display = 'none'; + await loadNotes(); + } catch (error) { + console.error('Erreur suppression image:', error); + } +} + +// ==================== TODOS GLOBAUX ==================== +async function loadTodos() { + try { + state.todos = await api.get('/api/todos'); + renderTodos(); + } catch (error) { + console.error('Erreur chargement todos:', error); + } +} + +function renderTodos() { + const todoList = document.getElementById('todoList'); + if (!todoList) return; + + todoList.innerHTML = ''; + + const filtered = state.todos.filter(todo => { + if (state.filter === 'active') return !todo.completed; + if (state.filter === 'completed') return todo.completed; + return true; + }); + + filtered.forEach(todo => { + const item = document.createElement('div'); + item.className = 'todo-item' + (todo.completed ? ' completed' : ''); + item.innerHTML = ` + + ${escapeHtml(todo.text)} + + `; + todoList.appendChild(item); + }); + + updateTodoCounter(); +} + +async function addTodo() { + const input = document.getElementById('todoInput'); + const text = input.value.trim(); + + if (!text) return; + + try { + await api.post('/api/todos', { text }); + input.value = ''; + await loadTodos(); + } catch (error) { + console.error('Erreur ajout todo:', error); + } +} + +async function toggleTodo(id, completed) { + try { + await api.put(`/api/todos/${id}`, { completed }); + await loadTodos(); + } catch (error) { + console.error('Erreur toggle todo:', error); + } +} + +async function deleteTodo(id) { + try { + await api.delete(`/api/todos/${id}`); + await loadTodos(); + } catch (error) { + console.error('Erreur suppression todo:', error); + } +} + +function setTodoFilter(filter) { + state.filter = filter; + document.querySelectorAll('.filter-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.filter === filter); + }); + renderTodos(); +} + +function updateTodoCounter() { + const counter = document.getElementById('todoCounter'); + if (!counter) return; + + const remaining = state.todos.filter(t => !t.completed).length; + counter.textContent = `${remaining} tâche${remaining > 1 ? 's' : ''} restante${remaining > 1 ? 's' : ''}`; +} + +// ==================== SEARCH ==================== +function setupSearch() { + const searchInput = document.getElementById('searchInput'); + const searchClear = document.getElementById('searchClear'); + + if (!searchInput) return; + + const performSearch = utils.debounce(() => { + state.searchQuery = searchInput.value.trim(); + renderNotes(); + + if (state.searchQuery) { + searchClear.style.display = 'block'; + } else { + searchClear.style.display = 'none'; + } + }, 300); + + searchInput.addEventListener('input', performSearch); + + if (searchClear) { + searchClear.addEventListener('click', () => { + searchInput.value = ''; + state.searchQuery = ''; + searchClear.style.display = 'none'; + renderNotes(); + }); + } +} + +// ==================== INIT ==================== +async function init() { + // Vérifier l'authentification + await initAuth(); + + // Charger les données + await Promise.all([loadNotes(), loadTodos()]); + + // Setup search + setupSearch(); + + // Event listeners + const newNoteBtn = document.getElementById('newNoteBtn'); + if (newNoteBtn) { + newNoteBtn.addEventListener('click', () => openNoteModal()); + } + + const closeModal = document.getElementById('closeModal'); + if (closeModal) { + closeModal.addEventListener('click', closeNoteModal); + } + + const deleteNote = document.getElementById('deleteNote'); + if (deleteNote) { + deleteNote.addEventListener('click', deleteCurrentNote); + } + + const addImageBtn = document.getElementById('addImageBtn'); + if (addImageBtn) { + addImageBtn.addEventListener('click', addNoteImage); + } + + const imageInput = document.getElementById('imageInput'); + if (imageInput) { + imageInput.addEventListener('change', handleImageUpload); + } + + const removeImage = document.getElementById('removeImage'); + if (removeImage) { + removeImage.addEventListener('click', removeNoteImage); + } + + const addNoteTodoBtn = document.getElementById('addNoteTodo'); + if (addNoteTodoBtn) { + addNoteTodoBtn.addEventListener('click', addNoteTodo); + } + + const addTodoBtn = document.getElementById('addTodoBtn'); + if (addTodoBtn) { + addTodoBtn.addEventListener('click', addTodo); + } + + const todoInput = document.getElementById('todoInput'); + if (todoInput) { + todoInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') addTodo(); + }); + } + + // Todo filters + document.querySelectorAll('.filter-btn').forEach(btn => { + btn.addEventListener('click', () => setTodoFilter(btn.dataset.filter)); + }); + + // Close modal on backdrop click + const noteModal = document.getElementById('noteModal'); + if (noteModal) { + noteModal.addEventListener('click', (e) => { + if (e.target === noteModal) closeNoteModal(); + }); + } + + // Close modal on Escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeNoteModal(); + document.getElementById('adminModal').style.display = 'none'; + } + }); +} + +// Démarrer l'application +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..f5dae31 --- /dev/null +++ b/public/login.html @@ -0,0 +1,222 @@ + + + + + + Connexion - NoteFlow + + + + + + + + + diff --git a/routes/auth.routes.js b/routes/auth.routes.js index 0c1c7f6..f0bb807 100644 --- a/routes/auth.routes.js +++ b/routes/auth.routes.js @@ -1,67 +1,99 @@ +// Routes d'authentification const express = require('express'); const router = express.Router(); const bcrypt = require('bcrypt'); -const jwt = require('jsonwebtoken'); const { body, validationResult } = require('express-validator'); -const rateLimit = require('express-rate-limit'); -const { db, logger } = require('../config/database'); -const { authMiddleware } = require('../middleware/auth'); -// Rate limiting -const loginLimiter = rateLimit({ - windowMs: 60 * 1000, // 1 minute - max: 10, // 10 requests per minute - message: { message: 'Too many login attempts, please try again later' } +const { getOne } = require('../config/database'); +const { generateToken, authenticateToken } = require('../middleware/auth'); +const logger = require('../config/logger'); + +/** + * POST /api/auth/login + * Connexion utilisateur + */ +router.post('/login', + [ + body('username').trim().notEmpty().withMessage('Le nom d\'utilisateur est requis'), + body('password').notEmpty().withMessage('Le mot de passe est requis') + ], + async (req, res) => { + try { + // Valider les entrées + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Données invalides', details: errors.array() }); + } + + const { username, password } = req.body; + + // Rechercher l'utilisateur + const user = await getOne( + 'SELECT id, username, password_hash, is_admin FROM users WHERE username = ?', + [username] + ); + + if (!user) { + logger.warn(`Tentative de connexion échouée pour l'utilisateur: ${username}`); + return res.status(401).json({ error: 'Identifiants invalides' }); + } + + // Vérifier le mot de passe + const validPassword = await bcrypt.compare(password, user.password_hash); + if (!validPassword) { + logger.warn(`Mot de passe incorrect pour l'utilisateur: ${username}`); + return res.status(401).json({ error: 'Identifiants invalides' }); + } + + // Générer le token JWT + const token = generateToken(user); + + logger.info(`Connexion réussie pour l'utilisateur: ${username}`); + + res.json({ + token, + user: { + id: user.id, + username: user.username, + is_admin: user.is_admin + } + }); + } catch (error) { + logger.error('Erreur lors de la connexion:', error); + res.status(500).json({ error: 'Erreur serveur lors de la connexion' }); + } + } +); + +/** + * POST /api/auth/logout + * Déconnexion (côté client uniquement, token invalidé côté client) + */ +router.post('/logout', authenticateToken, (req, res) => { + logger.info(`Déconnexion de l'utilisateur: ${req.user.username}`); + res.json({ message: 'Déconnexion réussie' }); }); -// Login validation -const loginValidation = [ - body('username').trim().notEmpty().escape(), - body('password').trim().notEmpty() -]; - -// Login route -router.post('/login', loginLimiter, loginValidation, async (req, res) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { username, password } = req.body; - - db.get('SELECT * FROM users WHERE username = ?', [username], async (err, user) => { - if (err) { - logger.error('Login error:', err); - return res.status(500).json({ message: 'Server error' }); - } - - if (!user) { - return res.status(401).json({ message: 'Invalid credentials' }); - } - - const validPassword = await bcrypt.compare(password, user.password_hash); - if (!validPassword) { - return res.status(401).json({ message: 'Invalid credentials' }); - } - - const token = jwt.sign( - { id: user.id, username: user.username, is_admin: user.is_admin }, - process.env.JWT_SECRET, - { expiresIn: '24h' } +/** + * GET /api/auth/me + * Récupérer les informations de l'utilisateur connecté + */ +router.get('/me', authenticateToken, async (req, res) => { + try { + const user = await getOne( + 'SELECT id, username, is_admin, created_at FROM users WHERE id = ?', + [req.user.id] ); - res.json({ token, user: { id: user.id, username: user.username, is_admin: user.is_admin } }); - }); -}); + if (!user) { + return res.status(404).json({ error: 'Utilisateur non trouvé' }); + } -// Get current user -router.get('/me', authMiddleware, (req, res) => { - res.json(req.user); -}); - -// Logout (client-side only, just for completeness) -router.post('/logout', (req, res) => { - res.json({ message: 'Logged out successfully' }); + res.json(user); + } catch (error) { + logger.error('Erreur lors de la récupération des informations utilisateur:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } }); module.exports = router; \ No newline at end of file diff --git a/routes/notes.routes.js b/routes/notes.routes.js index c929a90..931af4c 100644 --- a/routes/notes.routes.js +++ b/routes/notes.routes.js @@ -1,204 +1,442 @@ -const express = require('express'); -const router = express.Router(); -const multer = require('multer'); -const path = require('path'); -const { v4: uuidv4 } = require('uuid'); -const { body, validationResult } = require('express-validator'); -const { db, logger } = require('../config/database'); -const { authMiddleware } = require('../middleware/auth'); - -// Configure multer for image uploads -const storage = multer.diskStorage({ - destination: 'public/uploads/', - filename: (req, file, cb) => { - const uniqueName = `${uuidv4()}${path.extname(file.originalname)}`; - cb(null, uniqueName); - } -}); - -const upload = multer({ - storage, - limits: { fileSize: 5 * 1024 * 1024 }, // 5MB - fileFilter: (req, file, cb) => { - const allowedTypes = /jpeg|jpg|png|gif/; - const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); - const mimetype = allowedTypes.test(file.mimetype); - if (extname && mimetype) { - cb(null, true); - } else { - cb(new Error('Only image files are allowed')); - } - } -}); - -// Note validation -const noteValidation = [ - body('title').trim().notEmpty().escape(), - body('content').trim().optional().escape(), - body('archived').isBoolean().optional() -]; - -// Get all notes for user -router.get('/', authMiddleware, (req, res) => { - const query = ` - SELECT n.*, - GROUP_CONCAT(t.id || ':' || t.text || ':' || t.completed) as todos, - GROUP_CONCAT(i.id || ':' || i.filename) as images - FROM notes n - LEFT JOIN todos t ON n.id = t.note_id - LEFT JOIN images i ON n.id = i.note_id - WHERE n.user_id = ? - GROUP BY n.id - ORDER BY n.created_at DESC - `; - - db.all(query, [req.user.id], (err, notes) => { - if (err) { - logger.error('Error fetching notes:', err); - return res.status(500).json({ message: 'Server error' }); - } - - // Process the results to format todos and images - const processedNotes = notes.map(note => ({ - ...note, - todos: note.todos ? note.todos.split(',').map(todo => { - const [id, text, completed] = todo.split(':'); - return { id, text, completed: completed === '1' }; - }) : [], - images: note.images ? note.images.split(',').map(image => { - const [id, filename] = image.split(':'); - return { id, filename }; - }) : [] - })); - - res.json(processedNotes); - }); -}); - -// Create note -router.post('/', authMiddleware, noteValidation, (req, res) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { title, content, todos = [] } = req.body; - - db.run( - 'INSERT INTO notes (user_id, title, content) VALUES (?, ?, ?)', - [req.user.id, title, content], - function(err) { - if (err) { - logger.error('Error creating note:', err); - return res.status(500).json({ message: 'Server error' }); - } - - const noteId = this.lastID; - - // Insert todos if any - if (todos.length > 0) { - const todoValues = todos.map((todo, index) => - `(${noteId}, '${todo.text}', ${todo.completed ? 1 : 0}, ${index})` - ).join(','); - - db.run(`INSERT INTO todos (note_id, text, completed, position) VALUES ${todoValues}`); - } - - res.status(201).json({ id: noteId, title, content, todos: [] }); - } - ); -}); - -// Update note -router.put('/:id', authMiddleware, noteValidation, (req, res) => { - const noteId = req.params.id; - const { title, content, archived, todos = [] } = req.body; - - db.run( - 'UPDATE notes SET title = ?, content = ?, archived = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', - [title, content, archived ? 1 : 0, noteId, req.user.id], - function(err) { - if (err) { - logger.error('Error updating note:', err); - return res.status(500).json({ message: 'Server error' }); - } - - // Update todos - db.run('DELETE FROM todos WHERE note_id = ?', [noteId], (err) => { - if (err) { - logger.error('Error deleting todos:', err); - return; - } - - if (todos.length > 0) { - const todoValues = todos.map((todo, index) => - `(${noteId}, '${todo.text}', ${todo.completed ? 1 : 0}, ${index})` - ).join(','); - - db.run(`INSERT INTO todos (note_id, text, completed, position) VALUES ${todoValues}`); - } - }); - - res.json({ message: 'Note updated successfully' }); - } - ); -}); - -// Delete note -router.delete('/:id', authMiddleware, (req, res) => { - const noteId = req.params.id; - - db.run('DELETE FROM notes WHERE id = ? AND user_id = ?', [noteId, req.user.id], (err) => { - if (err) { - logger.error('Error deleting note:', err); - return res.status(500).json({ message: 'Server error' }); - } - - // Cleanup related records - db.run('DELETE FROM todos WHERE note_id = ?', [noteId]); - db.run('DELETE FROM images WHERE note_id = ?', [noteId]); - - res.json({ message: 'Note deleted successfully' }); - }); -}); - -// Upload image -router.post('/:id/images', authMiddleware, upload.single('image'), (req, res) => { - if (!req.file) { - return res.status(400).json({ message: 'No image file provided' }); - } - - const noteId = req.params.id; - const filename = req.file.filename; - - db.run( - 'INSERT INTO images (note_id, filename) VALUES (?, ?)', - [noteId, filename], - function(err) { - if (err) { - logger.error('Error saving image record:', err); - return res.status(500).json({ message: 'Server error' }); - } - res.status(201).json({ id: this.lastID, filename }); - } - ); -}); - -// Delete image -router.delete('/:noteId/images/:imageId', authMiddleware, (req, res) => { - const { noteId, imageId } = req.params; - - db.run( - 'DELETE FROM images WHERE id = ? AND note_id = ?', - [imageId, noteId], - (err) => { - if (err) { - logger.error('Error deleting image:', err); - return res.status(500).json({ message: 'Server error' }); - } - res.json({ message: 'Image deleted successfully' }); - } - ); -}); - -module.exports = router; \ No newline at end of file +// Routes de gestion des notes +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const { body, validationResult } = require('express-validator'); + +const { getAll, getOne, runQuery } = require('../config/database'); +const { authenticateToken } = require('../middleware/auth'); +const logger = require('../config/logger'); + +// Configuration de multer pour l'upload d'images +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const uploadsDir = path.join(__dirname, '../public/uploads'); + cb(null, uploadsDir); + }, + filename: (req, file, cb) => { + const uniqueName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}${path.extname(file.originalname)}`; + cb(null, uniqueName); + } +}); + +const upload = multer({ + storage, + limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024 }, // 5MB + fileFilter: (req, file, cb) => { + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Type de fichier non autorisé. Utilisez JPEG, PNG, WebP ou GIF.')); + } + } +}); + +// Toutes les routes nécessitent authentification +router.use(authenticateToken); + +/** + * GET /api/notes + * Liste toutes les notes de l'utilisateur + */ +router.get('/', async (req, res) => { + try { + const notes = await getAll(` + SELECT + n.id, n.title, n.content, n.image_filename, + n.created_at, n.updated_at, + COUNT(DISTINCT nt.id) as todos_count + FROM notes n + LEFT JOIN note_todos nt ON n.id = nt.note_id + WHERE n.user_id = ? + GROUP BY n.id + ORDER BY n.updated_at DESC + `, [req.user.id]); + + res.json(notes); + } catch (error) { + logger.error('Erreur lors de la récupération des notes:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * GET /api/notes/:id + * Récupérer une note avec ses todos + */ +router.get('/:id', async (req, res) => { + try { + const note = await getOne(` + SELECT id, title, content, image_filename, created_at, updated_at + FROM notes + WHERE id = ? AND user_id = ? + `, [req.params.id, req.user.id]); + + if (!note) { + return res.status(404).json({ error: 'Note non trouvée' }); + } + + // Récupérer les todos de la note + const todos = await getAll(` + SELECT id, text, completed, position + FROM note_todos + WHERE note_id = ? + ORDER BY position, id + `, [req.params.id]); + + note.todos = todos; + + res.json(note); + } catch (error) { + logger.error('Erreur lors de la récupération de la note:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * POST /api/notes + * Créer une nouvelle note + */ +router.post('/', + [ + body('title').trim().notEmpty().withMessage('Le titre est requis'), + body('content').optional() + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Données invalides', details: errors.array() }); + } + + const { title, content } = req.body; + + const result = await runQuery(` + INSERT INTO notes (user_id, title, content) + VALUES (?, ?, ?) + `, [req.user.id, title, content || '']); + + logger.info(`Note créée: ${title} (ID: ${result.id}) par ${req.user.username}`); + + res.status(201).json({ + id: result.id, + title, + content: content || '', + image_filename: null, + todos: [] + }); + } catch (error) { + logger.error('Erreur lors de la création de la note:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } + } +); + +/** + * PUT /api/notes/:id + * Modifier une note + */ +router.put('/:id', + [ + body('title').optional().trim().notEmpty(), + body('content').optional() + ], + async (req, res) => { + try { + const { title, content } = req.body; + + // Vérifier que la note appartient à l'utilisateur + const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!note) { + return res.status(404).json({ error: 'Note non trouvée' }); + } + + const updates = []; + const params = []; + + if (title !== undefined) { + updates.push('title = ?'); + params.push(title); + } + if (content !== undefined) { + updates.push('content = ?'); + params.push(content); + } + + if (updates.length > 0) { + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(req.params.id); + + await runQuery(`UPDATE notes SET ${updates.join(', ')} WHERE id = ?`, params); + } + + res.json({ message: 'Note modifiée avec succès' }); + } catch (error) { + logger.error('Erreur lors de la modification de la note:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } + } +); + +/** + * DELETE /api/notes/:id + * Supprimer une note + */ +router.delete('/:id', async (req, res) => { + try { + // Vérifier que la note appartient à l'utilisateur + const note = await getOne('SELECT id, image_filename FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!note) { + return res.status(404).json({ error: 'Note non trouvée' }); + } + + // Supprimer l'image si elle existe + if (note.image_filename) { + const imagePath = path.join(__dirname, '../public/uploads', note.image_filename); + if (fs.existsSync(imagePath)) { + fs.unlinkSync(imagePath); + } + } + + await runQuery('DELETE FROM notes WHERE id = ?', [req.params.id]); + + logger.info(`Note supprimée (ID: ${req.params.id}) par ${req.user.username}`); + + res.json({ message: 'Note supprimée avec succès' }); + } catch (error) { + logger.error('Erreur lors de la suppression de la note:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * POST /api/notes/:id/image + * Ajouter une image à une note + */ +router.post('/:id/image', upload.single('image'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'Aucune image fournie' }); + } + + // Vérifier que la note appartient à l'utilisateur + const note = await getOne('SELECT id, image_filename FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!note) { + // Supprimer le fichier uploadé + fs.unlinkSync(req.file.path); + return res.status(404).json({ error: 'Note non trouvée' }); + } + + // Supprimer l'ancienne image si elle existe + if (note.image_filename) { + const oldImagePath = path.join(__dirname, '../public/uploads', note.image_filename); + if (fs.existsSync(oldImagePath)) { + fs.unlinkSync(oldImagePath); + } + } + + // Mettre à jour la note avec le nouveau fichier + await runQuery('UPDATE notes SET image_filename = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.file.filename, req.params.id]); + + res.json({ + message: 'Image ajoutée avec succès', + filename: req.file.filename, + url: `/uploads/${req.file.filename}` + }); + } catch (error) { + logger.error('Erreur lors de l\'ajout de l\'image:', error); + if (req.file) { + fs.unlinkSync(req.file.path); + } + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * DELETE /api/notes/:id/image + * Supprimer l'image d'une note + */ +router.delete('/:id/image', async (req, res) => { + try { + const note = await getOne('SELECT id, image_filename FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!note) { + return res.status(404).json({ error: 'Note non trouvée' }); + } + + if (!note.image_filename) { + return res.status(400).json({ error: 'Aucune image à supprimer' }); + } + + // Supprimer le fichier + const imagePath = path.join(__dirname, '../public/uploads', note.image_filename); + if (fs.existsSync(imagePath)) { + fs.unlinkSync(imagePath); + } + + await runQuery('UPDATE notes SET image_filename = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.params.id]); + + res.json({ message: 'Image supprimée avec succès' }); + } catch (error) { + logger.error('Erreur lors de la suppression de l\'image:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * POST /api/notes/:id/todos + * Ajouter un todo à une note + */ +router.post('/:id/todos', + [body('text').trim().notEmpty().withMessage('Le texte est requis')], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Données invalides', details: errors.array() }); + } + + // Vérifier que la note appartient à l'utilisateur + const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!note) { + return res.status(404).json({ error: 'Note non trouvée' }); + } + + const { text } = req.body; + + const result = await runQuery(` + INSERT INTO note_todos (note_id, text, position) + VALUES (?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM note_todos WHERE note_id = ?)) + `, [req.params.id, text, req.params.id]); + + await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.params.id]); + + res.status(201).json({ + id: result.id, + text, + completed: false, + position: 0 + }); + } catch (error) { + logger.error('Erreur lors de l\'ajout du todo:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } + } +); + +/** + * PUT /api/notes/todos/:todoId + * Modifier un todo dans une note + */ +router.put('/todos/:todoId', + [ + body('text').optional().trim().notEmpty(), + body('completed').optional().isBoolean(), + body('position').optional().isInt() + ], + async (req, res) => { + try { + const { text, completed, position } = req.body; + + // Vérifier que le todo appartient à une note de l'utilisateur + const todo = await getOne(` + SELECT nt.id, nt.note_id + FROM note_todos nt + JOIN notes n ON nt.note_id = n.id + WHERE nt.id = ? AND n.user_id = ? + `, [req.params.todoId, req.user.id]); + + if (!todo) { + return res.status(404).json({ error: 'Todo non trouvé' }); + } + + const updates = []; + const params = []; + + if (text !== undefined) { + updates.push('text = ?'); + params.push(text); + } + if (completed !== undefined) { + updates.push('completed = ?'); + params.push(completed ? 1 : 0); + } + if (position !== undefined) { + updates.push('position = ?'); + params.push(position); + } + + if (updates.length > 0) { + params.push(req.params.todoId); + await runQuery(`UPDATE note_todos SET ${updates.join(', ')} WHERE id = ?`, params); + await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [todo.note_id]); + } + + res.json({ message: 'Todo modifié avec succès' }); + } catch (error) { + logger.error('Erreur lors de la modification du todo:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } + } +); + +/** + * DELETE /api/notes/todos/:todoId + * Supprimer un todo d'une note + */ +router.delete('/todos/:todoId', async (req, res) => { + try { + // Vérifier que le todo appartient à une note de l'utilisateur + const todo = await getOne(` + SELECT nt.id, nt.note_id + FROM note_todos nt + JOIN notes n ON nt.note_id = n.id + WHERE nt.id = ? AND n.user_id = ? + `, [req.params.todoId, req.user.id]); + + if (!todo) { + return res.status(404).json({ error: 'Todo non trouvé' }); + } + + await runQuery('DELETE FROM note_todos WHERE id = ?', [req.params.todoId]); + await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [todo.note_id]); + + res.json({ message: 'Todo supprimé avec succès' }); + } catch (error) { + logger.error('Erreur lors de la suppression du todo:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * GET /api/search + * Rechercher des notes + */ +const searchNotes = async (req, res) => { + try { + const query = req.query.q; + if (!query || query.trim().length === 0) { + return res.json([]); + } + + const searchTerm = `%${query}%`; + + const notes = await getAll(` + SELECT id, title, content, image_filename, created_at, updated_at + FROM notes + WHERE user_id = ? AND (title LIKE ? OR content LIKE ?) + ORDER BY updated_at DESC + LIMIT 50 + `, [req.user.id, searchTerm, searchTerm]); + + res.json(notes); + } catch (error) { + logger.error('Erreur lors de la recherche:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}; + +module.exports = router; +module.exports.searchNotes = searchNotes; diff --git a/routes/todos.routes.js b/routes/todos.routes.js new file mode 100644 index 0000000..3f920de --- /dev/null +++ b/routes/todos.routes.js @@ -0,0 +1,140 @@ +// Routes de gestion des todos globaux (sidebar) +const express = require('express'); +const router = express.Router(); +const { body, validationResult } = require('express-validator'); + +const { getAll, getOne, runQuery } = require('../config/database'); +const { authenticateToken } = require('../middleware/auth'); +const logger = require('../config/logger'); + +// Toutes les routes nécessitent authentification +router.use(authenticateToken); + +/** + * GET /api/todos + * Liste tous les todos globaux de l'utilisateur + */ +router.get('/', async (req, res) => { + try { + const todos = await getAll(` + SELECT id, text, completed, created_at + FROM global_todos + WHERE user_id = ? + ORDER BY created_at DESC + `, [req.user.id]); + + res.json(todos); + } catch (error) { + logger.error('Erreur lors de la récupération des todos:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * POST /api/todos + * Créer un nouveau todo global + */ +router.post('/', + [body('text').trim().notEmpty().withMessage('Le texte est requis')], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Données invalides', details: errors.array() }); + } + + const { text } = req.body; + + const result = await runQuery(` + INSERT INTO global_todos (user_id, text) + VALUES (?, ?) + `, [req.user.id, text]); + + logger.info(`Todo global créé (ID: ${result.id}) par ${req.user.username}`); + + res.status(201).json({ + id: result.id, + text, + completed: false, + created_at: new Date().toISOString() + }); + } catch (error) { + logger.error('Erreur lors de la création du todo:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } + } +); + +/** + * PUT /api/todos/:id + * Modifier un todo global + */ +router.put('/:id', + [ + body('text').optional().trim().notEmpty(), + body('completed').optional().isBoolean() + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Données invalides', details: errors.array() }); + } + + const { text, completed } = req.body; + + // Vérifier que le todo appartient à l'utilisateur + const todo = await getOne('SELECT id FROM global_todos WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!todo) { + return res.status(404).json({ error: 'Todo non trouvé' }); + } + + const updates = []; + const params = []; + + if (text !== undefined) { + updates.push('text = ?'); + params.push(text); + } + if (completed !== undefined) { + updates.push('completed = ?'); + params.push(completed ? 1 : 0); + } + + if (updates.length > 0) { + params.push(req.params.id); + await runQuery(`UPDATE global_todos SET ${updates.join(', ')} WHERE id = ?`, params); + } + + res.json({ message: 'Todo modifié avec succès' }); + } catch (error) { + logger.error('Erreur lors de la modification du todo:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } + } +); + +/** + * DELETE /api/todos/:id + * Supprimer un todo global + */ +router.delete('/:id', async (req, res) => { + try { + // Vérifier que le todo appartient à l'utilisateur + const todo = await getOne('SELECT id FROM global_todos WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!todo) { + return res.status(404).json({ error: 'Todo non trouvé' }); + } + + await runQuery('DELETE FROM global_todos WHERE id = ?', [req.params.id]); + + logger.info(`Todo global supprimé (ID: ${req.params.id}) par ${req.user.username}`); + + res.json({ message: 'Todo supprimé avec succès' }); + } catch (error) { + logger.error('Erreur lors de la suppression du todo:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +module.exports = router; diff --git a/routes/users.routes.js b/routes/users.routes.js index 9a1af0d..8973e4d 100644 --- a/routes/users.routes.js +++ b/routes/users.routes.js @@ -1,102 +1,156 @@ +// Routes de gestion des utilisateurs (admin uniquement) const express = require('express'); const router = express.Router(); const bcrypt = require('bcrypt'); const { body, validationResult } = require('express-validator'); -const { db, logger } = require('../config/database'); -const { authMiddleware, adminMiddleware } = require('../middleware/auth'); -// User validation rules -const userValidation = [ - body('username').trim().isLength({ min: 3 }).escape(), - body('password').isLength({ min: 6 }), - body('is_admin').isBoolean().optional() -]; +const { getAll, getOne, runQuery } = require('../config/database'); +const { authenticateToken, requireAdmin } = require('../middleware/auth'); +const logger = require('../config/logger'); -// Get all users (admin only) -router.get('/', authMiddleware, adminMiddleware, (req, res) => { - db.all('SELECT id, username, is_admin, created_at FROM users', (err, users) => { - if (err) { - logger.error('Error fetching users:', err); - return res.status(500).json({ message: 'Server error' }); - } - res.json(users); - }); -}); - -// Create user (admin only) -router.post('/', authMiddleware, adminMiddleware, userValidation, async (req, res) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { username, password, is_admin } = req.body; +// Toutes les routes nécessitent authentification et droits admin +router.use(authenticateToken); +router.use(requireAdmin); +/** + * GET /api/users + * Liste tous les utilisateurs + */ +router.get('/', async (req, res) => { try { - const hash = await bcrypt.hash(password, 10); - db.run( - 'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)', - [username, hash, is_admin ? 1 : 0], - function(err) { - if (err) { - if (err.message.includes('UNIQUE constraint failed')) { - return res.status(400).json({ message: 'Username already exists' }); - } - logger.error('Error creating user:', err); - return res.status(500).json({ message: 'Server error' }); - } - res.status(201).json({ id: this.lastID, username, is_admin }); - } + const users = await getAll( + 'SELECT id, username, is_admin, created_at FROM users ORDER BY created_at DESC' ); - } catch (err) { - logger.error('Password hashing error:', err); - res.status(500).json({ message: 'Server error' }); + res.json(users); + } catch (error) { + logger.error('Erreur lors de la récupération des utilisateurs:', error); + res.status(500).json({ error: 'Erreur serveur' }); } }); -// Update user (admin only) -router.put('/:id', authMiddleware, adminMiddleware, async (req, res) => { - const userId = req.params.id; - const { password, is_admin } = req.body; +/** + * POST /api/users + * Créer un nouvel utilisateur + */ +router.post('/', + [ + body('username').trim().isLength({ min: 3 }).withMessage('Le nom d\'utilisateur doit contenir au moins 3 caractères'), + body('password').isLength({ min: 6 }).withMessage('Le mot de passe doit contenir au moins 6 caractères'), + body('is_admin').optional().isBoolean() + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Données invalides', details: errors.array() }); + } - // Prevent modifying the main admin user - if (userId === '1') { - return res.status(403).json({ message: 'Cannot modify main admin user' }); + const { username, password, is_admin } = req.body; + + // Vérifier si l'utilisateur existe déjà + const existingUser = await getOne('SELECT id FROM users WHERE username = ?', [username]); + if (existingUser) { + return res.status(400).json({ error: 'Ce nom d\'utilisateur existe déjà' }); + } + + // Hasher le mot de passe + const passwordHash = await bcrypt.hash(password, 12); + + // Créer l'utilisateur + const result = await runQuery( + 'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)', + [username, passwordHash, is_admin ? 1 : 0] + ); + + logger.info(`Utilisateur créé: ${username} (ID: ${result.id})`); + + res.status(201).json({ + id: result.id, + username, + is_admin: is_admin || false + }); + } catch (error) { + logger.error('Erreur lors de la création de l\'utilisateur:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } } +); +/** + * PUT /api/users/:id + * Modifier un utilisateur + */ +router.put('/:id', + [ + body('password').optional().isLength({ min: 6 }).withMessage('Le mot de passe doit contenir au moins 6 caractères'), + body('is_admin').optional().isBoolean() + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Données invalides', details: errors.array() }); + } + + const userId = req.params.id; + const { password, is_admin } = req.body; + + // Vérifier que l'utilisateur existe + const user = await getOne('SELECT id, username FROM users WHERE id = ?', [userId]); + if (!user) { + return res.status(404).json({ error: 'Utilisateur non trouvé' }); + } + + // Mettre à jour le mot de passe si fourni + if (password) { + const passwordHash = await bcrypt.hash(password, 12); + await runQuery('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, userId]); + } + + // Mettre à jour le statut admin si fourni + if (typeof is_admin !== 'undefined') { + await runQuery('UPDATE users SET is_admin = ? WHERE id = ?', [is_admin ? 1 : 0, userId]); + } + + logger.info(`Utilisateur modifié: ${user.username} (ID: ${userId})`); + + res.json({ message: 'Utilisateur modifié avec succès' }); + } catch (error) { + logger.error('Erreur lors de la modification de l\'utilisateur:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } + } +); + +/** + * DELETE /api/users/:id + * Supprimer un utilisateur + */ +router.delete('/:id', async (req, res) => { try { - if (password) { - const hash = await bcrypt.hash(password, 10); - db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, userId]); - } - - if (typeof is_admin !== 'undefined') { - db.run('UPDATE users SET is_admin = ? WHERE id = ?', [is_admin ? 1 : 0, userId]); + const userId = req.params.id; + + // Ne pas permettre la suppression de soi-même + if (parseInt(userId) === req.user.id) { + return res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte' }); } - res.json({ message: 'User updated successfully' }); - } catch (err) { - logger.error('Error updating user:', err); - res.status(500).json({ message: 'Server error' }); + // Vérifier que l'utilisateur existe + const user = await getOne('SELECT id, username FROM users WHERE id = ?', [userId]); + if (!user) { + return res.status(404).json({ error: 'Utilisateur non trouvé' }); + } + + // Supprimer l'utilisateur (les notes et todos seront supprimés en cascade) + await runQuery('DELETE FROM users WHERE id = ?', [userId]); + + logger.info(`Utilisateur supprimé: ${user.username} (ID: ${userId})`); + + res.json({ message: 'Utilisateur supprimé avec succès' }); + } catch (error) { + logger.error('Erreur lors de la suppression de l\'utilisateur:', error); + res.status(500).json({ error: 'Erreur serveur' }); } }); -// Delete user (admin only) -router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => { - const userId = req.params.id; - - // Prevent deleting the main admin user - if (userId === '1') { - return res.status(403).json({ message: 'Cannot delete main admin user' }); - } - - db.run('DELETE FROM users WHERE id = ?', [userId], (err) => { - if (err) { - logger.error('Error deleting user:', err); - return res.status(500).json({ message: 'Server error' }); - } - res.json({ message: 'User deleted successfully' }); - }); -}); - module.exports = router; \ No newline at end of file diff --git a/server.js b/server.js index 8b737fb..48c157d 100644 --- a/server.js +++ b/server.js @@ -1,76 +1,158 @@ +// Serveur Express principal pour l'application NoteFlow const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); const path = require('path'); -const winston = require('winston'); const fs = require('fs'); -const { db } = require('./config/database'); -// Ensure uploads directory exists -const uploadsDir = path.join(__dirname, 'public/uploads'); -if (!fs.existsSync(uploadsDir)) { - fs.mkdirSync(uploadsDir, { recursive: true }); -} +const logger = require('./config/logger'); +const { initDatabase } = require('./config/database'); -// Configure logger -const logger = winston.createLogger({ - level: 'info', - format: winston.format.combine( - winston.format.timestamp(), - winston.format.json() - ), - transports: [ - new winston.transports.Console() - ] +// Créer l'application Express +const app = express(); +const PORT = process.env.PORT || 2222; + +// Créer les dossiers nécessaires +const dataDir = path.join(__dirname, 'data'); +const uploadsDir = path.join(__dirname, 'public', 'uploads'); + +[dataDir, uploadsDir].forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + logger.info(`Dossier créé: ${dir}`); + } }); -// Create Express app -const app = express(); - -// Middleware -app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], - styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], - fontSrc: ["'self'", "https://fonts.gstatic.com"], - imgSrc: ["'self'", "data:"], - connectSrc: ["'self'"] - } - } -})); -app.use(cors()); -app.use(express.json()); -app.use(express.static('public')); - -// Set JWT_SECRET environment variable if not set -if (!process.env.JWT_SECRET) { - process.env.JWT_SECRET = 'default_development_secret'; - logger.warn('JWT_SECRET not set, using default (insecure) value'); +// Créer un fichier .gitkeep dans uploads pour le tracking git +const gitkeepPath = path.join(uploadsDir, '.gitkeep'); +if (!fs.existsSync(gitkeepPath)) { + fs.writeFileSync(gitkeepPath, ''); } -// Routes +// Configuration de la sécurité avec Helmet +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:", "blob:"], + fontSrc: ["'self'"], + connectSrc: ["'self'"] + } + } +})); + +// CORS - À ajuster selon vos besoins +app.use(cors({ + origin: process.env.CORS_ORIGIN || '*', + credentials: true +})); + +// Body parsers +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Rate limiting sur les endpoints d'authentification +const authLimiter = rateLimit({ + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes par défaut + max: parseInt(process.env.RATE_LIMIT_MAX) || 100, // 100 requêtes par fenêtre + message: { error: 'Trop de tentatives, veuillez réessayer plus tard.' }, + standardHeaders: true, + legacyHeaders: false +}); + +app.use('/api/auth', authLimiter); + +// Servir les fichiers statiques +app.use(express.static(path.join(__dirname, 'public'))); +app.use('/uploads', express.static(uploadsDir)); + +// Healthcheck endpoint pour Docker +app.get('/health', (req, res) => { + res.status(200).json({ + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime() + }); +}); + +// Routes API app.use('/api/auth', require('./routes/auth.routes')); app.use('/api/users', require('./routes/users.routes')); app.use('/api/notes', require('./routes/notes.routes')); +app.use('/api/todos', require('./routes/todos.routes')); -// Serve static files -app.use('/uploads', express.static(path.join(__dirname, 'public/uploads'))); +// Route de recherche +app.get('/api/search', require('./routes/notes.routes').searchNotes); -// SPA fallback +// SPA fallback - Servir index.html pour toutes les autres routes app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, 'public/index.html')); + // Ne pas servir index.html pour les requêtes d'API + if (req.path.startsWith('/api/')) { + return res.status(404).json({ error: 'Endpoint non trouvé' }); + } + res.sendFile(path.join(__dirname, 'public', 'index.html')); }); -// Error handling +// Gestionnaire d'erreurs global app.use((err, req, res, next) => { - logger.error('Unhandled error:', err); - res.status(500).json({ message: 'Internal server error' }); + logger.error('Erreur non gérée:', { + error: err.message, + stack: err.stack, + path: req.path, + method: req.method + }); + + // Ne pas exposer les détails de l'erreur en production + const errorMessage = process.env.NODE_ENV === 'production' + ? 'Une erreur est survenue' + : err.message; + + res.status(err.status || 500).json({ + error: errorMessage + }); }); -// Start server -const PORT = process.env.PORT || 2222; -app.listen(PORT, () => { - logger.info(`Server running on port ${PORT}`); -}); \ No newline at end of file +// Initialiser la base de données et démarrer le serveur +async function startServer() { + try { + // Initialiser la base de données + await initDatabase(); + logger.info('✓ Base de données initialisée avec succès'); + + // Démarrer le serveur + app.listen(PORT, '0.0.0.0', () => { + logger.info('═════════════════════════════════════════════'); + logger.info(`✓ Serveur NoteFlow démarré sur le port ${PORT}`); + logger.info(`✓ Environnement: ${process.env.NODE_ENV || 'development'}`); + logger.info(`✓ URL: http://localhost:${PORT}`); + logger.info('═════════════════════════════════════════════'); + + if (process.env.NODE_ENV !== 'production') { + logger.info('Credentials par défaut:'); + logger.info(' Username: admin'); + logger.info(' Password: admin'); + logger.info('═════════════════════════════════════════════'); + } + }); + } catch (error) { + logger.error('Erreur lors du démarrage du serveur:', error); + process.exit(1); + } +} + +// Gestion propre de l'arrêt +process.on('SIGTERM', () => { + logger.info('Signal SIGTERM reçu, arrêt du serveur...'); + process.exit(0); +}); + +process.on('SIGINT', () => { + logger.info('Signal SIGINT reçu, arrêt du serveur...'); + process.exit(0); +}); + +// Démarrer l'application +startServer(); \ No newline at end of file