272 lines
9.2 KiB
JavaScript
272 lines
9.2 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import { config } from '../config.js';
|
|
import { db, id, now, visibleClause } from '../db.js';
|
|
import { camelAsset, camelAssetFolder, camelAssetLink } from './mappers.js';
|
|
import { extnameAny, isPathInside, normalizePackagePath, path, sanitizeFileName } from '../utils/pathUtils.js';
|
|
|
|
function inferKind(mimeType = '', fileName = '') {
|
|
const mime = mimeType.toLowerCase();
|
|
const ext = extnameAny(fileName);
|
|
if (mime.startsWith('image/')) return 'image';
|
|
if (mime.includes('zip') || ['.zip', '.intunewin', '.7z', '.rar'].includes(ext)) return 'archive';
|
|
if (['.msi', '.msp', '.exe'].includes(ext)) return 'installer';
|
|
if (['.ps1', '.psm1', '.psd1', '.cmd', '.bat'].includes(ext)) return 'script';
|
|
if (['.json', '.xml', '.yaml', '.yml', '.config', '.ini', '.reg', '.admx', '.adml'].includes(ext)) return 'config';
|
|
if (mime.startsWith('text/') || ['.txt', '.md', '.log'].includes(ext)) return 'text';
|
|
if (mime === 'application/pdf' || ['.pdf', '.doc', '.docx'].includes(ext)) return 'document';
|
|
return 'generic';
|
|
}
|
|
|
|
function lockerBucket(kind) {
|
|
const map = {
|
|
archive: 'packages',
|
|
config: 'configs',
|
|
document: 'documents',
|
|
generic: 'generic',
|
|
image: 'images',
|
|
installer: 'packages',
|
|
script: 'scripts',
|
|
text: 'documents'
|
|
};
|
|
return map[kind] || 'generic';
|
|
}
|
|
|
|
function storagePathFor(assetId, kind, originalName) {
|
|
return path.join(config.fileLockerDir, 'assets', lockerBucket(kind), assetId, sanitizeFileName(originalName));
|
|
}
|
|
|
|
function checksumFile(filePath) {
|
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
}
|
|
|
|
function selectAssetById(assetId, userId) {
|
|
return db.prepare(`
|
|
SELECT a.*, f.name AS folder_name, g.name AS group_name
|
|
FROM assets a
|
|
LEFT JOIN asset_folders f ON f.id = a.folder_id
|
|
LEFT JOIN groups g ON g.id = a.group_id
|
|
WHERE a.id = ? AND ${visibleClause('a')}
|
|
`).get(assetId, userId, userId);
|
|
}
|
|
|
|
export function listAssetFolders(userId) {
|
|
return db.prepare(`
|
|
SELECT f.*, g.name AS group_name
|
|
FROM asset_folders f
|
|
LEFT JOIN groups g ON g.id = f.group_id
|
|
WHERE ${visibleClause('f')}
|
|
ORDER BY f.name
|
|
`).all(userId, userId).map(camelAssetFolder);
|
|
}
|
|
|
|
export function createAssetFolder(payload, userId) {
|
|
const folderId = id('afld');
|
|
db.prepare(`
|
|
INSERT INTO asset_folders (id, parent_id, name, description, visibility, owner_user_id, group_id, created_by, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
folderId,
|
|
payload.parentId || null,
|
|
payload.name,
|
|
payload.description || '',
|
|
payload.visibility,
|
|
userId,
|
|
payload.visibility === 'group' ? payload.groupId || null : null,
|
|
userId,
|
|
now(),
|
|
now()
|
|
);
|
|
return camelAssetFolder(db.prepare('SELECT * FROM asset_folders WHERE id = ?').get(folderId));
|
|
}
|
|
|
|
export function updateAssetFolder(folderId, payload, userId) {
|
|
const existing = db.prepare(`SELECT * FROM asset_folders WHERE id = ? AND ${visibleClause()}`).get(folderId, userId, userId);
|
|
if (!existing) return null;
|
|
db.prepare(`
|
|
UPDATE asset_folders
|
|
SET parent_id = ?, name = ?, description = ?, visibility = ?, group_id = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
payload.parentId ?? existing.parent_id,
|
|
payload.name || existing.name,
|
|
payload.description ?? existing.description ?? '',
|
|
payload.visibility || existing.visibility,
|
|
(payload.visibility || existing.visibility) === 'group' ? payload.groupId ?? existing.group_id : null,
|
|
now(),
|
|
folderId
|
|
);
|
|
return camelAssetFolder(db.prepare('SELECT * FROM asset_folders WHERE id = ?').get(folderId));
|
|
}
|
|
|
|
export function deleteAssetFolder(folderId, userId) {
|
|
db.prepare(`DELETE FROM asset_folders WHERE id = ? AND ${visibleClause()}`).run(folderId, userId, userId);
|
|
}
|
|
|
|
export function listAssets(userId) {
|
|
return db.prepare(`
|
|
SELECT a.*, f.name AS folder_name, g.name AS group_name
|
|
FROM assets a
|
|
LEFT JOIN asset_folders f ON f.id = a.folder_id
|
|
LEFT JOIN groups g ON g.id = a.group_id
|
|
WHERE ${visibleClause('a')}
|
|
ORDER BY a.updated_at DESC, a.name
|
|
`).all(userId, userId).map(camelAsset);
|
|
}
|
|
|
|
export function listExecutableAssets(userId) {
|
|
return db.prepare(`
|
|
SELECT a.*, f.name AS folder_name, g.name AS group_name
|
|
FROM assets a
|
|
LEFT JOIN asset_folders f ON f.id = a.folder_id
|
|
LEFT JOIN groups g ON g.id = a.group_id
|
|
WHERE ${visibleClause('a')}
|
|
ORDER BY a.name
|
|
`).all(userId, userId).map((row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
originalName: row.original_name,
|
|
kind: row.kind,
|
|
mimeType: row.mime_type,
|
|
sizeBytes: row.size_bytes,
|
|
checksum: row.checksum,
|
|
packagePath: row.package_path || row.original_name,
|
|
localPath: row.storage_path,
|
|
token: `{{asset:${row.id}}}`
|
|
}));
|
|
}
|
|
|
|
export function getAsset(assetId, userId) {
|
|
const row = selectAssetById(assetId, userId);
|
|
return row ? camelAsset(row) : null;
|
|
}
|
|
|
|
export function getAssetFile(assetId, userId) {
|
|
const row = selectAssetById(assetId, userId);
|
|
if (!row) return null;
|
|
const resolved = path.resolve(row.storage_path);
|
|
if (!isPathInside(config.fileLockerDir, resolved)) throw new Error('Asset file path is outside the configured FileLocker directory');
|
|
return { asset: camelAsset(row), filePath: resolved };
|
|
}
|
|
|
|
export function createAssetFromUpload(file, payload, userId) {
|
|
if (!file?.path) throw new Error('A multipart file field named "file" is required');
|
|
const assetId = id('ast');
|
|
const originalName = sanitizeFileName(payload.originalName || file.originalname);
|
|
const mimeType = payload.mimeType || file.mimetype || 'application/octet-stream';
|
|
const kind = inferKind(mimeType, originalName);
|
|
const storagePath = storagePathFor(assetId, kind, originalName);
|
|
const checksum = checksumFile(file.path);
|
|
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
|
fs.renameSync(file.path, storagePath);
|
|
|
|
db.prepare(`
|
|
INSERT INTO assets (
|
|
id, folder_id, name, original_name, description, kind, mime_type, size_bytes, checksum, storage_path, package_path,
|
|
visibility, owner_user_id, group_id, created_by, created_at, updated_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
assetId,
|
|
payload.folderId || null,
|
|
payload.name || originalName,
|
|
originalName,
|
|
payload.description || '',
|
|
kind,
|
|
mimeType,
|
|
file.size,
|
|
checksum,
|
|
storagePath,
|
|
normalizePackagePath(payload.packagePath, originalName),
|
|
payload.visibility,
|
|
userId,
|
|
payload.visibility === 'group' ? payload.groupId || null : null,
|
|
userId,
|
|
now(),
|
|
now()
|
|
);
|
|
return getAsset(assetId, userId);
|
|
}
|
|
|
|
export function updateAsset(assetId, payload, userId) {
|
|
const existing = db.prepare(`SELECT * FROM assets WHERE id = ? AND ${visibleClause()}`).get(assetId, userId, userId);
|
|
if (!existing) return null;
|
|
const visibility = payload.visibility || existing.visibility;
|
|
db.prepare(`
|
|
UPDATE assets
|
|
SET folder_id = ?, name = ?, description = ?, package_path = ?, visibility = ?, group_id = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
payload.folderId ?? existing.folder_id,
|
|
payload.name || existing.name,
|
|
payload.description ?? existing.description ?? '',
|
|
normalizePackagePath(payload.packagePath ?? existing.package_path, existing.original_name),
|
|
visibility,
|
|
visibility === 'group' ? payload.groupId ?? existing.group_id : null,
|
|
now(),
|
|
assetId
|
|
);
|
|
return getAsset(assetId, userId);
|
|
}
|
|
|
|
export function deleteAsset(assetId, userId) {
|
|
const row = db.prepare(`SELECT * FROM assets WHERE id = ? AND ${visibleClause()}`).get(assetId, userId, userId);
|
|
if (!row) return false;
|
|
db.prepare('DELETE FROM assets WHERE id = ?').run(assetId);
|
|
try {
|
|
fs.rmSync(row.storage_path, { force: true });
|
|
} catch {
|
|
// Metadata deletion should not fail because an external cleanup already removed the blob.
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function listAssetLinks(assetId, userId) {
|
|
const asset = selectAssetById(assetId, userId);
|
|
if (!asset) return null;
|
|
return db.prepare(`
|
|
SELECT l.*, a.name AS asset_name
|
|
FROM asset_links l
|
|
LEFT JOIN assets a ON a.id = l.asset_id
|
|
WHERE l.asset_id = ?
|
|
ORDER BY l.created_at DESC
|
|
`).all(assetId).map(camelAssetLink);
|
|
}
|
|
|
|
export function createAssetLink(assetId, payload, userId) {
|
|
const asset = selectAssetById(assetId, userId);
|
|
if (!asset) return null;
|
|
const linkId = id('alnk');
|
|
db.prepare(`
|
|
INSERT INTO asset_links (id, asset_id, target_type, target_id, link_role, package_path, created_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
linkId,
|
|
assetId,
|
|
payload.targetType,
|
|
payload.targetId,
|
|
payload.linkRole,
|
|
normalizePackagePath(payload.packagePath || asset.package_path || asset.original_name),
|
|
userId,
|
|
now()
|
|
);
|
|
return camelAssetLink(db.prepare(`
|
|
SELECT l.*, a.name AS asset_name
|
|
FROM asset_links l
|
|
LEFT JOIN assets a ON a.id = l.asset_id
|
|
WHERE l.id = ?
|
|
`).get(linkId));
|
|
}
|
|
|
|
export function deleteAssetLink(linkId, userId) {
|
|
const link = db.prepare(`
|
|
SELECT l.id
|
|
FROM asset_links l
|
|
INNER JOIN assets a ON a.id = l.asset_id
|
|
WHERE l.id = ? AND ${visibleClause('a')}
|
|
`).get(linkId, userId, userId);
|
|
if (!link) return false;
|
|
db.prepare('DELETE FROM asset_links WHERE id = ?').run(linkId);
|
|
return true;
|
|
}
|