Merge pull request #1 from R0m1k3/claude/dockerized-notes-todo-app-011CV1roGRbnm21wgtB2LMxP

Build Dockerized Notes and Todo App
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-11 12:14:18 +01:00
commit 0b0f749ad4
22 files changed
+3412 -587

No files matched your search

+23
View File
@@ -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
+53 -18
View File
@@ -1,24 +1,59 @@
# Logs # Dependencies
logs node_modules/
*.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* package-lock.json
lerna-debug.log* yarn.lock
node_modules # Environment variables
dist .env
dist-ssr .env.local
*.local .env.production
# Editor directories and files # Database
.vscode/* data/*.db
!.vscode/extensions.json data/*.db-journal
.idea data/*.db-shm
data/*.db-wal
# Uploads
public/uploads/*
!public/uploads/.gitkeep
# Logs
*.log
logs/
data/*.log
# OS files
.DS_Store .DS_Store
*.suo .DS_Store?
*.ntvs* ._*
*.njsproj .Spotlight-V100
*.sln .Trashes
*.sw? ehthumbs.db
Thumbs.db
*~
# IDE
.vscode/
.idea/
*.swp
*.swo
*.swn
.project
.settings/
.classpath
# Docker
.dockerignore
# Build files
dist/
build/
*.tgz
# Temporary files
tmp/
temp/
+20 -28
View File
@@ -1,42 +1,34 @@
# Image de base Node.js 20 Alpine pour optimiser la taille
FROM node:20-alpine FROM node:20-alpine
WORKDIR /home/node/app # Définir le répertoire de travail
WORKDIR /app
# Install build dependencies # Installer SQLite
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache sqlite
# Create necessary directories # Copier les fichiers de dépendances
RUN mkdir -p public/css/dist data public/uploads
# Install dependencies first (better layer caching)
COPY package*.json ./ COPY package*.json ./
RUN npm install
# Copy configuration files # Installer les dépendances de production uniquement
COPY postcss.config.js tailwind.config.js ./ RUN npm ci --only=production
# Copy source files # Copier le code source
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
COPY . . COPY . .
# Set correct permissions # Créer les dossiers nécessaires et définir les permissions
RUN chown -R node:node . 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 USER node
# Set environment variables # Exposer le port 2222
ENV NODE_ENV=production \
PORT=2222
# Expose port
EXPOSE 2222 EXPOSE 2222
# Start the application # Healthcheck pour vérifier que l'application fonctionne
CMD ["npm", "start"] 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"]
+187 -1
View File
@@ -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 <repository-url>
cd noteflow
```
2. **Configurer les variables d'environnement**
```bash
cp .env.example .env
```
Éditer le fichier `.env` :
```env
PORT=2222
JWT_SECRET=<générer_une_clé_secrète_forte>
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
+149 -93
View File
@@ -1,119 +1,175 @@
// Configuration et initialisation de la base de données SQLite
const sqlite3 = require('sqlite3').verbose(); const sqlite3 = require('sqlite3').verbose();
const path = require('path'); const path = require('path');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const winston = require('winston'); const logger = require('./logger');
const fs = require('fs');
// Ensure data directory exists const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../data/notes.db');
const dataDir = path.resolve(__dirname, '../data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Configure logger // Créer une connexion à la base de données
const logger = winston.createLogger({ const db = new sqlite3.Database(DB_PATH, (err) => {
level: 'info', if (err) {
format: winston.format.combine( logger.error('Erreur lors de la connexion à la base de données:', err);
winston.format.timestamp(), process.exit(1);
winston.format.json() }
), logger.info(`Base de données connectée: ${DB_PATH}`);
transports: [
new winston.transports.File({ filename: path.join(dataDir, 'app.log') }),
new winston.transports.Console()
]
}); });
const dbPath = path.join(dataDir, 'notes.db'); // Activer les clés étrangères
logger.info(`Using database at: ${dbPath}`); db.run('PRAGMA foreign_keys = ON');
// Create database connection /**
const db = new sqlite3.Database(dbPath, (err) => { * Initialiser la base de données avec les tables nécessaires
if (err) { */
logger.error('Database connection error:', err); function initDatabase() {
process.exit(1); return new Promise((resolve, reject) => {
} db.serialize(async () => {
logger.info('Connected to SQLite database'); try {
initializeDatabase(); // Table users
});
// Initialize database schema
function initializeDatabase() {
db.serialize(() => {
// Create users table
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
is_admin INTEGER DEFAULT 0, is_admin BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
`); `);
// Create notes table // Table notes
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS notes ( CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
content TEXT, content TEXT,
archived INTEGER DEFAULT 0, image_filename TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) )
`); `);
// Create todos table // Table note_todos (todos dans les notes)
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS todos ( CREATE TABLE IF NOT EXISTS note_todos (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL, note_id INTEGER NOT NULL,
text TEXT NOT NULL, text TEXT NOT NULL,
completed INTEGER DEFAULT 0, completed BOOLEAN DEFAULT 0,
position INTEGER DEFAULT 0, position INTEGER DEFAULT 0,
FOREIGN KEY (note_id) REFERENCES notes (id) ON DELETE CASCADE FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
) )
`); `);
// Create images table // Table global_todos (sidebar permanente)
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS images ( CREATE TABLE IF NOT EXISTS global_todos (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
filename TEXT NOT NULL, text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed BOOLEAN DEFAULT 0,
FOREIGN KEY (note_id) REFERENCES notes (id) ON DELETE CASCADE created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
) FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`); `);
// Check if admin user exists, create if not // Créer les index pour la performance
db.get('SELECT * FROM users WHERE username = ?', ['admin'], async (err, user) => { db.run('CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id)');
if (err) { db.run('CREATE INDEX IF NOT EXISTS idx_note_todos ON note_todos(note_id)');
logger.error('Error checking admin user:', err); db.run('CREATE INDEX IF NOT EXISTS idx_global_todos_user ON global_todos(user_id)');
return;
}
if (!user) { logger.info('✓ Tables de base de données créées avec succès');
try {
const hash = await bcrypt.hash('admin', 10); // Créer l'utilisateur admin par défaut si la table est vide
db.run( db.get('SELECT COUNT(*) as count FROM users', async (err, row) => {
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)', if (err) {
['admin', hash, 1], logger.error('Erreur lors de la vérification des utilisateurs:', err);
(err) => { reject(err);
if (err) { return;
logger.error('Error creating admin user:', err); }
return;
} if (row.count === 0) {
logger.info('Admin user created successfully'); // Créer l'utilisateur admin par défaut
} const defaultPassword = 'admin';
); const passwordHash = await bcrypt.hash(defaultPassword, 12);
} catch (err) {
logger.error('Error hashing admin password:', err); 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 }; /**
* 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
};
+53
View File
@@ -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;
+11 -7
View File
@@ -7,19 +7,23 @@ services:
ports: ports:
- "2222:2222" - "2222:2222"
volumes: volumes:
- notes_data:/home/node/app/data - ./data:/app/data
- notes_uploads:/home/node/app/public/uploads - ./public/uploads:/app/public/uploads
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- JWT_SECRET=changeme_in_production - JWT_SECRET=${JWT_SECRET:-change_me_in_production_please_use_strong_secret}
- PORT=2222 - PORT=2222
- UPLOADS_PATH=/app/public/uploads
- DB_PATH=/app/data/notes.db
restart: unless-stopped restart: unless-stopped
networks: networks:
- nginx_default - nginx_default
healthcheck:
volumes: test: ["CMD", "node", "-e", "require('http').get('http://localhost:2222/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
notes_data: interval: 30s
notes_uploads: timeout: 10s
retries: 3
start_period: 40s
networks: networks:
nginx_default: nginx_default:
+65 -30
View File
@@ -1,40 +1,75 @@
// Middleware d'authentification JWT
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const winston = require('winston'); const logger = require('../config/logger');
// Configure logger const JWT_SECRET = process.env.JWT_SECRET || 'change_me_in_production_please_use_strong_secret';
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console()
]
});
const authMiddleware = (req, res, next) => { if (JWT_SECRET === 'change_me_in_production_please_use_strong_secret' && process.env.NODE_ENV === 'production') {
try { logger.warn('⚠️ ATTENTION: JWT_SECRET par défaut utilisé en production! Changez-le immédiatement!');
const token = req.headers.authorization?.split(' ')[1]; }
if (!token) { /**
return res.status(401).json({ message: 'Authentication required' }); * 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); // Ajouter les informations utilisateur à la requête
req.user = decoded; req.user = user;
next(); 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) { * Middleware pour vérifier que l'utilisateur est admin
return res.status(403).json({ message: 'Admin access required' }); */
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(); next();
}; }
module.exports = { authMiddleware, adminMiddleware }; /**
* 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
};
+21 -11
View File
@@ -1,16 +1,28 @@
{ {
"name": "notes-todo-app", "name": "noteflow",
"version": "1.0.0", "version": "1.0.0",
"description": "Notes and Todo List Application", "description": "Application web de notes et todo list moderne avec Docker",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon 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": { "dependencies": {
"@tanstack/react-query": "^5.90.7",
"autoprefixer": "^10.4.16",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
@@ -19,15 +31,13 @@
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"postcss": "^8.4.31",
"postcss-cli": "^10.1.0",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"tailwindcss": "^3.3.5",
"tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.3" "nodemon": "^3.0.2"
},
"engines": {
"node": ">=20.0.0"
} }
} }
+126
View File
@@ -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);
}
+510
View File
@@ -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);
}
+129
View File
@@ -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;
}
}
+46
View File
@@ -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;
}
+53
View File
@@ -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;
}
+143 -7
View File
@@ -3,14 +3,150 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes & Todos</title> <title>NoteFlow - Notes & Todo</title>
<link rel="stylesheet" href="/css/styles.css">
<!-- CSS -->
<link rel="stylesheet" href="/css/reset.css">
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/layout.css">
<link rel="stylesheet" href="/css/components.css">
<link rel="stylesheet" href="/css/animations.css">
</head> </head>
<body> <body>
<div id="root"></div> <!-- Header fixe -->
<script src="/js/auth.js"></script> <header class="header">
<script src="/js/notes.js"></script> <div class="header-content">
<script src="/js/admin.js"></script> <div class="header-logo">
<script src="/js/app.js"></script> <h1>📝 NoteFlow</h1>
</div>
<div class="header-search">
<input type="text" id="searchInput" placeholder="Rechercher des notes..." class="search-input">
<button id="searchClear" class="search-clear" style="display: none;">✕</button>
</div>
<div class="header-user">
<div class="user-info" id="userInfo">
<span class="user-avatar">👤</span>
<span class="user-name" id="userName">User</span>
<span class="user-dropdown-icon">▼</span>
</div>
<div class="user-dropdown" id="userDropdown">
<button class="dropdown-item" id="adminBtn" style="display: none;">⚙️ Administration</button>
<button class="dropdown-item" id="logoutBtn">🚪 Déconnexion</button>
</div>
</div>
</div>
</header>
<!-- Container principal -->
<div class="main-container">
<!-- Zone des notes (70%) -->
<main class="notes-area">
<div class="notes-grid" id="notesGrid">
<!-- Les cards de notes seront insérées ici dynamiquement -->
</div>
<button class="fab" id="newNoteBtn" title="Nouvelle note">+</button>
</main>
<!-- Sidebar Todo (30%) -->
<aside class="todo-sidebar">
<h2 class="sidebar-title">Quick Tasks</h2>
<div class="todo-input-container">
<input type="text" id="todoInput" class="todo-input" placeholder="Ajouter une tâche...">
<button id="addTodoBtn" class="todo-add-btn">+</button>
</div>
<div class="todo-filters">
<button class="filter-btn active" data-filter="all">Toutes</button>
<button class="filter-btn" data-filter="active">Actives</button>
<button class="filter-btn" data-filter="completed">Terminées</button>
</div>
<div class="todo-counter">
<span id="todoCounter">0 tâches restantes</span>
</div>
<div class="todo-list" id="todoList">
<!-- Les todos seront insérées ici dynamiquement -->
</div>
</aside>
</div>
<!-- Modal Note -->
<div class="modal-backdrop" id="noteModal" style="display: none;">
<div class="modal-content note-modal">
<div class="modal-header">
<button class="modal-close" id="closeModal">✕</button>
<button class="modal-delete" id="deleteNote">🗑️</button>
</div>
<input type="text" id="noteTitle" class="note-title-input" placeholder="Titre de la note">
<div class="note-divider"></div>
<textarea id="noteContent" class="note-content-textarea" placeholder="Contenu de la note..."></textarea>
<div id="noteImageContainer" class="note-image-container" style="display: none;">
<img id="noteImage" class="note-image" alt="Image de la note">
<button class="note-image-remove" id="removeImage">✕</button>
</div>
<div class="note-todos-section">
<h3 class="note-section-title">Todos dans cette note</h3>
<div id="noteTodosList" class="note-todos-list">
<!-- Les todos de la note seront insérés ici -->
</div>
<button class="note-add-todo-btn" id="addNoteTodo">+ Ajouter un todo</button>
</div>
<div class="note-divider"></div>
<div class="modal-actions">
<button class="action-btn" id="addImageBtn">📷 Ajouter une image</button>
<input type="file" id="imageInput" accept="image/*" style="display: none;">
</div>
<div class="modal-footer">
<span id="noteMetadata" class="note-metadata"></span>
</div>
</div>
</div>
<!-- Modal Admin -->
<div class="modal-backdrop" id="adminModal" style="display: none;">
<div class="modal-content admin-modal">
<div class="modal-header">
<h2>Administration des utilisateurs</h2>
<button class="modal-close" id="closeAdminModal">✕</button>
</div>
<div class="admin-actions">
<button class="action-btn" id="createUserBtn">+ Créer un utilisateur</button>
</div>
<div class="users-table-container">
<table class="users-table" id="usersTable">
<thead>
<tr>
<th>ID</th>
<th>Nom d'utilisateur</th>
<th>Admin</th>
<th>Créé le</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="usersTableBody">
<!-- Les utilisateurs seront insérés ici -->
</tbody>
</table>
</div>
</div>
</div>
<!-- JavaScript -->
<script src="/js/complete-app.js"></script>
</body> </body>
</html> </html>
+663
View File
@@ -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 = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 40px;">Aucune note trouvée</p>';
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 = `<div class="note-card-title">${escapeHtml(note.title)}</div>`;
if (note.content) {
html += `<div class="note-card-content">${escapeHtml(utils.truncate(note.content))}</div>`;
}
if (note.image_filename) {
html += `<img src="/uploads/${note.image_filename}" class="note-card-image" alt="">`;
}
html += `<div class="note-card-meta">`;
html += `<span>${utils.formatDate(note.updated_at)}</span>`;
if (note.todos_count > 0) {
html += `<span class="note-card-badge">${note.todos_count} tasks</span>`;
}
html += `</div>`;
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 = `
<input type="checkbox" class="todo-checkbox" ${todo.completed ? 'checked' : ''}
onchange="toggleNoteTodo(${todo.id}, this.checked)">
<span class="todo-text">${escapeHtml(todo.text)}</span>
<button class="todo-delete" onclick="deleteNoteTodo(${todo.id})">✕</button>
`;
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 = `
<input type="checkbox" class="todo-checkbox" ${todo.completed ? 'checked' : ''}
onchange="toggleTodo(${todo.id}, this.checked)">
<span class="todo-text">${escapeHtml(todo.text)}</span>
<button class="todo-delete" onclick="deleteTodo(${todo.id})">✕</button>
`;
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();
}
+222
View File
@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion - NoteFlow</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: #1F2937;
}
.login-container {
background: white;
padding: 48px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 400px;
animation: slideIn 0.4s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-logo {
text-align: center;
margin-bottom: 32px;
}
.login-logo h1 {
font-size: 32px;
font-weight: bold;
color: #667eea;
}
.login-logo p {
color: #6B7280;
margin-top: 8px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #374151;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #E5E7EB;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.error-message {
background: #FEE2E2;
color: #991B1B;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
display: none;
}
.error-message.show {
display: block;
}
.login-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.login-btn:active {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.default-credentials {
margin-top: 24px;
padding: 16px;
background: #F3F4F6;
border-radius: 8px;
font-size: 14px;
color: #6B7280;
}
.default-credentials strong {
color: #374151;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-logo">
<h1>📝 NoteFlow</h1>
<p>Gérez vos notes et tâches efficacement</p>
</div>
<div id="errorMessage" class="error-message"></div>
<form id="loginForm">
<div class="form-group">
<label for="username">Nom d'utilisateur</label>
<input type="text" id="username" name="username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="login-btn" id="loginBtn">Se connecter</button>
</form>
<div class="default-credentials">
<strong>Identifiants par défaut:</strong><br>
Username: admin<br>
Password: admin
</div>
</div>
<script>
// Rediriger si déjà connecté
const token = localStorage.getItem('token');
if (token) {
window.location.href = '/';
}
const loginForm = document.getElementById('loginForm');
const errorMessage = document.getElementById('errorMessage');
const loginBtn = document.getElementById('loginBtn');
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
errorMessage.classList.remove('show');
loginBtn.disabled = true;
loginBtn.textContent = 'Connexion...';
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
window.location.href = '/';
} else {
errorMessage.textContent = data.error || 'Identifiants invalides';
errorMessage.classList.add('show');
}
} catch (error) {
errorMessage.textContent = 'Erreur de connexion au serveur';
errorMessage.classList.add('show');
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Se connecter';
}
});
</script>
</body>
</html>
+86 -54
View File
@@ -1,67 +1,99 @@
// Routes d'authentification
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator'); 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 { getOne } = require('../config/database');
const loginLimiter = rateLimit({ const { generateToken, authenticateToken } = require('../middleware/auth');
windowMs: 60 * 1000, // 1 minute const logger = require('../config/logger');
max: 10, // 10 requests per minute
message: { message: 'Too many login attempts, please try again later' } /**
* 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 = [ * GET /api/auth/me
body('username').trim().notEmpty().escape(), * Récupérer les informations de l'utilisateur connecté
body('password').trim().notEmpty() */
]; router.get('/me', authenticateToken, async (req, res) => {
try {
// Login route const user = await getOne(
router.post('/login', loginLimiter, loginValidation, async (req, res) => { 'SELECT id, username, is_admin, created_at FROM users WHERE id = ?',
const errors = validationResult(req); [req.user.id]
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' }
); );
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 res.json(user);
router.get('/me', authMiddleware, (req, res) => { } catch (error) {
res.json(req.user); logger.error('Erreur lors de la récupération des informations utilisateur:', error);
}); res.status(500).json({ error: 'Erreur serveur' });
}
// Logout (client-side only, just for completeness)
router.post('/logout', (req, res) => {
res.json({ message: 'Logged out successfully' });
}); });
module.exports = router; module.exports = router;
+442 -204
View File
@@ -1,204 +1,442 @@
const express = require('express'); // Routes de gestion des notes
const router = express.Router(); const express = require('express');
const multer = require('multer'); const router = express.Router();
const path = require('path'); const multer = require('multer');
const { v4: uuidv4 } = require('uuid'); const path = require('path');
const { body, validationResult } = require('express-validator'); const fs = require('fs');
const { db, logger } = require('../config/database'); const { body, validationResult } = require('express-validator');
const { authMiddleware } = require('../middleware/auth');
const { getAll, getOne, runQuery } = require('../config/database');
// Configure multer for image uploads const { authenticateToken } = require('../middleware/auth');
const storage = multer.diskStorage({ const logger = require('../config/logger');
destination: 'public/uploads/',
filename: (req, file, cb) => { // Configuration de multer pour l'upload d'images
const uniqueName = `${uuidv4()}${path.extname(file.originalname)}`; const storage = multer.diskStorage({
cb(null, uniqueName); destination: (req, file, cb) => {
} const uploadsDir = path.join(__dirname, '../public/uploads');
}); cb(null, uploadsDir);
},
const upload = multer({ filename: (req, file, cb) => {
storage, const uniqueName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}${path.extname(file.originalname)}`;
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB cb(null, uniqueName);
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); const upload = multer({
if (extname && mimetype) { storage,
cb(null, true); limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024 }, // 5MB
} else { fileFilter: (req, file, cb) => {
cb(new Error('Only image files are allowed')); 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.'));
// Note validation }
const noteValidation = [ }
body('title').trim().notEmpty().escape(), });
body('content').trim().optional().escape(),
body('archived').isBoolean().optional() // Toutes les routes nécessitent authentification
]; router.use(authenticateToken);
// Get all notes for user /**
router.get('/', authMiddleware, (req, res) => { * GET /api/notes
const query = ` * Liste toutes les notes de l'utilisateur
SELECT n.*, */
GROUP_CONCAT(t.id || ':' || t.text || ':' || t.completed) as todos, router.get('/', async (req, res) => {
GROUP_CONCAT(i.id || ':' || i.filename) as images try {
FROM notes n const notes = await getAll(`
LEFT JOIN todos t ON n.id = t.note_id SELECT
LEFT JOIN images i ON n.id = i.note_id n.id, n.title, n.content, n.image_filename,
WHERE n.user_id = ? n.created_at, n.updated_at,
GROUP BY n.id COUNT(DISTINCT nt.id) as todos_count
ORDER BY n.created_at DESC FROM notes n
`; LEFT JOIN note_todos nt ON n.id = nt.note_id
WHERE n.user_id = ?
db.all(query, [req.user.id], (err, notes) => { GROUP BY n.id
if (err) { ORDER BY n.updated_at DESC
logger.error('Error fetching notes:', err); `, [req.user.id]);
return res.status(500).json({ message: 'Server error' });
} res.json(notes);
} catch (error) {
// Process the results to format todos and images logger.error('Erreur lors de la récupération des notes:', error);
const processedNotes = notes.map(note => ({ res.status(500).json({ error: 'Erreur serveur' });
...note, }
todos: note.todos ? note.todos.split(',').map(todo => { });
const [id, text, completed] = todo.split(':');
return { id, text, completed: completed === '1' }; /**
}) : [], * GET /api/notes/:id
images: note.images ? note.images.split(',').map(image => { * Récupérer une note avec ses todos
const [id, filename] = image.split(':'); */
return { id, filename }; router.get('/:id', async (req, res) => {
}) : [] try {
})); const note = await getOne(`
SELECT id, title, content, image_filename, created_at, updated_at
res.json(processedNotes); FROM notes
}); WHERE id = ? AND user_id = ?
}); `, [req.params.id, req.user.id]);
// Create note if (!note) {
router.post('/', authMiddleware, noteValidation, (req, res) => { return res.status(404).json({ error: 'Note non trouvée' });
const errors = validationResult(req); }
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() }); // Récupérer les todos de la note
} const todos = await getAll(`
SELECT id, text, completed, position
const { title, content, todos = [] } = req.body; FROM note_todos
WHERE note_id = ?
db.run( ORDER BY position, id
'INSERT INTO notes (user_id, title, content) VALUES (?, ?, ?)', `, [req.params.id]);
[req.user.id, title, content],
function(err) { note.todos = todos;
if (err) {
logger.error('Error creating note:', err); res.json(note);
return res.status(500).json({ message: 'Server error' }); } catch (error) {
} logger.error('Erreur lors de la récupération de la note:', error);
res.status(500).json({ error: 'Erreur serveur' });
const noteId = this.lastID; }
});
// Insert todos if any
if (todos.length > 0) { /**
const todoValues = todos.map((todo, index) => * POST /api/notes
`(${noteId}, '${todo.text}', ${todo.completed ? 1 : 0}, ${index})` * Créer une nouvelle note
).join(','); */
router.post('/',
db.run(`INSERT INTO todos (note_id, text, completed, position) VALUES ${todoValues}`); [
} body('title').trim().notEmpty().withMessage('Le titre est requis'),
body('content').optional()
res.status(201).json({ id: noteId, title, content, todos: [] }); ],
} async (req, res) => {
); try {
}); const errors = validationResult(req);
if (!errors.isEmpty()) {
// Update note return res.status(400).json({ error: 'Données invalides', details: errors.array() });
router.put('/:id', authMiddleware, noteValidation, (req, res) => { }
const noteId = req.params.id;
const { title, content, archived, todos = [] } = req.body; const { title, content } = req.body;
db.run( const result = await runQuery(`
'UPDATE notes SET title = ?, content = ?, archived = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', INSERT INTO notes (user_id, title, content)
[title, content, archived ? 1 : 0, noteId, req.user.id], VALUES (?, ?, ?)
function(err) { `, [req.user.id, title, content || '']);
if (err) {
logger.error('Error updating note:', err); logger.info(`Note créée: ${title} (ID: ${result.id}) par ${req.user.username}`);
return res.status(500).json({ message: 'Server error' });
} res.status(201).json({
id: result.id,
// Update todos title,
db.run('DELETE FROM todos WHERE note_id = ?', [noteId], (err) => { content: content || '',
if (err) { image_filename: null,
logger.error('Error deleting todos:', err); todos: []
return; });
} } catch (error) {
logger.error('Erreur lors de la création de la note:', error);
if (todos.length > 0) { res.status(500).json({ error: 'Erreur serveur' });
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}`); /**
} * PUT /api/notes/:id
}); * Modifier une note
*/
res.json({ message: 'Note updated successfully' }); router.put('/:id',
} [
); body('title').optional().trim().notEmpty(),
}); body('content').optional()
],
// Delete note async (req, res) => {
router.delete('/:id', authMiddleware, (req, res) => { try {
const noteId = req.params.id; const { title, content } = req.body;
db.run('DELETE FROM notes WHERE id = ? AND user_id = ?', [noteId, req.user.id], (err) => { // Vérifier que la note appartient à l'utilisateur
if (err) { const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
logger.error('Error deleting note:', err); if (!note) {
return res.status(500).json({ message: 'Server error' }); return res.status(404).json({ error: 'Note non trouvée' });
} }
// Cleanup related records const updates = [];
db.run('DELETE FROM todos WHERE note_id = ?', [noteId]); const params = [];
db.run('DELETE FROM images WHERE note_id = ?', [noteId]);
if (title !== undefined) {
res.json({ message: 'Note deleted successfully' }); updates.push('title = ?');
}); params.push(title);
}); }
if (content !== undefined) {
// Upload image updates.push('content = ?');
router.post('/:id/images', authMiddleware, upload.single('image'), (req, res) => { params.push(content);
if (!req.file) { }
return res.status(400).json({ message: 'No image file provided' });
} if (updates.length > 0) {
updates.push('updated_at = CURRENT_TIMESTAMP');
const noteId = req.params.id; params.push(req.params.id);
const filename = req.file.filename;
await runQuery(`UPDATE notes SET ${updates.join(', ')} WHERE id = ?`, params);
db.run( }
'INSERT INTO images (note_id, filename) VALUES (?, ?)',
[noteId, filename], res.json({ message: 'Note modifiée avec succès' });
function(err) { } catch (error) {
if (err) { logger.error('Erreur lors de la modification de la note:', error);
logger.error('Error saving image record:', err); res.status(500).json({ error: 'Erreur serveur' });
return res.status(500).json({ message: 'Server error' }); }
} }
res.status(201).json({ id: this.lastID, filename }); );
}
); /**
}); * DELETE /api/notes/:id
* Supprimer une note
// Delete image */
router.delete('/:noteId/images/:imageId', authMiddleware, (req, res) => { router.delete('/:id', async (req, res) => {
const { noteId, imageId } = req.params; try {
// Vérifier que la note appartient à l'utilisateur
db.run( const note = await getOne('SELECT id, image_filename FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
'DELETE FROM images WHERE id = ? AND note_id = ?', if (!note) {
[imageId, noteId], return res.status(404).json({ error: 'Note non trouvée' });
(err) => { }
if (err) {
logger.error('Error deleting image:', err); // Supprimer l'image si elle existe
return res.status(500).json({ message: 'Server error' }); if (note.image_filename) {
} const imagePath = path.join(__dirname, '../public/uploads', note.image_filename);
res.json({ message: 'Image deleted successfully' }); if (fs.existsSync(imagePath)) {
} fs.unlinkSync(imagePath);
); }
}); }
module.exports = router; 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 express = require('express');
const router = express.Router(); const router = express.Router();
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const { db, logger } = require('../config/database');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
// User validation rules const { getAll, getOne, runQuery } = require('../config/database');
const userValidation = [ const { authenticateToken, requireAdmin } = require('../middleware/auth');
body('username').trim().isLength({ min: 3 }).escape(), const logger = require('../config/logger');
body('password').isLength({ min: 6 }),
body('is_admin').isBoolean().optional()
];
// Get all users (admin only) // Toutes les routes nécessitent authentification et droits admin
router.get('/', authMiddleware, adminMiddleware, (req, res) => { router.use(authenticateToken);
db.all('SELECT id, username, is_admin, created_at FROM users', (err, users) => { router.use(requireAdmin);
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;
/**
* GET /api/users
* Liste tous les utilisateurs
*/
router.get('/', async (req, res) => {
try { try {
const hash = await bcrypt.hash(password, 10); const users = await getAll(
db.run( 'SELECT id, username, is_admin, created_at FROM users ORDER BY created_at DESC'
'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 });
}
); );
} catch (err) { res.json(users);
logger.error('Password hashing error:', err); } catch (error) {
res.status(500).json({ message: 'Server 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) => { * POST /api/users
const userId = req.params.id; * Créer un nouvel utilisateur
const { password, is_admin } = req.body; */
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 const { username, password, is_admin } = req.body;
if (userId === '1') {
return res.status(403).json({ message: 'Cannot modify main admin user' }); // 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 { try {
if (password) { const userId = req.params.id;
const hash = await bcrypt.hash(password, 10);
db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, userId]); // 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' });
if (typeof is_admin !== 'undefined') {
db.run('UPDATE users SET is_admin = ? WHERE id = ?', [is_admin ? 1 : 0, userId]);
} }
res.json({ message: 'User updated successfully' }); // Vérifier que l'utilisateur existe
} catch (err) { const user = await getOne('SELECT id, username FROM users WHERE id = ?', [userId]);
logger.error('Error updating user:', err); if (!user) {
res.status(500).json({ message: 'Server error' }); 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; module.exports = router;
+136 -54
View File
@@ -1,76 +1,158 @@
// Serveur Express principal pour l'application NoteFlow
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const helmet = require('helmet'); const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const path = require('path'); const path = require('path');
const winston = require('winston');
const fs = require('fs'); const fs = require('fs');
const { db } = require('./config/database');
// Ensure uploads directory exists const logger = require('./config/logger');
const uploadsDir = path.join(__dirname, 'public/uploads'); const { initDatabase } = require('./config/database');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Configure logger // Créer l'application Express
const logger = winston.createLogger({ const app = express();
level: 'info', const PORT = process.env.PORT || 2222;
format: winston.format.combine(
winston.format.timestamp(), // Créer les dossiers nécessaires
winston.format.json() const dataDir = path.join(__dirname, 'data');
), const uploadsDir = path.join(__dirname, 'public', 'uploads');
transports: [
new winston.transports.Console() [dataDir, uploadsDir].forEach(dir => {
] if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
logger.info(`Dossier créé: ${dir}`);
}
}); });
// Create Express app // Créer un fichier .gitkeep dans uploads pour le tracking git
const app = express(); const gitkeepPath = path.join(uploadsDir, '.gitkeep');
if (!fs.existsSync(gitkeepPath)) {
// Middleware fs.writeFileSync(gitkeepPath, '');
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');
} }
// 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/auth', require('./routes/auth.routes'));
app.use('/api/users', require('./routes/users.routes')); app.use('/api/users', require('./routes/users.routes'));
app.use('/api/notes', require('./routes/notes.routes')); app.use('/api/notes', require('./routes/notes.routes'));
app.use('/api/todos', require('./routes/todos.routes'));
// Serve static files // Route de recherche
app.use('/uploads', express.static(path.join(__dirname, 'public/uploads'))); 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) => { 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) => { app.use((err, req, res, next) => {
logger.error('Unhandled error:', err); logger.error('Erreur non gérée:', {
res.status(500).json({ message: 'Internal server error' }); 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 // Initialiser la base de données et démarrer le serveur
const PORT = process.env.PORT || 2222; async function startServer() {
app.listen(PORT, () => { try {
logger.info(`Server running on port ${PORT}`); // 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();