/** * Nuage Module — CRUD * Database operations for folders, files, and shares + R2 integration */ import { query } from '@hykocx/zen/database'; import { uploadFile, deleteFile, proxyFile } from '@hykocx/zen/storage'; import crypto from 'crypto'; // ─── Helpers ──────────────────────────────────────────────────────────────── const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function sanitizeFilename(name) { const sanitized = name .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9._-]/g, '_') .replace(/_+/g, '_') .substring(0, 200); return sanitized || 'fichier'; } function generateNuageFilePath(fileId, filename) { return `nuage/files/${fileId}/${sanitizeFilename(filename)}`; } // ─── Folders ──────────────────────────────────────────────────────────────── export async function getFolderById(id) { if (!id || !UUID_REGEX.test(id)) return null; const result = await query('SELECT * FROM zen_nuage_folders WHERE id = $1', [id]); return result.rows[0] || null; } export async function getFolderContents(folderId = null) { const param = folderId || null; const folderResult = await query( `SELECT f.*, (SELECT COUNT(*) > 0 FROM zen_nuage_shares s WHERE s.target_id = f.id AND s.target_type = 'folder' AND s.is_active = true AND (s.expires_at IS NULL OR s.expires_at > NOW())) AS has_active_share FROM zen_nuage_folders f WHERE f.parent_id ${param ? '= $1' : 'IS NULL'} ORDER BY f.name ASC`, param ? [param] : [] ); const fileResult = await query( `SELECT f.*, (SELECT COUNT(*) > 0 FROM zen_nuage_shares s WHERE s.target_id = f.id AND s.target_type = 'file' AND s.is_active = true AND (s.expires_at IS NULL OR s.expires_at > NOW())) AS has_active_share FROM zen_nuage_files f WHERE f.folder_id ${param ? '= $1' : 'IS NULL'} ORDER BY f.display_name ASC`, param ? [param] : [] ); return { folders: folderResult.rows, files: fileResult.rows, }; } export async function getFolderBreadcrumb(folderId) { if (!folderId) return []; const breadcrumb = []; let currentId = folderId; while (currentId) { const result = await query('SELECT * FROM zen_nuage_folders WHERE id = $1', [currentId]); const folder = result.rows[0]; if (!folder) break; breadcrumb.unshift(folder); currentId = folder.parent_id; } return breadcrumb; } export async function createFolder(name, parentId = null) { let depth = 0; if (parentId) { const parent = await getFolderById(parentId); if (!parent) throw new Error('Dossier parent introuvable'); depth = parent.depth + 1; if (depth > 10) throw new Error('Profondeur maximale de 10 niveaux atteinte'); } const result = await query( `INSERT INTO zen_nuage_folders (name, parent_id, depth) VALUES ($1, $2, $3) RETURNING *`, [name.trim(), parentId, depth] ); return result.rows[0]; } export async function renameFolder(id, name) { const result = await query( `UPDATE zen_nuage_folders SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`, [name.trim(), id] ); return result.rows[0] || null; } export async function moveFolder(id, newParentId) { let depth = 0; if (newParentId) { const parent = await getFolderById(newParentId); if (!parent) throw new Error('Dossier parent introuvable'); depth = parent.depth + 1; if (depth > 10) throw new Error('Profondeur maximale de 10 niveaux atteinte'); } const result = await query( `UPDATE zen_nuage_folders SET parent_id = $1, depth = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 RETURNING *`, [newParentId, depth, id] ); return result.rows[0] || null; } export async function deleteFolder(id) { // Collect all r2_keys recursively before deleting const r2Keys = await collectFolderR2Keys(id); for (const key of r2Keys) { await deleteFile(key).catch(() => {}); } await query('DELETE FROM zen_nuage_folders WHERE id = $1', [id]); return { success: true }; } async function collectFolderR2Keys(folderId) { const keys = []; const filesResult = await query('SELECT r2_key FROM zen_nuage_files WHERE folder_id = $1', [folderId]); for (const row of filesResult.rows) keys.push(row.r2_key); const subFolders = await query('SELECT id FROM zen_nuage_folders WHERE parent_id = $1', [folderId]); for (const sub of subFolders.rows) { const subKeys = await collectFolderR2Keys(sub.id); keys.push(...subKeys); } return keys; } export async function getFolderItemCount(folderId) { const folders = await query('SELECT COUNT(*) FROM zen_nuage_folders WHERE parent_id = $1', [folderId]); const files = await query('SELECT COUNT(*) FROM zen_nuage_files WHERE folder_id = $1', [folderId]); return parseInt(folders.rows[0].count) + parseInt(files.rows[0].count); } // ─── Files ────────────────────────────────────────────────────────────────── export async function getFileById(id) { if (!id || !UUID_REGEX.test(id)) return null; const result = await query('SELECT * FROM zen_nuage_files WHERE id = $1', [id]); return result.rows[0] || null; } export async function uploadNuageFile(folderId, buffer, originalName, mimeType, size) { const fileId = crypto.randomUUID(); const r2Key = generateNuageFilePath(fileId, originalName); const uploadResult = await uploadFile({ key: r2Key, body: buffer, contentType: mimeType }); if (!uploadResult.success) throw new Error(uploadResult.error || 'Échec du téléversement vers R2'); const result = await query( `INSERT INTO zen_nuage_files (id, folder_id, original_name, display_name, mime_type, size, r2_key) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [fileId, folderId || null, originalName, originalName, mimeType, size, r2Key] ); return result.rows[0]; } export async function renameFile(id, displayName) { const result = await query( `UPDATE zen_nuage_files SET display_name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`, [displayName.trim(), id] ); return result.rows[0] || null; } export async function moveFile(id, newFolderId) { const result = await query( `UPDATE zen_nuage_files SET folder_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`, [newFolderId || null, id] ); return result.rows[0] || null; } export async function deleteNuageFile(id) { const file = await getFileById(id); if (!file) throw new Error('Fichier introuvable'); await deleteFile(file.r2_key).catch(() => {}); await query('DELETE FROM zen_nuage_files WHERE id = $1', [id]); return { success: true }; } export async function proxyNuageFile(id, { inline = false } = {}) { const file = await getFileById(id); if (!file) throw new Error('Fichier introuvable'); return proxyFile(file.r2_key, { filename: inline ? undefined : file.display_name }); } export async function getFolderUploadedSize(folderId) { const result = await query( `SELECT COALESCE(SUM(size), 0) AS total FROM zen_nuage_files WHERE folder_id = $1`, [folderId] ); return parseInt(result.rows[0].total); } // ─── Shares ───────────────────────────────────────────────────────────────── export async function getShareByToken(token) { const result = await query('SELECT * FROM zen_nuage_shares WHERE token = $1', [token]); return result.rows[0] || null; } export async function getShareInfoForMetadata(token) { const result = await query( `SELECT s.*, CASE WHEN s.target_type = 'file' THEN (SELECT display_name FROM zen_nuage_files WHERE id = s.target_id) WHEN s.target_type = 'folder' THEN (SELECT name FROM zen_nuage_folders WHERE id = s.target_id) END AS target_name FROM zen_nuage_shares s WHERE s.token = $1`, [token] ); return result.rows[0] || null; } export async function getSharesForTarget(targetId, targetType) { const result = await query( `SELECT s.*, u.name, u.email FROM zen_nuage_shares s LEFT JOIN zen_auth_users u ON u.id = s.user_id WHERE s.target_id = $1 AND s.target_type = $2 ORDER BY s.created_at DESC`, [targetId, targetType] ); return result.rows; } export async function getSharesForUser(userId) { const result = await query( `SELECT s.* FROM zen_nuage_shares s WHERE s.user_id = $1 AND s.is_active = true AND (s.expires_at IS NULL OR s.expires_at > CURRENT_TIMESTAMP) ORDER BY s.created_at DESC`, [userId] ); return result.rows; } export async function getAllActiveShares() { const result = await query( `SELECT s.*, u.name, u.email, CASE WHEN s.target_type = 'file' THEN (SELECT display_name FROM zen_nuage_files WHERE id = s.target_id) WHEN s.target_type = 'folder' THEN (SELECT name FROM zen_nuage_folders WHERE id = s.target_id) END AS target_name FROM zen_nuage_shares s LEFT JOIN zen_auth_users u ON u.id = s.user_id ORDER BY s.created_at DESC` ); return result.rows; } export async function createShare({ targetId, targetType, shareType, userId = null, permission, password = null, expiresAt = null, uploadLimitBytes = null, }) { const token = crypto.randomBytes(32).toString('hex'); let passwordHash = null; if (password) { const salt = crypto.randomBytes(16).toString('hex'); passwordHash = await new Promise((resolve, reject) => { crypto.scrypt(password, salt, 64, (err, key) => { if (err) reject(err); else resolve(`${salt}:${key.toString('hex')}`); }); }); } const result = await query( `INSERT INTO zen_nuage_shares (token, target_type, target_id, share_type, user_id, permission, password_hash, expires_at, upload_limit_bytes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [token, targetType, targetId, shareType, userId, permission, passwordHash, expiresAt, uploadLimitBytes] ); return result.rows[0]; } export async function revokeShare(id) { const result = await query( `UPDATE zen_nuage_shares SET is_active = false WHERE id = $1 RETURNING *`, [id] ); return result.rows[0] || null; } export async function updateShare(id, { permission, expiresAt, uploadLimitBytes, password, clearPassword, userId, shareType, isActive, } = {}) { let passwordHash = undefined; if (clearPassword) { passwordHash = null; } else if (password) { const salt = crypto.randomBytes(16).toString('hex'); passwordHash = await new Promise((resolve, reject) => { crypto.scrypt(password, salt, 64, (err, key) => { if (err) reject(err); else resolve(`${salt}:${key.toString('hex')}`); }); }); } const sets = []; const values = []; let idx = 1; if (permission !== undefined) { sets.push(`permission = $${idx++}`); values.push(permission); } if (expiresAt !== undefined) { sets.push(`expires_at = $${idx++}`); values.push(expiresAt); } if (uploadLimitBytes !== undefined){ sets.push(`upload_limit_bytes = $${idx++}`); values.push(uploadLimitBytes); } if (passwordHash !== undefined) { sets.push(`password_hash = $${idx++}`); values.push(passwordHash); } if (userId !== undefined) { sets.push(`user_id = $${idx++}`); values.push(userId); } if (shareType !== undefined) { sets.push(`share_type = $${idx++}`); values.push(shareType); } if (isActive !== undefined) { sets.push(`is_active = $${idx++}`); values.push(isActive); } if (sets.length === 0) throw new Error('Aucun champ à modifier'); values.push(id); const result = await query( `UPDATE zen_nuage_shares SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`, values ); return result.rows[0] || null; } export function isShareValid(share) { if (!share || !share.is_active) return false; if (share.expires_at && new Date(share.expires_at) < new Date()) return false; return true; } export async function verifySharePassword(share, password) { if (!share.password_hash) return true; const [salt, storedKey] = share.password_hash.split(':'); return new Promise((resolve, reject) => { crypto.scrypt(password, salt, 64, (err, derivedKey) => { if (err) reject(err); else { const storedBuf = Buffer.from(storedKey, 'hex'); resolve( storedBuf.length === derivedKey.length && crypto.timingSafeEqual(storedBuf, derivedKey) ); } }); }); } // ─── User search (for share panel) ────────────────────────────────────────── export async function searchUsers(q, limit = 10) { const like = `%${q}%`; const result = await query( `SELECT id, name, email FROM zen_auth_users WHERE (name ILIKE $1 OR email ILIKE $1) ORDER BY name LIMIT $2`, [like, limit] ); return result.rows; } // ─── Shared content access (for public pages and client section) ───────────── export async function getSharedFolderContents(share, folderId = null) { if (share.target_type === 'file') { const file = await getFileById(share.target_id); return { folders: [], files: file ? [file] : [] }; } // For folder shares: start at the shared root or a sub-folder within it const targetId = folderId || share.target_id; // Verify the requested folder is within the share scope if (folderId && folderId !== share.target_id) { const breadcrumb = await getFolderBreadcrumb(folderId); const inScope = breadcrumb.some(f => f.id === share.target_id); if (!inScope) throw new Error('Dossier hors de la portée du partage'); } return getFolderContents(targetId); } export async function getSharedBreadcrumb(share, folderId = null) { if (!folderId || folderId === share.target_id) return []; const full = await getFolderBreadcrumb(folderId); const rootIndex = full.findIndex(f => f.id === share.target_id); return rootIndex >= 0 ? full.slice(rootIndex + 1) : []; } // ─── Share scope validators ────────────────────────────────────────────────── /** * Checks whether a file belongs to the scope of a share. * For file shares: the file must be the shared file itself. * For folder shares: the file must live inside the shared folder tree. */ export async function isFileInShare(fileId, share) { const file = await getFileById(fileId); if (!file) return false; if (share.target_type === 'file') { return file.id === share.target_id; } // Folder share: file must be in the shared folder or a descendant if (!file.folder_id) return false; if (file.folder_id === share.target_id) return true; const breadcrumb = await getFolderBreadcrumb(file.folder_id); return breadcrumb.some(f => f.id === share.target_id); } /** * Checks whether a folder belongs to the scope of a share. * Only meaningful for folder shares; the folder must be the root or a descendant. */ export async function isFolderInShare(folderId, share) { if (share.target_type !== 'folder') return false; if (folderId === share.target_id) return true; const breadcrumb = await getFolderBreadcrumb(folderId); return breadcrumb.some(f => f.id === share.target_id); } // ─── Password cookie helpers (server-side enforcement) ─────────────────────── export function getPasswordCookieName(token) { return `zen_nuage_pw_${token}`; } export function signPasswordToken(token) { const secret = process.env.ZEN_SESSION_SECRET; if (!secret) throw new Error('ZEN_SESSION_SECRET environment variable is not set'); return crypto.createHmac('sha256', secret).update(token).digest('hex'); }