Notifications corrections
This commit is contained in:
parent
0beb7eb815
commit
5397536572
438
ANALYSE_PAGES_S3.md
Normal file
438
ANALYSE_PAGES_S3.md
Normal 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*
|
||||||
@ -3,6 +3,33 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { authOptions } from "@/app/api/auth/options";
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
import { getObjectContent } from '@/lib/s3';
|
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
|
// Helper function to check authentication
|
||||||
async function checkAuth(request: Request) {
|
async function checkAuth(request: Request) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@ -21,7 +48,11 @@ export async function GET(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const { authorized, userId } = await checkAuth(request);
|
const { authorized, userId } = await checkAuth(request);
|
||||||
if (!authorized || !userId) {
|
if (!authorized || !userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return createErrorResponse(
|
||||||
|
StorageError.UNAUTHORIZED,
|
||||||
|
'Authentication required',
|
||||||
|
401
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
@ -29,7 +60,12 @@ export async function GET(request: Request) {
|
|||||||
const id = searchParams.get('id');
|
const id = searchParams.get('id');
|
||||||
|
|
||||||
if (!path && !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
|
// Determine the key to use
|
||||||
@ -42,7 +78,12 @@ export async function GET(request: Request) {
|
|||||||
// Ensure the user can only access their own files
|
// Ensure the user can only access their own files
|
||||||
if (!key.startsWith(`user-${userId}/`)) {
|
if (!key.startsWith(`user-${userId}/`)) {
|
||||||
console.error('Unauthorized file access attempt:', { userId, fileId: id });
|
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) {
|
} else if (path) {
|
||||||
// If a path is provided, ensure it contains the user's ID
|
// 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 });
|
console.log('Converted NextCloud path to S3 key:', { path, key });
|
||||||
} else {
|
} else {
|
||||||
console.error('Unauthorized file access attempt:', { userId, filePath: path });
|
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 {
|
} else {
|
||||||
// If it already contains user ID, use the path directly
|
// If it already contains user ID, use the path directly
|
||||||
key = path;
|
key = path;
|
||||||
}
|
}
|
||||||
} else {
|
} 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 });
|
console.log('Fetching file content from S3:', { key });
|
||||||
|
|
||||||
|
try {
|
||||||
// Get the file content
|
// Get the file content
|
||||||
const content = await getObjectContent(key);
|
const content = await getObjectContent(key);
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
return createErrorResponse(
|
||||||
|
StorageError.NOT_FOUND,
|
||||||
|
'File not found',
|
||||||
|
404,
|
||||||
|
{ key }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ content });
|
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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching file content:', 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) }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3,6 +3,33 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { authOptions } from "@/app/api/auth/options";
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
import { listUserObjects, putObject, deleteObject } from '@/lib/s3';
|
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
|
// Helper function to check authentication
|
||||||
async function checkAuth(request: Request) {
|
async function checkAuth(request: Request) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@ -22,14 +49,33 @@ export async function GET(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const { authorized, userId } = await checkAuth(request);
|
const { authorized, userId } = await checkAuth(request);
|
||||||
if (!authorized || !userId) {
|
if (!authorized || !userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return createErrorResponse(
|
||||||
|
StorageError.UNAUTHORIZED,
|
||||||
|
'Authentication required',
|
||||||
|
401
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const folderParam = searchParams.get('folder');
|
const folderParam = searchParams.get('folder');
|
||||||
|
|
||||||
if (!folderParam) {
|
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
|
// Try both lowercase and original case to maintain compatibility
|
||||||
@ -38,6 +84,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
console.log(`Listing files for user ${userId} in folder: ${folderParam} (normalized: ${normalizedFolder})`);
|
console.log(`Listing files for user ${userId} in folder: ${folderParam} (normalized: ${normalizedFolder})`);
|
||||||
|
|
||||||
|
try {
|
||||||
// First try with the exact folder name as provided
|
// First try with the exact folder name as provided
|
||||||
let files = await listUserObjects(userId, folderParam);
|
let files = await listUserObjects(userId, folderParam);
|
||||||
|
|
||||||
@ -48,9 +95,26 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(files);
|
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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error listing files:', 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 {
|
try {
|
||||||
const { authorized, userId } = await checkAuth(request);
|
const { authorized, userId } = await checkAuth(request);
|
||||||
if (!authorized || !userId) {
|
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;
|
const { title, content, folder } = body;
|
||||||
|
|
||||||
if (!title || !content || !folder) {
|
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
|
// Normalize folder name
|
||||||
const normalizedFolder = folder.toLowerCase();
|
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
|
// Create the full key (path) for the S3 object
|
||||||
// Remove 'pages/' prefix since it's already the bucket name
|
const key = `user-${userId}/${normalizedFolder}/${sanitizedTitle}${sanitizedTitle.endsWith('.md') ? '' : '.md'}`;
|
||||||
const key = `user-${userId}/${normalizedFolder}/${title}${title.endsWith('.md') ? '' : '.md'}`;
|
|
||||||
|
|
||||||
console.log('Creating file in S3:', { key, contentLength: content.length });
|
console.log('Creating file in S3:', { key, contentLength: content.length });
|
||||||
|
|
||||||
|
try {
|
||||||
// Save the file to S3
|
// Save the file to S3
|
||||||
const file = await putObject(key, content);
|
const file = await putObject(key, content);
|
||||||
|
|
||||||
return NextResponse.json(file);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error creating file:', 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 {
|
try {
|
||||||
const { authorized, userId } = await checkAuth(request);
|
const { authorized, userId } = await checkAuth(request);
|
||||||
if (!authorized || !userId) {
|
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;
|
const { id, title, content, folder, mime } = body;
|
||||||
|
|
||||||
// Check if this is using the direct id (key) or needs to construct one
|
// 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) {
|
if (id) {
|
||||||
// Ensure the user can only access their own files
|
// Ensure the user can only access their own files
|
||||||
if (!id.includes(`user-${userId}/`)) {
|
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;
|
key = id;
|
||||||
} else {
|
} else {
|
||||||
// If id is not provided, construct it from folder and title
|
// If id is not provided, construct it from folder and title
|
||||||
if (!title || !folder) {
|
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();
|
const normalizedFolder = folder.toLowerCase();
|
||||||
// Remove 'pages/' prefix since it's already the bucket name
|
const sanitizedTitle = title.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
key = `user-${userId}/${normalizedFolder}/${title}${title.endsWith('.md') ? '' : '.md'}`;
|
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 });
|
console.log('Updating file in S3:', { key, contentLength: content?.length });
|
||||||
|
|
||||||
|
try {
|
||||||
// Update the file
|
// Update the file
|
||||||
const file = await putObject(key, content, mime);
|
const file = await putObject(key, content, mime);
|
||||||
|
|
||||||
return NextResponse.json(file);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error updating file:', 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 {
|
try {
|
||||||
const { authorized, userId } = await checkAuth(request);
|
const { authorized, userId } = await checkAuth(request);
|
||||||
if (!authorized || !userId) {
|
if (!authorized || !userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return createErrorResponse(
|
||||||
|
StorageError.UNAUTHORIZED,
|
||||||
|
'Authentication required',
|
||||||
|
401
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const id = searchParams.get('id');
|
const id = searchParams.get('id');
|
||||||
|
|
||||||
if (!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
|
// Ensure the user can only delete their own files
|
||||||
if (!id.includes(`user-${userId}/`)) {
|
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 });
|
console.log('Deleting file from S3:', { key: id });
|
||||||
|
|
||||||
|
try {
|
||||||
// Delete the file
|
// Delete the file
|
||||||
await deleteObject(id);
|
await deleteObject(id);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
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) {
|
} catch (error) {
|
||||||
console.error('Error deleting file:', 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) }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -13,6 +13,7 @@ import { X, Menu } from "lucide-react";
|
|||||||
import { ContactDetails } from '@/components/carnet/contact-details';
|
import { ContactDetails } from '@/components/carnet/contact-details';
|
||||||
import { parse as parseVCard, format as formatVCard } from 'vcard-parser';
|
import { parse as parseVCard, format as formatVCard } from 'vcard-parser';
|
||||||
import { PaneLayout } from './pane-layout';
|
import { PaneLayout } from './pane-layout';
|
||||||
|
import { notesCache, noteContentCache, foldersCache, invalidateFolderCache, invalidateNoteCache } from '@/lib/cache-utils';
|
||||||
|
|
||||||
interface Note {
|
interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
@ -62,60 +63,32 @@ export default function CarnetPage() {
|
|||||||
const isSmallScreen = useMediaQuery("(max-width: 768px)");
|
const isSmallScreen = useMediaQuery("(max-width: 768px)");
|
||||||
const isMediumScreen = useMediaQuery("(max-width: 1024px)");
|
const isMediumScreen = useMediaQuery("(max-width: 1024px)");
|
||||||
|
|
||||||
// Cache for Nextcloud folders
|
// Cache is now managed by cache-utils.ts
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchNextcloudFolders = async () => {
|
const fetchNextcloudFolders = async () => {
|
||||||
// First check localStorage cache
|
// Check cache first
|
||||||
const cachedData = localStorage.getItem('nextcloud_folders');
|
const cacheKey = session?.user?.id || 'default';
|
||||||
if (cachedData) {
|
const cachedFolders = foldersCache.get<string[]>(cacheKey);
|
||||||
const { folders, timestamp } = JSON.parse(cachedData);
|
if (cachedFolders) {
|
||||||
const cacheAge = Date.now() - timestamp;
|
setNextcloudFolders(cachedFolders);
|
||||||
if (cacheAge < 5 * 60 * 1000) { // 5 minutes cache
|
|
||||||
setNextcloudFolders(folders);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/nextcloud/status');
|
const response = await fetch('/api/storage/status');
|
||||||
if (!response.ok) {
|
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 data = await response.json();
|
||||||
const folders = data.folders || [];
|
const folders = data.folders || [];
|
||||||
|
|
||||||
// Update both localStorage and memory cache
|
// Update cache
|
||||||
localStorage.setItem('nextcloud_folders', JSON.stringify({
|
foldersCache.set(cacheKey, folders);
|
||||||
folders,
|
|
||||||
timestamp: Date.now()
|
|
||||||
}));
|
|
||||||
|
|
||||||
foldersCache.current = {
|
|
||||||
folders,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
setNextcloudFolders(folders);
|
setNextcloudFolders(folders);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching Nextcloud folders:', err);
|
console.error('Error fetching storage folders:', err);
|
||||||
setNextcloudFolders([]);
|
setNextcloudFolders([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -123,7 +96,7 @@ export default function CarnetPage() {
|
|||||||
if (status === "authenticated") {
|
if (status === "authenticated") {
|
||||||
fetchNextcloudFolders();
|
fetchNextcloudFolders();
|
||||||
}
|
}
|
||||||
}, [status]);
|
}, [status, session?.user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
@ -184,22 +157,15 @@ export default function CarnetPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleNoteSaved = (event: CustomEvent) => {
|
const handleNoteSaved = (event: CustomEvent) => {
|
||||||
const folder = event.detail?.folder;
|
const folder = event.detail?.folder;
|
||||||
if (folder && selectedFolder.toLowerCase() === folder.toLowerCase()) {
|
if (folder && session?.user?.id) {
|
||||||
console.log('Note saved event received, refreshing notes list');
|
console.log('Note saved event received, invalidating cache for folder:', folder);
|
||||||
// Clear cache and fetch fresh notes
|
// Invalidate cache for this folder
|
||||||
const cacheKey = `${session?.user?.id}-${folder}`;
|
invalidateFolderCache(session.user.id, folder);
|
||||||
if (notesCache.current[cacheKey]) {
|
// Fetch notes if this is the current folder
|
||||||
delete notesCache.current[cacheKey];
|
if (selectedFolder.toLowerCase() === folder.toLowerCase()) {
|
||||||
}
|
|
||||||
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();
|
fetchNotes();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('note-saved', handleNoteSaved as EventListener);
|
window.addEventListener('note-saved', handleNoteSaved as EventListener);
|
||||||
@ -309,6 +275,11 @@ export default function CarnetPage() {
|
|||||||
|
|
||||||
// Fetch notes based on the selected folder
|
// Fetch notes based on the selected folder
|
||||||
const fetchNotes = async () => {
|
const fetchNotes = async () => {
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
setIsLoadingNotes(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoadingNotes(true);
|
setIsLoadingNotes(true);
|
||||||
|
|
||||||
@ -316,42 +287,18 @@ export default function CarnetPage() {
|
|||||||
const folderLowercase = selectedFolder.toLowerCase();
|
const folderLowercase = selectedFolder.toLowerCase();
|
||||||
console.log(`Fetching notes from folder: ${folderLowercase}`);
|
console.log(`Fetching notes from folder: ${folderLowercase}`);
|
||||||
|
|
||||||
// Check in-memory cache first
|
// Check cache first
|
||||||
const cacheKey = `${session?.user?.id}-${folderLowercase}`;
|
const cacheKey = `${session.user.id}-${folderLowercase}`;
|
||||||
const cachedNotes = notesCache.current[cacheKey];
|
const cachedNotes = notesCache.get<Note[]>(cacheKey);
|
||||||
const CACHE_EXPIRATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
||||||
|
|
||||||
if (cachedNotes && (Date.now() - cachedNotes.timestamp) < CACHE_EXPIRATION) {
|
if (cachedNotes) {
|
||||||
console.log(`Using cached notes for ${folderLowercase} folder`);
|
console.log(`Using cached notes for ${folderLowercase} folder`);
|
||||||
setNotes(cachedNotes.notes);
|
setNotes(cachedNotes);
|
||||||
setIsLoadingNotes(false);
|
setIsLoadingNotes(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check localStorage cache if in-memory cache is not available
|
// Fetch from API
|
||||||
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
|
|
||||||
const response = await fetch(`/api/storage/files?folder=${folderLowercase}`);
|
const response = await fetch(`/api/storage/files?folder=${folderLowercase}`);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@ -361,20 +308,11 @@ export default function CarnetPage() {
|
|||||||
// Update state
|
// Update state
|
||||||
setNotes(data);
|
setNotes(data);
|
||||||
|
|
||||||
// Update both caches
|
// Update cache
|
||||||
const newTimestamp = Date.now();
|
notesCache.set(cacheKey, data);
|
||||||
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);
|
|
||||||
}
|
|
||||||
} else {
|
} 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([]);
|
setNotes([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -415,42 +353,22 @@ export default function CarnetPage() {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Invalidate the cache for this folder to ensure fresh data on next fetch
|
// Invalidate the cache for this folder to ensure fresh data on next fetch
|
||||||
const cacheKey = `${session?.user?.id}-${selectedFolder.toLowerCase()}`;
|
if (session?.user?.id) {
|
||||||
|
invalidateFolderCache(session.user.id, selectedFolder);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the content cache for this note
|
// Update the content cache for this note
|
||||||
if (payload.id) {
|
if (payload.id) {
|
||||||
noteContentCache.current[payload.id] = {
|
noteContentCache.set(payload.id, payload.content);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the list of notes
|
// Refresh the list of notes
|
||||||
fetchNotes();
|
fetchNotes();
|
||||||
} else {
|
} 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) {
|
} catch (error) {
|
||||||
console.error('Error saving note:', error);
|
console.error('Error saving note:', error);
|
||||||
@ -485,7 +403,7 @@ export default function CarnetPage() {
|
|||||||
|
|
||||||
const handleNoteSave = async (note: Note) => {
|
const handleNoteSave = async (note: Note) => {
|
||||||
try {
|
try {
|
||||||
const endpoint = note.id ? '/api/nextcloud/files' : '/api/nextcloud/files';
|
const endpoint = '/api/storage/files';
|
||||||
const method = note.id ? 'PUT' : 'POST';
|
const method = note.id ? 'PUT' : 'POST';
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
@ -497,22 +415,24 @@ export default function CarnetPage() {
|
|||||||
id: note.id,
|
id: note.id,
|
||||||
title: note.title,
|
title: note.title,
|
||||||
content: note.content,
|
content: note.content,
|
||||||
folder: selectedFolder
|
folder: selectedFolder.toLowerCase()
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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
|
// 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) {
|
if (notesResponse.ok) {
|
||||||
const updatedNotes = await notesResponse.json();
|
const updatedNotes = await notesResponse.json();
|
||||||
setNotes(updatedNotes);
|
setNotes(updatedNotes);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving note:', 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) => {
|
const handleDeleteNote = async (note: Note) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/nextcloud/files`, {
|
const response = await fetch(`/api/storage/files?id=${encodeURIComponent(note.id)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
|
||||||
id: note.id,
|
|
||||||
folder: selectedFolder
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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
|
// 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) {
|
if (notesResponse.ok) {
|
||||||
const updatedNotes = await notesResponse.json();
|
const updatedNotes = await notesResponse.json();
|
||||||
setNotes(updatedNotes);
|
setNotes(updatedNotes);
|
||||||
@ -598,6 +515,7 @@ export default function CarnetPage() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting note:', 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 {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Always use Allemanique.vcf for new contacts
|
// Use S3 path structure: user-{userId}/contacts/{filename}.vcf
|
||||||
const basePath = `/files/cube-${session.user.id}/Private/Contacts`;
|
|
||||||
const vcfFile = 'Allemanique.vcf';
|
const vcfFile = 'Allemanique.vcf';
|
||||||
const path = `${basePath}/${vcfFile}`;
|
const s3Key = `user-${session.user.id}/contacts/${vcfFile}`;
|
||||||
|
|
||||||
let vcfContent = '';
|
let vcfContent = '';
|
||||||
let existingContacts: string[] = [];
|
let existingContacts: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to get existing contacts from the VCF file
|
// 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) {
|
if (response.ok) {
|
||||||
const { content } = await response.json();
|
const { content } = await response.json();
|
||||||
// Split the content into individual vCards
|
// Split the content into individual vCards
|
||||||
@ -678,23 +595,24 @@ export default function CarnetPage() {
|
|||||||
// Join all vCards back together with proper spacing
|
// Join all vCards back together with proper spacing
|
||||||
vcfContent = updatedVcards.join('\n\n');
|
vcfContent = updatedVcards.join('\n\n');
|
||||||
|
|
||||||
// Save the updated VCF file
|
// Save the updated VCF file using S3 storage API
|
||||||
const saveResponse = await fetch('/api/nextcloud/files', {
|
const saveResponse = await fetch('/api/storage/files', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: path,
|
id: s3Key,
|
||||||
title: vcfFile,
|
title: vcfFile,
|
||||||
content: vcfContent,
|
content: vcfContent,
|
||||||
folder: 'Contacts',
|
folder: 'contacts',
|
||||||
mime: 'text/vcard'
|
mime: 'text/vcard'
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!saveResponse.ok) {
|
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
|
// Refresh the contacts list
|
||||||
@ -706,6 +624,7 @@ export default function CarnetPage() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving contact:', error);
|
console.error('Error saving contact:', error);
|
||||||
|
throw error; // Re-throw pour permettre la gestion d'erreur en amont
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -716,18 +635,23 @@ export default function CarnetPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
console.error('No user session available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Determine the correct VCF file path
|
// Use S3 path structure: user-{userId}/contacts/{filename}.vcf
|
||||||
const basePath = `/files/cube-${session?.user?.id}/Private/Contacts`;
|
const vcfFile = contact.group ? `${contact.group}.vcf` : 'Allemanique.vcf';
|
||||||
const vcfFile = contact.group ? `${contact.group}.vcf` : 'contacts.vcf';
|
const s3Key = `user-${session.user.id}/contacts/${vcfFile}`;
|
||||||
const path = `${basePath}/${vcfFile}`;
|
|
||||||
|
|
||||||
// Get existing contacts from the VCF file
|
// 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) {
|
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();
|
const { content } = await response.json();
|
||||||
@ -742,23 +666,24 @@ export default function CarnetPage() {
|
|||||||
// Join the remaining vCards back together
|
// Join the remaining vCards back together
|
||||||
const vcfContent = updatedVcards.map((section: string) => 'BEGIN:VCARD' + section).join('\n');
|
const vcfContent = updatedVcards.map((section: string) => 'BEGIN:VCARD' + section).join('\n');
|
||||||
|
|
||||||
// Save the updated VCF file
|
// Save the updated VCF file using S3 storage API
|
||||||
const saveResponse = await fetch('/api/nextcloud/files', {
|
const saveResponse = await fetch('/api/storage/files', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: path,
|
id: s3Key,
|
||||||
title: vcfFile,
|
title: vcfFile,
|
||||||
content: vcfContent,
|
content: vcfContent,
|
||||||
folder: 'Contacts',
|
folder: 'contacts',
|
||||||
mime: 'text/vcard'
|
mime: 'text/vcard'
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!saveResponse.ok) {
|
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
|
// Clear selected contact and refresh list
|
||||||
@ -766,6 +691,7 @@ export default function CarnetPage() {
|
|||||||
await fetchContacts(selectedFolder);
|
await fetchContacts(selectedFolder);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting contact:', error);
|
console.error('Error deleting contact:', error);
|
||||||
|
throw error; // Re-throw pour permettre la gestion d'erreur en amont
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||||||
import { Image, FileText, Link, List, Plus } from 'lucide-react';
|
import { Image, FileText, Link, List, Plus } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { noteContentCache } from '@/lib/cache-utils';
|
||||||
|
|
||||||
interface Note {
|
interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
@ -32,10 +33,6 @@ export const Editor: React.FC<EditorProps> = ({ note, onSave, currentFolder = 'N
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: session, status } = useSession();
|
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(() => {
|
useEffect(() => {
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
if (status === 'unauthenticated') {
|
if (status === 'unauthenticated') {
|
||||||
@ -49,38 +46,15 @@ export const Editor: React.FC<EditorProps> = ({ note, onSave, currentFolder = 'N
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// First check in-memory cache
|
// Check cache first
|
||||||
const cachedContent = contentCache.current[note.id];
|
const cachedContent = noteContentCache.get<string>(note.id);
|
||||||
if (cachedContent && (Date.now() - cachedContent.timestamp) < CACHE_EXPIRATION) {
|
if (cachedContent) {
|
||||||
console.log(`Using cached content for note ${note.title}`);
|
console.log(`Using cached content for note ${note.title}`);
|
||||||
setContent(cachedContent.content || '');
|
setContent(cachedContent);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
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
|
// If cache miss, fetch from API
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/storage/files/content?path=${encodeURIComponent(note.id)}`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorData = await response.json().catch(() => ({}));
|
||||||
throw new Error(`Failed to fetch note content: ${response.status} ${errorText}`);
|
const errorMessage = errorData.message || errorData.error || `Failed to fetch note content: ${response.status}`;
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setContent(data.content || '');
|
setContent(data.content || '');
|
||||||
|
|
||||||
// Update both caches
|
// Update cache
|
||||||
const newTimestamp = Date.now();
|
noteContentCache.set(note.id, data.content || '');
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching note content:', 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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -128,7 +94,7 @@ export const Editor: React.FC<EditorProps> = ({ note, onSave, currentFolder = 'N
|
|||||||
setTitle('');
|
setTitle('');
|
||||||
setContent('');
|
setContent('');
|
||||||
}
|
}
|
||||||
}, [note, router, CACHE_EXPIRATION]);
|
}, [note, router]);
|
||||||
|
|
||||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setTitle(e.target.value);
|
setTitle(e.target.value);
|
||||||
@ -207,23 +173,7 @@ export const Editor: React.FC<EditorProps> = ({ note, onSave, currentFolder = 'N
|
|||||||
console.log('Note saved successfully:', savedNote);
|
console.log('Note saved successfully:', savedNote);
|
||||||
|
|
||||||
// Update content cache after successful save
|
// Update content cache after successful save
|
||||||
const newTimestamp = Date.now();
|
noteContentCache.set(noteId, content);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
onSave?.({
|
onSave?.({
|
||||||
|
|||||||
237
lib/cache-utils.ts
Normal file
237
lib/cache-utils.ts
Normal 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}`);
|
||||||
|
}
|
||||||
16
lib/s3.ts
16
lib/s3.ts
@ -3,13 +3,21 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|||||||
|
|
||||||
// S3 Configuration
|
// S3 Configuration
|
||||||
export const S3_CONFIG = {
|
export const S3_CONFIG = {
|
||||||
endpoint: 'https://dome-api.slm-lab.net',
|
endpoint: process.env.S3_ENDPOINT || process.env.MINIO_S3_UPLOAD_BUCKET_URL || 'https://dome-api.slm-lab.net',
|
||||||
region: 'us-east-1',
|
region: process.env.S3_REGION || process.env.MINIO_AWS_REGION || 'us-east-1',
|
||||||
bucket: process.env.S3_BUCKET || 'pages',
|
bucket: process.env.S3_BUCKET || 'pages',
|
||||||
accessKey: '4aBT4CMb7JIMMyUtp4Pl',
|
accessKey: process.env.S3_ACCESS_KEY || process.env.MINIO_ACCESS_KEY || '',
|
||||||
secretKey: 'HGn39XhCIlqOjmDVzRK9MED2Fci2rYvDDgbLFElg'
|
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
|
// Initialize S3 client for Minio
|
||||||
export const s3Client = new S3Client({
|
export const s3Client = new S3Client({
|
||||||
region: S3_CONFIG.region,
|
region: S3_CONFIG.region,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user