Notifications corrections

This commit is contained in:
alma 2026-01-16 11:23:13 +01:00
parent 0beb7eb815
commit 5397536572
7 changed files with 1167 additions and 279 deletions

438
ANALYSE_PAGES_S3.md Normal file
View File

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

View File

@ -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);
try {
// Get the file content
const content = await getObjectContent(key);
if (!content) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
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) }
);
}
}

View File

@ -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);
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);
// 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) }
);
}
}

View File

@ -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<Record<string, { notes: Note[]; timestamp: number }>>({});
// Cache for note content (Panel 3)
const noteContentCache = useRef<Record<string, { content: string; timestamp: number }>>({});
// 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<string[]>(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<Note[]>(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);
}

View File

@ -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;
@ -32,10 +33,6 @@ export const Editor: React.FC<EditorProps> = ({ note, onSave, currentFolder = 'N
const router = useRouter();
const { data: session, status } = useSession();
// Content cache for notes
const contentCache = useRef<Record<string, { content: string; timestamp: number }>>({});
const CACHE_EXPIRATION = 15 * 60 * 1000; // 15 minutes in milliseconds
useEffect(() => {
// Redirect to login if not authenticated
if (status === 'unauthenticated') {
@ -49,38 +46,15 @@ export const Editor: React.FC<EditorProps> = ({ 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<string>(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<EditorProps> = ({ 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<EditorProps> = ({ note, onSave, currentFolder = 'N
setTitle('');
setContent('');
}
}, [note, router, CACHE_EXPIRATION]);
}, [note, router]);
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
@ -207,23 +173,7 @@ export const Editor: React.FC<EditorProps> = ({ 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?.({

237
lib/cache-utils.ts Normal file
View File

@ -0,0 +1,237 @@
/**
* Cache utilities for Pages application
* Provides centralized cache management with proper invalidation
*/
interface CacheEntry<T> {
data: T;
timestamp: number;
}
interface CacheConfig {
ttl: number; // Time to live in milliseconds
keyPrefix: string;
}
class CacheManager {
private memoryCache: Map<string, CacheEntry<any>> = new Map();
private config: CacheConfig;
constructor(config: CacheConfig) {
this.config = config;
}
/**
* Get data from cache (memory first, then localStorage)
*/
get<T>(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<T> = 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<T>(key: string, data: T): void {
const fullKey = `${this.config.keyPrefix}${key}`;
const entry: CacheEntry<T> = {
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<any> = 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}`);
}

View File

@ -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,