462 lines
16 KiB
JavaScript
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');
|
|
}
|