Files
core/src/modules/nuage/crud.js
T
2026-04-12 12:50:14 -04:00

462 lines
16 KiB
JavaScript

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