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

22 KiB

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)

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 :

// 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)

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 :

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)

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 :

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 :

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 :

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

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 :

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 :

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 :

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 :

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 :

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 :

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 :

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 :

// 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 :

// 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 :

/**
 * 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

const { profileId, state } = oauthStartBody.parse(req.body ?? {}); // Pourquoi {} par défaut ?

Recommandation :

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 :

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

// 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

// 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