Files
FamilyPlanner/docs/archive/ANALYSE_CODE_CALENDAR.md
philippe fdd72c1135 Initial commit: Family Planner application
Complete family planning application with:
- React frontend with TypeScript
- Node.js/Express backend with TypeScript
- Python ingestion service for document processing
- Planning ingestion service with LLM integration
- Shared UI components and type definitions
- OAuth integration for calendar synchronization
- Comprehensive documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:43:33 +02:00

847 lines
22 KiB
Markdown

# Analyse complète du code Calendar - Sécurité, Fiabilité, Performance, Qualité
## 📋 Vue d'ensemble
**Fichier analysé** : `backend/src/routes/calendar.ts`
**Lignes de code** : ~180
**Dépendances** : Express, Zod, Node Crypto
**Fonctionnalités** : OAuth Google/Outlook, gestion connexions calendrier
---
## 🔒 Sécurité : 3/10 ⚠️ CRITIQUE
### ❌ Vulnérabilités critiques
#### 1. **Stockage des tokens en mémoire (lignes 21-22)**
```typescript
const connectionsByProfile = new Map<string, ConnectedCalendar[]>();
const pendingStates = new Map<string, { profileId: string; provider: CalendarProvider; connectionId: string }>();
```
**Risques** :
- Perte totale des connexions au redémarrage du serveur
- Pas de persistance
- Pas de chiffrement
- Exposition en cas de dump mémoire
**Impact** : 🔴 CRITIQUE
**Recommandation** :
```typescript
// Utiliser une base de données avec chiffrement
import { Database } from "./database";
import { TokenEncryption } from "./security/encryption";
class SecureTokenStorage {
async saveToken(userId: string, token: string): Promise<void> {
const { encrypted, iv, authTag } = TokenEncryption.encrypt(token);
await Database.tokens.insert({
userId,
encryptedToken: encrypted,
iv,
authTag,
createdAt: new Date()
});
}
}
```
#### 2. **Client secrets dans le code (lignes 34-48)**
```typescript
const OAUTH_CONFIG = {
google: {
clientId: process.env.GOOGLE_CLIENT_ID || "YOUR_GOOGLE_CLIENT_ID", // ❌
// ...
}
};
```
**Risques** :
- Valeurs par défaut dangereuses
- Pas de validation des variables d'environnement
- Potentiel leak si `.env` n'est pas dans `.gitignore`
**Impact** : 🔴 CRITIQUE
**Recommandation** :
```typescript
function getRequiredEnv(key: string): string {
const value = process.env[key];
if (!value || value.startsWith("YOUR_") || value.includes("_here")) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
const OAUTH_CONFIG = {
google: {
clientId: getRequiredEnv("GOOGLE_CLIENT_ID"),
clientSecret: getRequiredEnv("GOOGLE_CLIENT_SECRET"),
redirectUri: getRequiredEnv("GOOGLE_REDIRECT_URI")
}
};
```
#### 3. **Pas de validation du state OAuth (ligne 99)**
```typescript
const pending = pendingStates.get(state);
if (!pending) return res.json({ success: false, error: "Invalid state" });
```
**Risques** :
- Attaque CSRF possible
- Pas d'expiration du state
- Pas de vérification de l'origine
**Impact** : 🟠 MOYEN
**Recommandation** :
```typescript
type PendingState = {
profileId: string;
provider: CalendarProvider;
connectionId: string;
createdAt: number;
expiresAt: number;
};
// Nettoyer les states expirés
setInterval(() => {
const now = Date.now();
for (const [state, data] of pendingStates.entries()) {
if (data.expiresAt < now) {
pendingStates.delete(state);
}
}
}, 60000); // Toutes les minutes
// Valider avec expiration
const pending = pendingStates.get(state);
if (!pending || pending.expiresAt < Date.now()) {
return res.status(400).json({ success: false, error: "State expired or invalid" });
}
```
#### 4. **Pas de protection contre les attaques par timing**
**Risque** : Les comparaisons de strings peuvent révéler des informations via timing attacks
**Recommandation** :
```typescript
import crypto from "crypto";
function constantTimeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
```
#### 5. **Endpoints sans authentification**
Tous les endpoints sont accessibles sans authentification utilisateur !
**Recommandation** :
```typescript
import { authenticate } from "../middleware/auth";
calendarRouter.use(authenticate); // Protéger toutes les routes
// Ou individuellement :
calendarRouter.get("/:profileId/connections", authenticate, (req, res) => {
// Vérifier que req.user.id === req.params.profileId
if (req.user.id !== req.params.profileId) {
return res.status(403).json({ error: "Forbidden" });
}
// ...
});
```
---
## 🛡️ Fiabilité : 4/10 ⚠️
### ❌ Points faibles
#### 1. **Gestion d'erreurs insuffisante**
```typescript
catch (e) {
console.error("OAuth start error:", e);
res.status(400).json({ message: "Invalid request" }); // Trop générique
}
```
**Problème** :
- Messages d'erreur génériques
- Pas de distinction entre erreurs client/serveur
- Logs insuffisants
- Pas de monitoring
**Recommandation** :
```typescript
import { ZodError } from "zod";
import { logger } from "../utils/logger";
try {
// ...
} catch (e) {
if (e instanceof ZodError) {
logger.warn("Validation error", { errors: e.errors, endpoint: req.path });
return res.status(400).json({
error: "Validation error",
details: e.errors.map(err => ({
field: err.path.join("."),
message: err.message
}))
});
}
logger.error("OAuth start failed", { error: e, profileId: req.body.profileId });
return res.status(500).json({ error: "Internal server error" });
}
```
#### 2. **Pas de retry logic**
Si l'API Google/Outlook échoue temporairement, pas de retry automatique.
**Recommandation** :
```typescript
async function fetchWithRetry(
url: string,
options: RequestInit,
maxRetries = 3
): Promise<Response> {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options);
if (response.ok || response.status < 500) return response;
} catch (e) {
if (i === maxRetries - 1) throw e;
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
}
}
throw new Error("Max retries exceeded");
}
```
#### 3. **Pas de validation des tokens reçus**
Après l'échange code → token, pas de validation de la structure du token.
**Recommandation** :
```typescript
const tokenResponseSchema = z.object({
access_token: z.string().min(1),
refresh_token: z.string().optional(),
expires_in: z.number().positive(),
token_type: z.literal("Bearer"),
scope: z.string()
});
const tokens = tokenResponseSchema.parse(await response.json());
```
#### 4. **Race conditions possibles**
Si deux requêtes modifient la même connexion simultanément :
```typescript
list[idx] = { ...list[idx], status: "connected", lastSyncedAt: now };
connectionsByProfile.set(profileId, list);
```
**Recommandation** : Utiliser une base de données avec transactions ACID
---
## ⚡ Performance : 5/10 ⚠️
### Points faibles
#### 1. **Pas de cache**
Chaque requête refait potentiellement le même appel API.
**Recommandation** :
```typescript
import NodeCache from "node-cache";
const connectionCache = new NodeCache({
stdTTL: 300, // 5 minutes
checkperiod: 60
});
calendarRouter.get("/:profileId/connections", (req, res) => {
const profileId = req.params.profileId;
const cacheKey = `connections:${profileId}`;
const cached = connectionCache.get(cacheKey);
if (cached) return res.json(cached);
const list = connectionsByProfile.get(profileId) ?? [];
connectionCache.set(cacheKey, list);
res.json(list);
});
```
#### 2. **Pas de pagination**
Si un utilisateur a 1000 connexions (peu probable mais possible), tout est retourné d'un coup.
**Recommandation** :
```typescript
calendarRouter.get("/:profileId/connections", (req, res) => {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const skip = (page - 1) * limit;
const list = connectionsByProfile.get(profileId) ?? [];
const paginated = list.slice(skip, skip + limit);
res.json({
data: paginated,
pagination: {
page,
limit,
total: list.length,
totalPages: Math.ceil(list.length / limit)
}
});
});
```
#### 3. **Pas de rate limiting**
Un attaquant peut spam les endpoints OAuth.
**Recommandation** :
```typescript
import rateLimit from "express-rate-limit";
const oauthLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 tentatives max
message: "Trop de tentatives OAuth, réessayez plus tard"
});
calendarRouter.post("/:provider/oauth/start", oauthLimiter, (req, res) => {
// ...
});
```
#### 4. **Synchronisation bloquante**
Le endpoint `/refresh` pourrait bloquer si l'API externe est lente.
**Recommandation** :
```typescript
// Synchronisation asynchrone avec job queue
import Bull from "bull";
const syncQueue = new Bull("calendar-sync");
syncQueue.process(async (job) => {
const { profileId, connectionId } = job.data;
// Faire la sync ici
});
calendarRouter.post("/:profileId/connections/:connectionId/refresh", async (req, res) => {
const job = await syncQueue.add({
profileId: req.params.profileId,
connectionId: req.params.connectionId
});
res.json({ jobId: job.id, status: "queued" });
});
```
---
## ✨ Qualité du code : 6/10
### ✅ Points positifs
1. **Utilisation de Zod pour la validation** (lignes 26-34)
2. **Types TypeScript bien définis** (lignes 6-19)
3. **Séparation des concerns** (Router séparé)
4. **Code lisible** et bien structuré
### ❌ Points à améliorer
#### 1. **Pas de tests**
**Recommandation** :
```typescript
// calendar.test.ts
import request from "supertest";
import { app } from "../app";
describe("Calendar OAuth", () => {
describe("POST /:provider/oauth/start", () => {
it("should return auth URL for Google", async () => {
const response = await request(app)
.post("/api/calendar/google/oauth/start")
.send({
profileId: "test-user",
state: "random-state-123"
});
expect(response.status).toBe(200);
expect(response.body.authUrl).toContain("accounts.google.com");
expect(response.body.authUrl).toContain("response_type=code");
});
it("should reject invalid provider", async () => {
const response = await request(app)
.post("/api/calendar/invalid/oauth/start")
.send({
profileId: "test-user",
state: "random-state-123"
});
expect(response.status).toBe(400);
});
});
});
```
#### 2. **Manque de documentation**
**Recommandation** :
```typescript
/**
* Démarre le flow OAuth pour un provider de calendrier
*
* @route POST /api/calendar/:provider/oauth/start
* @param provider - "google" ou "outlook"
* @body profileId - ID de l'utilisateur
* @body state - Token CSRF (min 6 caractères)
* @returns authUrl - URL de redirection OAuth
* @returns state - Le state pour vérification ultérieure
*
* @example
* POST /api/calendar/google/oauth/start
* {
* "profileId": "user-123",
* "state": "csrf-token-abc123"
* }
*
* Response:
* {
* "authUrl": "https://accounts.google.com/o/oauth2/v2/auth?...",
* "state": "csrf-token-abc123"
* }
*/
calendarRouter.post("/:provider/oauth/start", (req, res) => {
// ...
});
```
#### 3. **Constantes magiques**
```typescript
const { profileId, state } = oauthStartBody.parse(req.body ?? {}); // Pourquoi {} par défaut ?
```
**Recommandation** :
```typescript
const DEFAULT_REQUEST_BODY = {};
const MIN_STATE_LENGTH = 6;
const oauthStartBody = z.object({
profileId: z.string().min(1),
state: z.string().min(MIN_STATE_LENGTH, "State must be at least 6 characters")
});
```
#### 4. **Duplication de code**
La construction de l'URL OAuth pour Google et Outlook est similaire.
**Recommandation** :
```typescript
function buildOAuthUrl(
provider: CalendarProvider,
config: typeof OAUTH_CONFIG[CalendarProvider],
state: string
): string {
const baseUrls = {
google: "https://accounts.google.com/o/oauth2/v2/auth",
outlook: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
};
const commonParams = {
client_id: config.clientId,
redirect_uri: config.redirectUri,
response_type: "code",
scope: config.scope,
state
};
const providerParams = provider === "google"
? { ...commonParams, access_type: "offline", prompt: "consent" }
: { ...commonParams, response_mode: "query" };
return `${baseUrls[provider]}?${new URLSearchParams(providerParams).toString()}`;
}
```
---
## 📊 Import planning (IA, Google Agenda, Outlook)
### Architecture recommandée
```typescript
// services/calendar-import.service.ts
export class CalendarImportService {
/**
* Importe les événements depuis un provider
*/
async importEvents(
connectionId: string,
options: ImportOptions
): Promise<ImportResult> {
const connection = await this.getConnection(connectionId);
switch (connection.provider) {
case "google":
return this.importFromGoogle(connection, options);
case "outlook":
return this.importFromOutlook(connection, options);
default:
throw new Error(`Unsupported provider: ${connection.provider}`);
}
}
/**
* Importe depuis Google Calendar API
*/
private async importFromGoogle(
connection: ConnectedCalendar,
options: ImportOptions
): Promise<ImportResult> {
const accessToken = await this.getAccessToken(connection);
const response = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/primary/events?${new URLSearchParams({
timeMin: options.startDate.toISOString(),
timeMax: options.endDate.toISOString(),
maxResults: String(options.maxResults || 250),
singleEvents: "true",
orderBy: "startTime"
})}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
if (response.status === 401) {
// Token expiré, refresh
await this.refreshToken(connection);
return this.importFromGoogle(connection, options); // Retry
}
throw new Error(`Google API error: ${response.status}`);
}
const data = await response.json();
return this.normalizeGoogleEvents(data.items);
}
/**
* Importe depuis Microsoft Graph API
*/
private async importFromOutlook(
connection: ConnectedCalendar,
options: ImportOptions
): Promise<ImportResult> {
const accessToken = await this.getAccessToken(connection);
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/calendar/events?${new URLSearchParams({
$filter: `start/dateTime ge '${options.startDate.toISOString()}' and end/dateTime le '${options.endDate.toISOString()}'`,
$top: String(options.maxResults || 250),
$orderby: "start/dateTime"
})}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
if (response.status === 401) {
await this.refreshToken(connection);
return this.importFromOutlook(connection, options);
}
throw new Error(`Microsoft API error: ${response.status}`);
}
const data = await response.json();
return this.normalizeOutlookEvents(data.value);
}
/**
* Normalise les événements Google vers format commun
*/
private normalizeGoogleEvents(events: any[]): ImportResult {
const normalized = events.map(event => ({
id: generateUUID(),
externalId: event.id,
source: "google" as const,
title: event.summary || "(Sans titre)",
description: event.description,
startDate: event.start.dateTime || event.start.date,
endDate: event.end.dateTime || event.end.date,
isAllDay: !event.start.dateTime,
location: event.location,
attendees: event.attendees?.map((a: any) => ({
email: a.email,
name: a.displayName,
status: a.responseStatus
})),
recurrence: event.recurrence,
createdAt: event.created,
updatedAt: event.updated
}));
return {
events: normalized,
count: normalized.length,
source: "google",
importedAt: new Date().toISOString()
};
}
/**
* Normalise les événements Outlook vers format commun
*/
private normalizeOutlookEvents(events: any[]): ImportResult {
const normalized = events.map(event => ({
id: generateUUID(),
externalId: event.id,
source: "outlook" as const,
title: event.subject || "(Sans titre)",
description: event.bodyPreview,
startDate: event.start.dateTime,
endDate: event.end.dateTime,
isAllDay: event.isAllDay,
location: event.location?.displayName,
attendees: event.attendees?.map((a: any) => ({
email: a.emailAddress.address,
name: a.emailAddress.name,
status: a.status.response
})),
recurrence: event.recurrence,
createdAt: event.createdDateTime,
updatedAt: event.lastModifiedDateTime
}));
return {
events: normalized,
count: normalized.length,
source: "outlook",
importedAt: new Date().toISOString()
};
}
/**
* Utilise l'IA pour enrichir les événements importés
*/
async enrichWithAI(events: NormalizedEvent[]): Promise<EnrichedEvent[]> {
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
return Promise.all(events.map(async (event) => {
const prompt = `
Analyse cet événement de calendrier et catégorise-le :
Titre: ${event.title}
Description: ${event.description || "N/A"}
Lieu: ${event.location || "N/A"}
Retourne un JSON avec:
- category: "work" | "personal" | "family" | "school" | "health" | "other"
- priority: "low" | "medium" | "high"
- tags: string[] (max 5 tags pertinents)
- suggestedReminder: number (minutes avant l'événement, 0 si non pertinent)
`;
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" }
});
const aiEnrichment = JSON.parse(response.choices[0].message.content);
return {
...event,
...aiEnrichment,
confidence: 0.85 // Confiance dans la catégorisation IA
};
}));
}
}
// Types
type ImportOptions = {
startDate: Date;
endDate: Date;
maxResults?: number;
};
type ImportResult = {
events: NormalizedEvent[];
count: number;
source: "google" | "outlook";
importedAt: string;
};
type NormalizedEvent = {
id: string;
externalId: string;
source: "google" | "outlook";
title: string;
description?: string;
startDate: string;
endDate: string;
isAllDay: boolean;
location?: string;
attendees?: Array<{
email: string;
name?: string;
status?: string;
}>;
recurrence?: any;
createdAt: string;
updatedAt: string;
};
type EnrichedEvent = NormalizedEvent & {
category: "work" | "personal" | "family" | "school" | "health" | "other";
priority: "low" | "medium" | "high";
tags: string[];
suggestedReminder: number;
confidence: number;
};
```
### Endpoint d'import
```typescript
// routes/calendar.ts (à ajouter)
calendarRouter.post("/:profileId/connections/:connectionId/import", async (req, res) => {
try {
const { profileId, connectionId } = req.params;
const { startDate, endDate, enrichWithAI = false } = req.body;
const importService = new CalendarImportService();
let result = await importService.importEvents(connectionId, {
startDate: new Date(startDate),
endDate: new Date(endDate)
});
if (enrichWithAI) {
result.events = await importService.enrichWithAI(result.events);
}
// Sauvegarder dans la base
await saveEventsToDatabase(profileId, result.events);
res.json({
success: true,
imported: result.count,
enriched: enrichWithAI,
importedAt: result.importedAt
});
} catch (error) {
logger.error("Import failed", { error, profileId, connectionId });
res.status(500).json({ error: "Import failed" });
}
});
```
---
## 📈 Score global et recommandations
| Critère | Score | Priorité amélioration |
|---------|-------|----------------------|
| **Sécurité** | 3/10 | 🔴 CRITIQUE |
| **Fiabilité** | 4/10 | 🟠 HAUTE |
| **Performance** | 5/10 | 🟡 MOYENNE |
| **Qualité code** | 6/10 | 🟡 MOYENNE |
| **Import/Sync** | N/A | 🔴 À IMPLÉMENTER |
### Roadmap d'amélioration
#### Phase 1 - Sécurité CRITIQUE (1 semaine)
1. ✅ Implémenter chiffrement tokens (AES-256-GCM)
2. ✅ Ajouter validation stricte variables d'environnement
3. ✅ Implémenter expiration des states OAuth
4. ✅ Ajouter middleware d'authentification
5. ✅ Révoquer clé OpenAI exposée
#### Phase 2 - Fiabilité (1 semaine)
1. ✅ Migrer vers base de données (PostgreSQL + Prisma)
2. ✅ Implémenter gestion d'erreurs robuste
3. ✅ Ajouter retry logic avec backoff exponentiel
4. ✅ Implémenter logging structuré (Winston/Pino)
5. ✅ Ajouter monitoring (Sentry/DataDog)
#### Phase 3 - Import/Synchronisation (2 semaines)
1. ✅ Implémenter échange code OAuth → tokens
2. ✅ Implémenter refresh token automatique
3. ✅ Créer CalendarImportService
4. ✅ Normaliser événements Google/Outlook
5. ✅ Intégrer enrichissement IA (optionnel)
6. ✅ Implémenter sync incrémentale (webhook si possible)
#### Phase 4 - Performance (1 semaine)
1. ✅ Ajouter cache Redis
2. ✅ Implémenter pagination
3. ✅ Ajouter rate limiting
4. ✅ Créer job queue pour sync asynchrone
5. ✅ Optimiser requêtes base de données
#### Phase 5 - Qualité (1 semaine)
1. ✅ Écrire tests unitaires (Jest)
2. ✅ Écrire tests d'intégration (Supertest)
3. ✅ Ajouter documentation OpenAPI/Swagger
4. ✅ Refactorer code dupliqué
5. ✅ Setup CI/CD avec tests
---
## 💡 Conclusion
Le code actuel est un **prototype fonctionnel** mais **NON PRODUCTION-READY**.
**Forces** :
- Structure claire
- Utilisation de TypeScript
- Validation avec Zod
- Correction de l'erreur OAuth "response_type"
**Faiblesses critiques** :
- Aucune sécurité (tokens en clair, pas d'auth)
- Pas de persistance (perte au redémarrage)
- Pas de gestion d'erreurs robuste
- Pas de tests
- Import/sync calendrier non implémenté
**Temps estimé pour production** : 6-8 semaines avec 1 développeur full-time
**Priorité immédiate** :
1. 🔴 Chiffrement tokens
2. 🔴 Base de données
3. 🟠 Implémentation OAuth complète
4. 🟠 Import Google/Outlook
5. 🟡 Tests et monitoring