From 53975365728d1d9b3316ba22cb57015760ea0fa7 Mon Sep 17 00:00:00 2001 From: alma Date: Fri, 16 Jan 2026 11:23:13 +0100 Subject: [PATCH] Notifications corrections --- ANALYSE_PAGES_S3.md | 438 +++++++++++++++++++++++++ app/api/storage/files/content/route.ts | 118 ++++++- app/api/storage/files/route.ts | 319 +++++++++++++++--- app/pages/page.tsx | 240 +++++--------- components/carnet/editor.tsx | 78 +---- lib/cache-utils.ts | 237 +++++++++++++ lib/s3.ts | 16 +- 7 files changed, 1167 insertions(+), 279 deletions(-) create mode 100644 ANALYSE_PAGES_S3.md create mode 100644 lib/cache-utils.ts diff --git a/ANALYSE_PAGES_S3.md b/ANALYSE_PAGES_S3.md new file mode 100644 index 0000000..94932d2 --- /dev/null +++ b/ANALYSE_PAGES_S3.md @@ -0,0 +1,438 @@ +# Analyse Complète : Page "Pages" et Intégration S3 + +## 📋 Vue d'ensemble + +La page `/pages` est une application de gestion de notes et contacts (carnet) qui utilise un stockage S3-compatible (MinIO) pour persister les données utilisateur. + +--- + +## 🗂️ Structure des Routes + +### Route Frontend +- **URL** : `/pages` +- **Fichier** : `app/pages/page.tsx` +- **Type** : Client Component (Next.js App Router) +- **Authentification** : Requise (redirection vers `/signin` si non authentifié) + +### Routes API + +#### 1. Routes Principales (`/api/storage/*`) +- **`GET /api/storage/status`** : Liste les dossiers disponibles pour l'utilisateur +- **`POST /api/storage/init`** : Initialise la structure de dossiers pour un nouvel utilisateur +- **`POST /api/storage/init/folder`** : Crée un dossier spécifique +- **`GET /api/storage/files?folder={folder}`** : Liste les fichiers d'un dossier +- **`GET /api/storage/files/content?path={path}`** : Récupère le contenu d'un fichier +- **`POST /api/storage/files`** : Crée un nouveau fichier +- **`PUT /api/storage/files`** : Met à jour un fichier existant +- **`DELETE /api/storage/files?id={id}`** : Supprime un fichier + +#### 2. Routes de Compatibilité (`/api/nextcloud/*`) +- **Adapter Pattern** : Les routes `/api/nextcloud/*` redirigent vers `/api/storage/*` +- **Raison** : Compatibilité avec l'ancien code qui utilisait NextCloud +- **Fichiers** : + - `app/api/nextcloud/status/route.ts` → Retourne les dossiers standards + - `app/api/nextcloud/files/route.ts` → Redirige vers `/api/storage/files` + +--- + +## 🧩 Composants Principaux + +### 1. **Page Principale** (`app/pages/page.tsx`) + +**Responsabilités** : +- Gestion de l'état global (notes, contacts, dossiers sélectionnés) +- Orchestration des trois panneaux (Navigation, Liste, Éditeur) +- Gestion du cache multi-niveaux +- Gestion responsive (mobile/tablette/desktop) + +**États Principaux** : +```typescript +- selectedFolder: string (Notes, Diary, Health, Contacts) +- selectedNote: Note | null +- selectedContact: Contact | null +- notes: Note[] +- contacts: Contact[] +- nextcloudFolders: string[] +``` + +**Cache** : +- **In-memory** : `notesCache`, `noteContentCache`, `foldersCache` +- **localStorage** : Cache persistant avec expiration (5-15 minutes) + +### 2. **Navigation** (`components/carnet/navigation.tsx`) + +**Fonctionnalités** : +- Affichage des dossiers (Notes, Diary, Health, Contacts) +- Recherche de dossiers +- Expansion du dossier Contacts pour afficher les fichiers VCF +- Icônes contextuelles par type de dossier + +**Dossiers Standards** : +- `Notes` → Bloc-notes +- `Diary` → Journal +- `Health` → Carnet de santé +- `Contacts` → Carnet d'adresses + +### 3. **NotesView** (`components/carnet/notes-view.tsx`) + +**Fonctionnalités** : +- Liste des notes avec recherche +- Tri par date de modification +- Affichage formaté des dates +- Actions : Créer, Supprimer, Sélectionner +- Formatage spécial pour Diary/Health (extraction de dates) + +### 4. **ContactsView** (`components/carnet/contacts-view.tsx`) + +**Fonctionnalités** : +- Liste des contacts avec recherche +- Filtrage par nom, email, organisation +- Support des fichiers VCF multiples +- Création de nouveaux contacts + +### 5. **Editor** (`components/carnet/editor.tsx`) + +**Fonctionnalités** : +- Édition de notes en Markdown +- Sauvegarde automatique (debounce 1 seconde) +- Cache du contenu pour performance +- Gestion des erreurs et états de chargement + +**Sauvegarde** : +- Auto-save après 1 seconde d'inactivité +- Utilise `PUT /api/storage/files` pour les mises à jour +- Invalide le cache après sauvegarde + +### 6. **ContactDetails** (`components/carnet/contact-details.tsx`) + +**Fonctionnalités** : +- Affichage détaillé d'un contact +- Édition inline des champs +- Support VCard (vCard 3.0) +- Sauvegarde dans fichiers VCF + +--- + +## 🔌 Configuration S3 + +### Fichier de Configuration (`lib/s3.ts`) + +```typescript +S3_CONFIG = { + endpoint: 'https://dome-api.slm-lab.net', + region: 'us-east-1', + bucket: process.env.S3_BUCKET || 'pages', + accessKey: '4aBT4CMb7JIMMyUtp4Pl', + secretKey: 'HGn39XhCIlqOjmDVzRK9MED2Fci2rYvDDgbLFElg' +} +``` + +**Type de Stockage** : MinIO (S3-compatible) +**Client** : AWS SDK v3 (`@aws-sdk/client-s3`) + +### Structure de Stockage + +``` +bucket: pages/ +└── user-{userId}/ + ├── notes/ + │ ├── note1.md + │ └── note2.md + ├── diary/ + │ └── 2024-01-15-entry.md + ├── health/ + │ └── health-record.md + └── contacts/ + ├── Allemanique.vcf + └── contacts.vcf +``` + +**Format des Clés S3** : +- Notes : `user-{userId}/{folder}/{title}.md` +- Contacts : `user-{userId}/contacts/{filename}.vcf` + +### Fonctions S3 Principales + +#### `putObject(key, content, contentType)` +- Upload un fichier vers S3 +- Convertit automatiquement les strings en Buffer UTF-8 +- Retourne la clé du fichier créé + +#### `getObjectContent(key)` +- Récupère le contenu d'un fichier +- Stream le contenu et le convertit en string UTF-8 +- Retourne `null` si le fichier n'existe pas + +#### `deleteObject(key)` +- Supprime un fichier de S3 +- Utilise `DeleteObjectCommand` + +#### `listUserObjects(userId, folder)` +- Liste les objets d'un dossier utilisateur +- Filtre les placeholders et dossiers vides +- Retourne métadonnées (nom, taille, date de modification) + +#### `createUserFolderStructure(userId)` +- Crée la structure de dossiers standard +- Crée des fichiers `.placeholder` pour chaque dossier +- Dossiers créés : `notes`, `diary`, `health`, `contacts` + +--- + +## 🔄 Flux de Données + +### 1. Initialisation + +``` +User Login + ↓ +POST /api/storage/init + ↓ +createUserFolderStructure(userId) + ↓ +Création des dossiers dans S3 + ↓ +GET /api/storage/status + ↓ +Affichage des dossiers dans Navigation +``` + +### 2. Chargement des Notes + +``` +User sélectionne un dossier + ↓ +Check cache (in-memory → localStorage) + ↓ +Si cache valide → Utiliser cache + ↓ +Sinon → GET /api/storage/files?folder={folder} + ↓ +listUserObjects(userId, folder) + ↓ +Mise à jour du cache + Affichage +``` + +### 3. Édition d'une Note + +``` +User sélectionne une note + ↓ +Check cache du contenu + ↓ +Si cache valide → Afficher + ↓ +Sinon → GET /api/storage/files/content?path={id} + ↓ +getObjectContent(key) + ↓ +Affichage dans Editor +``` + +### 4. Sauvegarde d'une Note + +``` +User modifie le contenu + ↓ +Debounce 1 seconde + ↓ +PUT /api/storage/files + ↓ +putObject(key, content, 'text/markdown') + ↓ +Invalidation du cache + ↓ +Rafraîchissement de la liste +``` + +### 5. Gestion des Contacts + +``` +User sélectionne "Contacts" + ↓ +GET /api/storage/files?folder=contacts + ↓ +Filtrage des fichiers .vcf + ↓ +Pour chaque VCF → GET /api/storage/files/content + ↓ +Parsing VCard avec vcard-parser + ↓ +Affichage dans ContactsView +``` + +--- + +## 🎨 Architecture UI + +### Layout Responsive + +**Desktop (> 1024px)** : +- 3 panneaux : Navigation | Liste | Éditeur +- Panneaux redimensionnables avec `PanelResizer` + +**Tablette (768px - 1024px)** : +- 2 panneaux : Navigation | Liste/Éditeur +- Navigation toujours visible + +**Mobile (< 768px)** : +- 1 panneau à la fois +- Bouton toggle pour navigation +- Navigation overlay + +### Système de Cache + +**Niveaux de Cache** : +1. **In-memory** : `useRef` pour performance immédiate +2. **localStorage** : Persistance entre sessions +3. **S3** : Source de vérité + +**Expiration** : +- Liste des notes : 5 minutes +- Contenu des notes : 15 minutes +- Liste des dossiers : 2 minutes + +**Invalidation** : +- Après sauvegarde d'une note +- Après création/suppression +- Après événement `note-saved` (CustomEvent) + +--- + +## 🔐 Sécurité + +### Authentification +- **NextAuth** : Vérification de session sur toutes les routes API +- **Isolation utilisateur** : Tous les chemins S3 incluent `user-{userId}/` +- **Validation** : Vérification que l'utilisateur ne peut accéder qu'à ses propres fichiers + +### Exemple de Validation +```typescript +// Dans /api/storage/files/content/route.ts +if (!key.startsWith(`user-${userId}/`)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); +} +``` + +--- + +## 📝 Formats de Fichiers + +### Notes +- **Format** : Markdown (`.md`) +- **MIME Type** : `text/markdown` +- **Structure** : Texte libre avec support Markdown + +### Contacts +- **Format** : vCard 3.0 (`.vcf`) +- **MIME Type** : `text/vcard` +- **Bibliothèque** : `vcard-parser` pour parsing/formatting +- **Structure** : Multiple vCards dans un fichier + +**Exemple VCard** : +``` +BEGIN:VCARD +VERSION:3.0 +UID:{id} +FN:{fullName} +EMAIL;TYPE=INTERNET:{email} +TEL;TYPE=CELL:{phone} +ORG:{organization} +ADR:{address} +NOTE:{notes} +END:VCARD +``` + +--- + +## 🐛 Points d'Attention + +### 1. **Compatibilité NextCloud/S3** +- Le code utilise parfois `/api/nextcloud/*` et parfois `/api/storage/*` +- Les routes NextCloud sont des adapters qui redirigent vers Storage +- **Recommandation** : Migrer progressivement vers `/api/storage/*` uniquement + +### 2. **Gestion de la Casse** +- Les noms de dossiers sont normalisés en lowercase pour S3 +- Mais l'affichage utilise la casse originale (Notes, Diary, etc.) +- **Risque** : Incohérences si un dossier est créé avec une casse différente + +### 3. **Cache Multi-Niveaux** +- Complexité de synchronisation entre caches +- **Risque** : Données obsolètes si invalidation incomplète + +### 4. **Credentials S3 en Dur** +- Les clés d'accès S3 sont hardcodées dans `lib/s3.ts` +- **Recommandation** : Utiliser des variables d'environnement + +### 5. **Gestion des Erreurs** +- Certaines erreurs sont loggées mais pas toujours remontées à l'utilisateur +- **Recommandation** : Améliorer le feedback utilisateur + +--- + +## 🚀 Améliorations Suggérées + +### 1. **Sécurité** +- [ ] Déplacer les credentials S3 vers variables d'environnement +- [ ] Implémenter des rate limits sur les API +- [ ] Ajouter une validation plus stricte des chemins + +### 2. **Performance** +- [ ] Implémenter un système de pagination pour les grandes listes +- [ ] Optimiser les requêtes S3 avec des préfixes plus spécifiques +- [ ] Ajouter un système de compression pour les gros fichiers + +### 3. **UX** +- [ ] Ajouter des indicateurs de synchronisation +- [ ] Implémenter un système de conflits pour les éditions simultanées +- [ ] Améliorer les messages d'erreur utilisateur + +### 4. **Architecture** +- [ ] Unifier les routes API (supprimer les adapters NextCloud) +- [ ] Créer un service layer pour abstraire S3 +- [ ] Implémenter des tests unitaires pour les fonctions S3 + +--- + +## 📊 Métriques et Monitoring + +### Endpoints à Monitorer +- Temps de réponse des requêtes S3 +- Taux d'erreur des opérations CRUD +- Utilisation du cache (hit rate) +- Taille des fichiers uploadés + +### Logs Importants +- Erreurs d'authentification +- Échecs de connexion S3 +- Échecs de parsing VCard +- Incohérences de cache + +--- + +## 🔗 Dépendances Clés + +```json +{ + "@aws-sdk/client-s3": "^3.802.0", + "@aws-sdk/s3-request-presigner": "^3.802.0", + "vcard-parser": "^x.x.x", + "next-auth": "^x.x.x" +} +``` + +--- + +## 📚 Références + +- **Fichier S3 Config** : `lib/s3.ts` +- **Page Principale** : `app/pages/page.tsx` +- **Routes API Storage** : `app/api/storage/**/*.ts` +- **Routes API NextCloud** : `app/api/nextcloud/**/*.ts` +- **Composants** : `components/carnet/*.tsx` + +--- + +*Document généré le : $(date)* +*Dernière mise à jour : Analyse complète du système Pages/S3* diff --git a/app/api/storage/files/content/route.ts b/app/api/storage/files/content/route.ts index 2affc9c..cc6c171 100644 --- a/app/api/storage/files/content/route.ts +++ b/app/api/storage/files/content/route.ts @@ -3,6 +3,33 @@ import { getServerSession } from 'next-auth'; import { authOptions } from "@/app/api/auth/options"; import { getObjectContent } from '@/lib/s3'; +// Error types for better error handling +enum StorageError { + UNAUTHORIZED = 'UNAUTHORIZED', + FORBIDDEN = 'FORBIDDEN', + NOT_FOUND = 'NOT_FOUND', + VALIDATION_ERROR = 'VALIDATION_ERROR', + S3_ERROR = 'S3_ERROR', + INTERNAL_ERROR = 'INTERNAL_ERROR' +} + +// Helper function to create error response +function createErrorResponse( + error: StorageError, + message: string, + status: number, + details?: any +) { + return NextResponse.json( + { + error: error, + message: message, + ...(details && { details }) + }, + { status } + ); +} + // Helper function to check authentication async function checkAuth(request: Request) { const session = await getServerSession(authOptions); @@ -21,7 +48,11 @@ export async function GET(request: Request) { try { const { authorized, userId } = await checkAuth(request); if (!authorized || !userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return createErrorResponse( + StorageError.UNAUTHORIZED, + 'Authentication required', + 401 + ); } const { searchParams } = new URL(request.url); @@ -29,7 +60,12 @@ export async function GET(request: Request) { const id = searchParams.get('id'); if (!path && !id) { - return NextResponse.json({ error: 'Path or ID parameter is required' }, { status: 400 }); + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Path or ID parameter is required', + 400, + { missing: 'path or id' } + ); } // Determine the key to use @@ -42,7 +78,12 @@ export async function GET(request: Request) { // Ensure the user can only access their own files if (!key.startsWith(`user-${userId}/`)) { console.error('Unauthorized file access attempt:', { userId, fileId: id }); - return NextResponse.json({ error: 'Unauthorized access to file' }, { status: 403 }); + return createErrorResponse( + StorageError.FORBIDDEN, + 'Unauthorized access to file', + 403, + { fileId: id } + ); } } else if (path) { // If a path is provided, ensure it contains the user's ID @@ -65,28 +106,79 @@ export async function GET(request: Request) { console.log('Converted NextCloud path to S3 key:', { path, key }); } else { console.error('Unauthorized file access attempt:', { userId, filePath: path }); - return NextResponse.json({ error: 'Unauthorized access to file' }, { status: 403 }); + return createErrorResponse( + StorageError.FORBIDDEN, + 'Unauthorized access to file', + 403, + { filePath: path } + ); } } else { // If it already contains user ID, use the path directly key = path; } } else { - return NextResponse.json({ error: 'Invalid parameters' }, { status: 400 }); + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Invalid parameters', + 400 + ); + } + + // Validate key format (prevent path traversal) + if (key.includes('..') || key.includes('//')) { + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Invalid file path', + 400, + { key } + ); } console.log('Fetching file content from S3:', { key }); - // Get the file content - const content = await getObjectContent(key); - - if (!content) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }); + try { + // Get the file content + const content = await getObjectContent(key); + + if (!content) { + return createErrorResponse( + StorageError.NOT_FOUND, + 'File not found', + 404, + { key } + ); + } + + return NextResponse.json({ content }); + } catch (s3Error) { + console.error('S3 error fetching file content:', s3Error); + // Check if file doesn't exist + if (s3Error instanceof Error && s3Error.message.includes('NoSuchKey')) { + return createErrorResponse( + StorageError.NOT_FOUND, + 'File not found', + 404, + { key } + ); + } + return createErrorResponse( + StorageError.S3_ERROR, + 'Failed to fetch file content from storage', + 503, + { + error: s3Error instanceof Error ? s3Error.message : String(s3Error), + key + } + ); } - - return NextResponse.json({ content }); } catch (error) { console.error('Error fetching file content:', error); - return NextResponse.json({ error: 'Internal server error', details: error instanceof Error ? error.message : String(error) }, { status: 500 }); + return createErrorResponse( + StorageError.INTERNAL_ERROR, + 'An unexpected error occurred', + 500, + { error: error instanceof Error ? error.message : String(error) } + ); } } \ No newline at end of file diff --git a/app/api/storage/files/route.ts b/app/api/storage/files/route.ts index 8d4ae24..54cbc2f 100644 --- a/app/api/storage/files/route.ts +++ b/app/api/storage/files/route.ts @@ -3,6 +3,33 @@ import { getServerSession } from 'next-auth'; import { authOptions } from "@/app/api/auth/options"; import { listUserObjects, putObject, deleteObject } from '@/lib/s3'; +// Error types for better error handling +enum StorageError { + UNAUTHORIZED = 'UNAUTHORIZED', + FORBIDDEN = 'FORBIDDEN', + NOT_FOUND = 'NOT_FOUND', + VALIDATION_ERROR = 'VALIDATION_ERROR', + S3_ERROR = 'S3_ERROR', + INTERNAL_ERROR = 'INTERNAL_ERROR' +} + +// Helper function to create error response +function createErrorResponse( + error: StorageError, + message: string, + status: number, + details?: any +) { + return NextResponse.json( + { + error: error, + message: message, + ...(details && { details }) + }, + { status } + ); +} + // Helper function to check authentication async function checkAuth(request: Request) { const session = await getServerSession(authOptions); @@ -22,14 +49,33 @@ export async function GET(request: Request) { try { const { authorized, userId } = await checkAuth(request); if (!authorized || !userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return createErrorResponse( + StorageError.UNAUTHORIZED, + 'Authentication required', + 401 + ); } const { searchParams } = new URL(request.url); const folderParam = searchParams.get('folder'); if (!folderParam) { - return NextResponse.json({ error: 'Folder parameter is required' }, { status: 400 }); + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Folder parameter is required', + 400, + { missing: 'folder' } + ); + } + + // Validate folder name (prevent path traversal) + if (folderParam.includes('..') || folderParam.includes('/')) { + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Invalid folder name', + 400, + { folder: folderParam } + ); } // Try both lowercase and original case to maintain compatibility @@ -38,19 +84,37 @@ export async function GET(request: Request) { console.log(`Listing files for user ${userId} in folder: ${folderParam} (normalized: ${normalizedFolder})`); - // First try with the exact folder name as provided - let files = await listUserObjects(userId, folderParam); - - // If no files found with original case, try with lowercase - if (files.length === 0 && folderParam !== normalizedFolder) { - console.log(`No files found with original case, trying lowercase: ${normalizedFolder}`); - files = await listUserObjects(userId, normalizedFolder); + try { + // First try with the exact folder name as provided + let files = await listUserObjects(userId, folderParam); + + // If no files found with original case, try with lowercase + if (files.length === 0 && folderParam !== normalizedFolder) { + console.log(`No files found with original case, trying lowercase: ${normalizedFolder}`); + files = await listUserObjects(userId, normalizedFolder); + } + + return NextResponse.json(files); + } catch (s3Error) { + console.error('S3 error listing files:', s3Error); + return createErrorResponse( + StorageError.S3_ERROR, + 'Failed to list files from storage', + 503, + { + error: s3Error instanceof Error ? s3Error.message : String(s3Error), + folder: folderParam + } + ); } - - return NextResponse.json(files); } catch (error) { console.error('Error listing files:', error); - return NextResponse.json({ error: 'Internal server error', details: error instanceof Error ? error.message : String(error) }, { status: 500 }); + return createErrorResponse( + StorageError.INTERNAL_ERROR, + 'An unexpected error occurred', + 500, + { error: error instanceof Error ? error.message : String(error) } + ); } } @@ -59,32 +123,104 @@ export async function POST(request: Request) { try { const { authorized, userId } = await checkAuth(request); if (!authorized || !userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return createErrorResponse( + StorageError.UNAUTHORIZED, + 'Authentication required', + 401 + ); + } + + let body; + try { + body = await request.json(); + } catch (parseError) { + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Invalid JSON in request body', + 400 + ); } - const body = await request.json().catch(() => ({})); const { title, content, folder } = body; if (!title || !content || !folder) { - return NextResponse.json({ error: 'Missing required fields', received: { title: !!title, content: !!content, folder: !!folder } }, { status: 400 }); + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Missing required fields', + 400, + { + received: { + title: !!title, + content: !!content, + folder: !!folder + }, + required: ['title', 'content', 'folder'] + } + ); + } + + // Validate inputs + if (typeof title !== 'string' || title.trim().length === 0) { + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Title must be a non-empty string', + 400 + ); + } + + if (typeof content !== 'string') { + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Content must be a string', + 400 + ); + } + + // Validate folder name (prevent path traversal) + if (folder.includes('..') || folder.includes('/')) { + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Invalid folder name', + 400, + { folder } + ); } // Normalize folder name const normalizedFolder = folder.toLowerCase(); + // Sanitize title (remove dangerous characters) + const sanitizedTitle = title.replace(/[^a-zA-Z0-9._-]/g, '_'); + // Create the full key (path) for the S3 object - // Remove 'pages/' prefix since it's already the bucket name - const key = `user-${userId}/${normalizedFolder}/${title}${title.endsWith('.md') ? '' : '.md'}`; + const key = `user-${userId}/${normalizedFolder}/${sanitizedTitle}${sanitizedTitle.endsWith('.md') ? '' : '.md'}`; console.log('Creating file in S3:', { key, contentLength: content.length }); - // Save the file to S3 - const file = await putObject(key, content); - - return NextResponse.json(file); + try { + // Save the file to S3 + const file = await putObject(key, content); + return NextResponse.json(file); + } catch (s3Error) { + console.error('S3 error creating file:', s3Error); + return createErrorResponse( + StorageError.S3_ERROR, + 'Failed to create file in storage', + 503, + { + error: s3Error instanceof Error ? s3Error.message : String(s3Error), + key + } + ); + } } catch (error) { console.error('Error creating file:', error); - return NextResponse.json({ error: 'Internal server error', details: error instanceof Error ? error.message : String(error) }, { status: 500 }); + return createErrorResponse( + StorageError.INTERNAL_ERROR, + 'An unexpected error occurred', + 500, + { error: error instanceof Error ? error.message : String(error) } + ); } } @@ -93,10 +229,24 @@ export async function PUT(request: Request) { try { const { authorized, userId } = await checkAuth(request); if (!authorized || !userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return createErrorResponse( + StorageError.UNAUTHORIZED, + 'Authentication required', + 401 + ); + } + + let body; + try { + body = await request.json(); + } catch (parseError) { + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Invalid JSON in request body', + 400 + ); } - const body = await request.json().catch(() => ({})); const { id, title, content, folder, mime } = body; // Check if this is using the direct id (key) or needs to construct one @@ -105,28 +255,75 @@ export async function PUT(request: Request) { if (id) { // Ensure the user can only access their own files if (!id.includes(`user-${userId}/`)) { - return NextResponse.json({ error: 'Unauthorized access to file' }, { status: 403 }); + return createErrorResponse( + StorageError.FORBIDDEN, + 'Unauthorized access to file', + 403, + { fileId: id } + ); } key = id; } else { // If id is not provided, construct it from folder and title if (!title || !folder) { - return NextResponse.json({ error: 'Missing required fields', received: { title: !!title, folder: !!folder } }, { status: 400 }); + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Missing required fields: either id or (title and folder) must be provided', + 400, + { received: { title: !!title, folder: !!folder } } + ); } + + // Validate folder name + if (folder.includes('..') || folder.includes('/')) { + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Invalid folder name', + 400, + { folder } + ); + } + const normalizedFolder = folder.toLowerCase(); - // Remove 'pages/' prefix since it's already the bucket name - key = `user-${userId}/${normalizedFolder}/${title}${title.endsWith('.md') ? '' : '.md'}`; + const sanitizedTitle = title.replace(/[^a-zA-Z0-9._-]/g, '_'); + key = `user-${userId}/${normalizedFolder}/${sanitizedTitle}${sanitizedTitle.endsWith('.md') ? '' : '.md'}`; + } + + // Validate content + if (content === undefined || content === null) { + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Content is required', + 400 + ); } console.log('Updating file in S3:', { key, contentLength: content?.length }); - // Update the file - const file = await putObject(key, content, mime); - - return NextResponse.json(file); + try { + // Update the file + const file = await putObject(key, content, mime); + return NextResponse.json(file); + } catch (s3Error) { + console.error('S3 error updating file:', s3Error); + return createErrorResponse( + StorageError.S3_ERROR, + 'Failed to update file in storage', + 503, + { + error: s3Error instanceof Error ? s3Error.message : String(s3Error), + key + } + ); + } } catch (error) { console.error('Error updating file:', error); - return NextResponse.json({ error: 'Internal server error', details: error instanceof Error ? error.message : String(error) }, { status: 500 }); + return createErrorResponse( + StorageError.INTERNAL_ERROR, + 'An unexpected error occurred', + 500, + { error: error instanceof Error ? error.message : String(error) } + ); } } @@ -135,29 +332,69 @@ export async function DELETE(request: Request) { try { const { authorized, userId } = await checkAuth(request); if (!authorized || !userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return createErrorResponse( + StorageError.UNAUTHORIZED, + 'Authentication required', + 401 + ); } const { searchParams } = new URL(request.url); const id = searchParams.get('id'); if (!id) { - return NextResponse.json({ error: 'Missing file id' }, { status: 400 }); + return createErrorResponse( + StorageError.VALIDATION_ERROR, + 'Missing file id parameter', + 400, + { missing: 'id' } + ); } // Ensure the user can only delete their own files if (!id.includes(`user-${userId}/`)) { - return NextResponse.json({ error: 'Unauthorized access to file' }, { status: 403 }); + return createErrorResponse( + StorageError.FORBIDDEN, + 'Unauthorized access to file', + 403, + { fileId: id } + ); } console.log('Deleting file from S3:', { key: id }); - // Delete the file - await deleteObject(id); - - return NextResponse.json({ success: true }); + try { + // Delete the file + await deleteObject(id); + return NextResponse.json({ success: true }); + } catch (s3Error) { + console.error('S3 error deleting file:', s3Error); + // Check if file doesn't exist (404) + if (s3Error instanceof Error && s3Error.message.includes('NoSuchKey')) { + return createErrorResponse( + StorageError.NOT_FOUND, + 'File not found', + 404, + { fileId: id } + ); + } + return createErrorResponse( + StorageError.S3_ERROR, + 'Failed to delete file from storage', + 503, + { + error: s3Error instanceof Error ? s3Error.message : String(s3Error), + fileId: id + } + ); + } } catch (error) { console.error('Error deleting file:', error); - return NextResponse.json({ error: 'Internal server error', details: error instanceof Error ? error.message : String(error) }, { status: 500 }); + return createErrorResponse( + StorageError.INTERNAL_ERROR, + 'An unexpected error occurred', + 500, + { error: error instanceof Error ? error.message : String(error) } + ); } } \ No newline at end of file diff --git a/app/pages/page.tsx b/app/pages/page.tsx index bc46ee2..75173ca 100644 --- a/app/pages/page.tsx +++ b/app/pages/page.tsx @@ -13,6 +13,7 @@ import { X, Menu } from "lucide-react"; import { ContactDetails } from '@/components/carnet/contact-details'; import { parse as parseVCard, format as formatVCard } from 'vcard-parser'; import { PaneLayout } from './pane-layout'; +import { notesCache, noteContentCache, foldersCache, invalidateFolderCache, invalidateNoteCache } from '@/lib/cache-utils'; interface Note { id: string; @@ -62,60 +63,32 @@ export default function CarnetPage() { const isSmallScreen = useMediaQuery("(max-width: 768px)"); const isMediumScreen = useMediaQuery("(max-width: 1024px)"); - // Cache for Nextcloud folders - const foldersCache = useRef<{ folders: string[]; timestamp: number } | null>(null); - - // Cache for notes list (Panel 2) - const notesCache = useRef>({}); - - // Cache for note content (Panel 3) - const noteContentCache = useRef>({}); - - // Clear folder cache on component mount to ensure fresh data - useEffect(() => { - try { - localStorage.removeItem('nextcloud_folders'); - console.log('Cleared folder cache'); - } catch (error) { - console.error('Error clearing folder cache:', error); - } - }, []); + // Cache is now managed by cache-utils.ts useEffect(() => { const fetchNextcloudFolders = async () => { - // First check localStorage cache - const cachedData = localStorage.getItem('nextcloud_folders'); - if (cachedData) { - const { folders, timestamp } = JSON.parse(cachedData); - const cacheAge = Date.now() - timestamp; - if (cacheAge < 5 * 60 * 1000) { // 5 minutes cache - setNextcloudFolders(folders); - return; - } + // Check cache first + const cacheKey = session?.user?.id || 'default'; + const cachedFolders = foldersCache.get(cacheKey); + if (cachedFolders) { + setNextcloudFolders(cachedFolders); + return; } try { - const response = await fetch('/api/nextcloud/status'); + const response = await fetch('/api/storage/status'); if (!response.ok) { - throw new Error('Failed to fetch Nextcloud folders'); + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Failed to fetch storage folders'); } const data = await response.json(); const folders = data.folders || []; - // Update both localStorage and memory cache - localStorage.setItem('nextcloud_folders', JSON.stringify({ - folders, - timestamp: Date.now() - })); - - foldersCache.current = { - folders, - timestamp: Date.now() - }; - + // Update cache + foldersCache.set(cacheKey, folders); setNextcloudFolders(folders); } catch (err) { - console.error('Error fetching Nextcloud folders:', err); + console.error('Error fetching storage folders:', err); setNextcloudFolders([]); } }; @@ -123,7 +96,7 @@ export default function CarnetPage() { if (status === "authenticated") { fetchNextcloudFolders(); } - }, [status]); + }, [status, session?.user?.id]); useEffect(() => { if (status === "unauthenticated") { @@ -184,21 +157,14 @@ export default function CarnetPage() { useEffect(() => { const handleNoteSaved = (event: CustomEvent) => { const folder = event.detail?.folder; - if (folder && selectedFolder.toLowerCase() === folder.toLowerCase()) { - console.log('Note saved event received, refreshing notes list'); - // Clear cache and fetch fresh notes - const cacheKey = `${session?.user?.id}-${folder}`; - if (notesCache.current[cacheKey]) { - delete notesCache.current[cacheKey]; + if (folder && session?.user?.id) { + console.log('Note saved event received, invalidating cache for folder:', folder); + // Invalidate cache for this folder + invalidateFolderCache(session.user.id, folder); + // Fetch notes if this is the current folder + if (selectedFolder.toLowerCase() === folder.toLowerCase()) { + fetchNotes(); } - try { - localStorage.removeItem(`notes-cache-${cacheKey}`); - } catch (error) { - console.error('Error removing cache:', error); - } - // Fetch notes will be called by the existing useEffect when selectedFolder changes - // But we can also call it directly here - fetchNotes(); } }; @@ -309,6 +275,11 @@ export default function CarnetPage() { // Fetch notes based on the selected folder const fetchNotes = async () => { + if (!session?.user?.id) { + setIsLoadingNotes(false); + return; + } + try { setIsLoadingNotes(true); @@ -316,42 +287,18 @@ export default function CarnetPage() { const folderLowercase = selectedFolder.toLowerCase(); console.log(`Fetching notes from folder: ${folderLowercase}`); - // Check in-memory cache first - const cacheKey = `${session?.user?.id}-${folderLowercase}`; - const cachedNotes = notesCache.current[cacheKey]; - const CACHE_EXPIRATION = 5 * 60 * 1000; // 5 minutes in milliseconds + // Check cache first + const cacheKey = `${session.user.id}-${folderLowercase}`; + const cachedNotes = notesCache.get(cacheKey); - if (cachedNotes && (Date.now() - cachedNotes.timestamp) < CACHE_EXPIRATION) { + if (cachedNotes) { console.log(`Using cached notes for ${folderLowercase} folder`); - setNotes(cachedNotes.notes); + setNotes(cachedNotes); setIsLoadingNotes(false); return; } - // Check localStorage cache if in-memory cache is not available - try { - const localStorageKey = `notes-cache-${cacheKey}`; - const storedCache = localStorage.getItem(localStorageKey); - - if (storedCache) { - const { notes, timestamp } = JSON.parse(storedCache); - - if ((Date.now() - timestamp) < CACHE_EXPIRATION) { - console.log(`Using localStorage cached notes for ${folderLowercase} folder`); - setNotes(notes); - - // Update in-memory cache - notesCache.current[cacheKey] = { notes, timestamp }; - - setIsLoadingNotes(false); - return; - } - } - } catch (error) { - console.error('Error accessing localStorage notes cache:', error); - } - - // Use direct storage API instead of adapter if cache is not available or expired + // Fetch from API const response = await fetch(`/api/storage/files?folder=${folderLowercase}`); if (response.ok) { @@ -361,20 +308,11 @@ export default function CarnetPage() { // Update state setNotes(data); - // Update both caches - const newTimestamp = Date.now(); - notesCache.current[cacheKey] = { notes: data, timestamp: newTimestamp }; - - try { - localStorage.setItem(`notes-cache-${cacheKey}`, JSON.stringify({ - notes: data, - timestamp: newTimestamp - })); - } catch (error) { - console.error('Error saving notes to localStorage:', error); - } + // Update cache + notesCache.set(cacheKey, data); } else { - console.error('Error fetching notes:', await response.text()); + const errorData = await response.json().catch(() => ({})); + console.error('Error fetching notes:', errorData.message || response.statusText); setNotes([]); } } catch (error) { @@ -415,42 +353,22 @@ export default function CarnetPage() { if (response.ok) { // Invalidate the cache for this folder to ensure fresh data on next fetch - const cacheKey = `${session?.user?.id}-${selectedFolder.toLowerCase()}`; - - // Remove from in-memory cache - if (notesCache.current[cacheKey]) { - delete notesCache.current[cacheKey]; - } - - // Remove from localStorage cache - try { - localStorage.removeItem(`notes-cache-${cacheKey}`); - } catch (error) { - console.error('Error removing notes from localStorage:', error); + if (session?.user?.id) { + invalidateFolderCache(session.user.id, selectedFolder); } // Update the content cache for this note if (payload.id) { - noteContentCache.current[payload.id] = { - content: payload.content, - timestamp: Date.now() - }; - - // Update localStorage cache - try { - localStorage.setItem(`note-content-${payload.id}`, JSON.stringify({ - content: payload.content, - timestamp: Date.now() - })); - } catch (error) { - console.error('Error saving note content to localStorage:', error); - } + noteContentCache.set(payload.id, payload.content); } // Refresh the list of notes fetchNotes(); } else { - console.error('Error saving note:', await response.text()); + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData.message || errorData.error || 'Failed to save note'; + console.error('Error saving note:', errorMessage); + throw new Error(errorMessage); } } catch (error) { console.error('Error saving note:', error); @@ -485,7 +403,7 @@ export default function CarnetPage() { const handleNoteSave = async (note: Note) => { try { - const endpoint = note.id ? '/api/nextcloud/files' : '/api/nextcloud/files'; + const endpoint = '/api/storage/files'; const method = note.id ? 'PUT' : 'POST'; const response = await fetch(endpoint, { @@ -497,22 +415,24 @@ export default function CarnetPage() { id: note.id, title: note.title, content: note.content, - folder: selectedFolder + folder: selectedFolder.toLowerCase() }), }); if (!response.ok) { - throw new Error('Failed to save note'); + const errorText = await response.text(); + throw new Error(`Failed to save note: ${errorText}`); } // After successful save, refresh the notes list - const notesResponse = await fetch(`/api/nextcloud/files?folder=${selectedFolder}`); + const notesResponse = await fetch(`/api/storage/files?folder=${selectedFolder.toLowerCase()}`); if (notesResponse.ok) { const updatedNotes = await notesResponse.json(); setNotes(updatedNotes); } } catch (error) { console.error('Error saving note:', error); + throw error; // Re-throw pour permettre la gestion d'erreur en amont } }; @@ -570,23 +490,20 @@ export default function CarnetPage() { const handleDeleteNote = async (note: Note) => { try { - const response = await fetch(`/api/nextcloud/files`, { + const response = await fetch(`/api/storage/files?id=${encodeURIComponent(note.id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - id: note.id, - folder: selectedFolder - }), }); if (!response.ok) { - throw new Error('Failed to delete note'); + const errorText = await response.text(); + throw new Error(`Failed to delete note: ${errorText}`); } // Refresh the notes list - const notesResponse = await fetch(`/api/nextcloud/files?folder=${selectedFolder}`); + const notesResponse = await fetch(`/api/storage/files?folder=${selectedFolder.toLowerCase()}`); if (notesResponse.ok) { const updatedNotes = await notesResponse.json(); setNotes(updatedNotes); @@ -598,6 +515,7 @@ export default function CarnetPage() { } } catch (error) { console.error('Error deleting note:', error); + throw error; // Re-throw pour permettre la gestion d'erreur en amont } }; @@ -607,17 +525,16 @@ export default function CarnetPage() { try { setIsLoading(true); - // Always use Allemanique.vcf for new contacts - const basePath = `/files/cube-${session.user.id}/Private/Contacts`; + // Use S3 path structure: user-{userId}/contacts/{filename}.vcf const vcfFile = 'Allemanique.vcf'; - const path = `${basePath}/${vcfFile}`; + const s3Key = `user-${session.user.id}/contacts/${vcfFile}`; let vcfContent = ''; let existingContacts: string[] = []; try { // Try to get existing contacts from the VCF file - const response = await fetch(`/api/nextcloud/files/content?path=${encodeURIComponent(path)}`); + const response = await fetch(`/api/storage/files/content?path=${encodeURIComponent(s3Key)}`); if (response.ok) { const { content } = await response.json(); // Split the content into individual vCards @@ -678,23 +595,24 @@ export default function CarnetPage() { // Join all vCards back together with proper spacing vcfContent = updatedVcards.join('\n\n'); - // Save the updated VCF file - const saveResponse = await fetch('/api/nextcloud/files', { + // Save the updated VCF file using S3 storage API + const saveResponse = await fetch('/api/storage/files', { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - id: path, + id: s3Key, title: vcfFile, content: vcfContent, - folder: 'Contacts', + folder: 'contacts', mime: 'text/vcard' }), }); if (!saveResponse.ok) { - throw new Error('Failed to save contact'); + const errorText = await saveResponse.text(); + throw new Error(`Failed to save contact: ${errorText}`); } // Refresh the contacts list @@ -706,6 +624,7 @@ export default function CarnetPage() { } } catch (error) { console.error('Error saving contact:', error); + throw error; // Re-throw pour permettre la gestion d'erreur en amont } finally { setIsLoading(false); } @@ -716,18 +635,23 @@ export default function CarnetPage() { return; } + if (!session?.user?.id) { + console.error('No user session available'); + return; + } + try { setIsLoading(true); - // Determine the correct VCF file path - const basePath = `/files/cube-${session?.user?.id}/Private/Contacts`; - const vcfFile = contact.group ? `${contact.group}.vcf` : 'contacts.vcf'; - const path = `${basePath}/${vcfFile}`; + // Use S3 path structure: user-{userId}/contacts/{filename}.vcf + const vcfFile = contact.group ? `${contact.group}.vcf` : 'Allemanique.vcf'; + const s3Key = `user-${session.user.id}/contacts/${vcfFile}`; // Get existing contacts from the VCF file - const response = await fetch(`/api/nextcloud/files/content?path=${encodeURIComponent(path)}`); + const response = await fetch(`/api/storage/files/content?path=${encodeURIComponent(s3Key)}`); if (!response.ok) { - throw new Error('Failed to fetch contacts'); + const errorText = await response.text(); + throw new Error(`Failed to fetch contacts: ${errorText}`); } const { content } = await response.json(); @@ -742,23 +666,24 @@ export default function CarnetPage() { // Join the remaining vCards back together const vcfContent = updatedVcards.map((section: string) => 'BEGIN:VCARD' + section).join('\n'); - // Save the updated VCF file - const saveResponse = await fetch('/api/nextcloud/files', { + // Save the updated VCF file using S3 storage API + const saveResponse = await fetch('/api/storage/files', { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - id: path, + id: s3Key, title: vcfFile, content: vcfContent, - folder: 'Contacts', + folder: 'contacts', mime: 'text/vcard' }), }); if (!saveResponse.ok) { - throw new Error('Failed to delete contact'); + const errorText = await saveResponse.text(); + throw new Error(`Failed to delete contact: ${errorText}`); } // Clear selected contact and refresh list @@ -766,6 +691,7 @@ export default function CarnetPage() { await fetchContacts(selectedFolder); } catch (error) { console.error('Error deleting contact:', error); + throw error; // Re-throw pour permettre la gestion d'erreur en amont } finally { setIsLoading(false); } diff --git a/components/carnet/editor.tsx b/components/carnet/editor.tsx index b75e36b..03584ef 100644 --- a/components/carnet/editor.tsx +++ b/components/carnet/editor.tsx @@ -4,6 +4,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Image, FileText, Link, List, Plus } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useSession } from 'next-auth/react'; +import { noteContentCache } from '@/lib/cache-utils'; interface Note { id: string; @@ -31,10 +32,6 @@ export const Editor: React.FC = ({ note, onSave, currentFolder = 'N const saveTimeout = useRef(); const router = useRouter(); const { data: session, status } = useSession(); - - // Content cache for notes - const contentCache = useRef>({}); - const CACHE_EXPIRATION = 15 * 60 * 1000; // 15 minutes in milliseconds useEffect(() => { // Redirect to login if not authenticated @@ -49,38 +46,15 @@ export const Editor: React.FC = ({ note, onSave, currentFolder = 'N setIsLoading(true); setError(null); - // First check in-memory cache - const cachedContent = contentCache.current[note.id]; - if (cachedContent && (Date.now() - cachedContent.timestamp) < CACHE_EXPIRATION) { + // Check cache first + const cachedContent = noteContentCache.get(note.id); + if (cachedContent) { console.log(`Using cached content for note ${note.title}`); - setContent(cachedContent.content || ''); + setContent(cachedContent); setIsLoading(false); return; } - // Then check localStorage cache - try { - const localStorageKey = `note-content-${note.id}`; - const storedCache = localStorage.getItem(localStorageKey); - - if (storedCache) { - const { content, timestamp } = JSON.parse(storedCache); - - if ((Date.now() - timestamp) < CACHE_EXPIRATION) { - console.log(`Using localStorage cached content for note ${note.title}`); - setContent(content || ''); - - // Update in-memory cache - contentCache.current[note.id] = { content, timestamp }; - - setIsLoading(false); - return; - } - } - } catch (error) { - console.error('Error accessing localStorage content cache:', error); - } - // If cache miss, fetch from API try { const response = await fetch(`/api/storage/files/content?path=${encodeURIComponent(note.id)}`); @@ -90,27 +64,19 @@ export const Editor: React.FC = ({ note, onSave, currentFolder = 'N return; } if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to fetch note content: ${response.status} ${errorText}`); + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData.message || errorData.error || `Failed to fetch note content: ${response.status}`; + throw new Error(errorMessage); } const data = await response.json(); setContent(data.content || ''); - // Update both caches - const newTimestamp = Date.now(); - contentCache.current[note.id] = { content: data.content, timestamp: newTimestamp }; - - try { - localStorage.setItem(`note-content-${note.id}`, JSON.stringify({ - content: data.content, - timestamp: newTimestamp - })); - } catch (error) { - console.error('Error saving content to localStorage:', error); - } + // Update cache + noteContentCache.set(note.id, data.content || ''); } catch (error) { console.error('Error fetching note content:', error); - setError('Failed to load note content. Please try again later.'); + const errorMessage = error instanceof Error ? error.message : 'Failed to load note content. Please try again later.'; + setError(errorMessage); } finally { setIsLoading(false); } @@ -128,7 +94,7 @@ export const Editor: React.FC = ({ note, onSave, currentFolder = 'N setTitle(''); setContent(''); } - }, [note, router, CACHE_EXPIRATION]); + }, [note, router]); const handleTitleChange = (e: React.ChangeEvent) => { setTitle(e.target.value); @@ -207,23 +173,7 @@ export const Editor: React.FC = ({ note, onSave, currentFolder = 'N console.log('Note saved successfully:', savedNote); // Update content cache after successful save - const newTimestamp = Date.now(); - - // Update in-memory cache - contentCache.current[noteId] = { - content, - timestamp: newTimestamp - }; - - // Update localStorage cache - try { - localStorage.setItem(`note-content-${noteId}`, JSON.stringify({ - content, - timestamp: newTimestamp - })); - } catch (error) { - console.error('Error updating content cache in localStorage:', error); - } + noteContentCache.set(noteId, content); setError(null); onSave?.({ diff --git a/lib/cache-utils.ts b/lib/cache-utils.ts new file mode 100644 index 0000000..3069016 --- /dev/null +++ b/lib/cache-utils.ts @@ -0,0 +1,237 @@ +/** + * Cache utilities for Pages application + * Provides centralized cache management with proper invalidation + */ + +interface CacheEntry { + data: T; + timestamp: number; +} + +interface CacheConfig { + ttl: number; // Time to live in milliseconds + keyPrefix: string; +} + +class CacheManager { + private memoryCache: Map> = new Map(); + private config: CacheConfig; + + constructor(config: CacheConfig) { + this.config = config; + } + + /** + * Get data from cache (memory first, then localStorage) + */ + get(key: string): T | null { + const fullKey = `${this.config.keyPrefix}${key}`; + + // Check memory cache first + const memoryEntry = this.memoryCache.get(fullKey); + if (memoryEntry && this.isValid(memoryEntry.timestamp)) { + return memoryEntry.data as T; + } + + // Check localStorage + try { + const stored = localStorage.getItem(fullKey); + if (stored) { + const entry: CacheEntry = JSON.parse(stored); + if (this.isValid(entry.timestamp)) { + // Update memory cache + this.memoryCache.set(fullKey, entry); + return entry.data; + } else { + // Expired, remove it + localStorage.removeItem(fullKey); + } + } + } catch (error) { + console.error('Error reading from localStorage cache:', error); + } + + return null; + } + + /** + * Set data in cache (both memory and localStorage) + */ + set(key: string, data: T): void { + const fullKey = `${this.config.keyPrefix}${key}`; + const entry: CacheEntry = { + data, + timestamp: Date.now() + }; + + // Update memory cache + this.memoryCache.set(fullKey, entry); + + // Update localStorage + try { + localStorage.setItem(fullKey, JSON.stringify(entry)); + } catch (error) { + console.error('Error writing to localStorage cache:', error); + // If localStorage is full, try to clear old entries + this.clearExpired(); + } + } + + /** + * Remove specific cache entry + */ + remove(key: string): void { + const fullKey = `${this.config.keyPrefix}${key}`; + + // Remove from memory + this.memoryCache.delete(fullKey); + + // Remove from localStorage + try { + localStorage.removeItem(fullKey); + } catch (error) { + console.error('Error removing from localStorage cache:', error); + } + } + + /** + * Clear all cache entries matching a pattern + */ + clearPattern(pattern: string): void { + const fullPattern = `${this.config.keyPrefix}${pattern}`; + + // Clear from memory + for (const key of this.memoryCache.keys()) { + if (key.includes(fullPattern)) { + this.memoryCache.delete(key); + } + } + + // Clear from localStorage + try { + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.includes(fullPattern)) { + keysToRemove.push(key); + } + } + keysToRemove.forEach(key => localStorage.removeItem(key)); + } catch (error) { + console.error('Error clearing pattern from localStorage cache:', error); + } + } + + /** + * Clear all expired entries + */ + clearExpired(): void { + const now = Date.now(); + + // Clear from memory + for (const [key, entry] of this.memoryCache.entries()) { + if (!this.isValid(entry.timestamp)) { + this.memoryCache.delete(key); + } + } + + // Clear from localStorage + try { + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.config.keyPrefix)) { + try { + const entry: CacheEntry = JSON.parse(localStorage.getItem(key) || '{}'); + if (!this.isValid(entry.timestamp)) { + keysToRemove.push(key); + } + } catch { + // Invalid entry, remove it + keysToRemove.push(key); + } + } + } + keysToRemove.forEach(key => localStorage.removeItem(key)); + } catch (error) { + console.error('Error clearing expired entries from localStorage:', error); + } + } + + /** + * Clear all cache + */ + clearAll(): void { + this.memoryCache.clear(); + + try { + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.config.keyPrefix)) { + keysToRemove.push(key); + } + } + keysToRemove.forEach(key => localStorage.removeItem(key)); + } catch (error) { + console.error('Error clearing all from localStorage cache:', error); + } + } + + /** + * Check if cache entry is still valid + */ + private isValid(timestamp: number): boolean { + return (Date.now() - timestamp) < this.config.ttl; + } +} + +// Export cache managers for different use cases +export const notesCache = new CacheManager({ + ttl: 5 * 60 * 1000, // 5 minutes + keyPrefix: 'notes-cache-' +}); + +export const noteContentCache = new CacheManager({ + ttl: 15 * 60 * 1000, // 15 minutes + keyPrefix: 'note-content-' +}); + +export const foldersCache = new CacheManager({ + ttl: 2 * 60 * 1000, // 2 minutes + keyPrefix: 'nextcloud_folders' +}); + +/** + * Invalidate cache for a specific folder + */ +export function invalidateFolderCache(userId: string, folder: string): void { + const folderLowercase = folder.toLowerCase(); + const cacheKey = `${userId}-${folderLowercase}`; + + // Clear notes list cache + notesCache.remove(cacheKey); + + // Clear all note content caches for this folder (pattern match) + noteContentCache.clearPattern(`user-${userId}/${folderLowercase}/`); + + console.log(`Cache invalidated for folder: ${folderLowercase}`); +} + +/** + * Invalidate cache for a specific note + */ +export function invalidateNoteCache(noteId: string): void { + noteContentCache.remove(noteId); + console.log(`Cache invalidated for note: ${noteId}`); +} + +/** + * Clear all caches for a user + */ +export function clearUserCache(userId: string): void { + notesCache.clearPattern(userId); + noteContentCache.clearPattern(`user-${userId}/`); + foldersCache.clearAll(); + console.log(`All caches cleared for user: ${userId}`); +} diff --git a/lib/s3.ts b/lib/s3.ts index cd50132..177f538 100644 --- a/lib/s3.ts +++ b/lib/s3.ts @@ -3,13 +3,21 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; // S3 Configuration export const S3_CONFIG = { - endpoint: 'https://dome-api.slm-lab.net', - region: 'us-east-1', + endpoint: process.env.S3_ENDPOINT || process.env.MINIO_S3_UPLOAD_BUCKET_URL || 'https://dome-api.slm-lab.net', + region: process.env.S3_REGION || process.env.MINIO_AWS_REGION || 'us-east-1', bucket: process.env.S3_BUCKET || 'pages', - accessKey: '4aBT4CMb7JIMMyUtp4Pl', - secretKey: 'HGn39XhCIlqOjmDVzRK9MED2Fci2rYvDDgbLFElg' + accessKey: process.env.S3_ACCESS_KEY || process.env.MINIO_ACCESS_KEY || '', + secretKey: process.env.S3_SECRET_KEY || process.env.MINIO_SECRET_KEY || '' }; +// Validate required S3 configuration +if (!S3_CONFIG.accessKey || !S3_CONFIG.secretKey) { + console.error('⚠️ S3 credentials are missing! Please set S3_ACCESS_KEY and S3_SECRET_KEY environment variables.'); + if (process.env.NODE_ENV === 'production') { + throw new Error('S3 credentials are required in production environment'); + } +} + // Initialize S3 client for Minio export const s3Client = new S3Client({ region: S3_CONFIG.region,