Création complète de l'application NoteFlow - Notes & Todo List

Application web Dockerisée moderne avec:
- Backend Node.js + Express + SQLite
- Frontend HTML/CSS/JavaScript vanilla
- Authentification JWT + bcrypt
- Gestion des notes avec images et todos intégrés
- Sidebar todos globaux avec filtres
- Interface utilisateur fluide avec animations CSS
- Administration des utilisateurs
- Sécurité production-ready (Helmet, rate limiting, validation)
- Configuration Docker complète
- Documentation README détaillée

Stack technique:
- Node.js 20 Alpine
- SQLite3 avec indexes optimisés
- Multer pour upload d'images
- Winston pour logging
- Express-validator pour validation
- Architecture modulaire (routes, middleware, config)

Fonctionnalités:
- Notes: CRUD complet, recherche, images, todos intégrés
- Todos globaux: création, toggle, filtres, suppression
- Auth: JWT 24h, bcrypt 12 rounds
- Admin: gestion utilisateurs complète
- UI: masonry grid, modal animations, responsive

Configuration Docker:
- Healthcheck intégré
- Volumes persistants pour data et uploads
- Network nginx_default pour reverse proxy
- Variables d'environnement configurables

Prêt pour déploiement avec: docker-compose up -d
This commit is contained in:
Claude committed 2025-11-11 09:36:32 +00:00
1 parent a8599c8703
commit 7a716dc17a
22 files changed
+3414 -589

No files matched your search

+86 -54
View File
@@ -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;
+442 -204
View File
@@ -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;
// 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;
+140
View File
@@ -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;
+134 -80
View File
@@ -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;