Initial
This commit is contained in:
289
server/models/assetModel.js
Normal file
289
server/models/assetModel.js
Normal file
@@ -0,0 +1,289 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
import { db, id, now, visibleClause } from '../db.js';
|
||||
import { camelAsset, camelAssetFolder, camelAssetLink } from './mappers.js';
|
||||
|
||||
function sanitizeFileName(name) {
|
||||
return String(name || 'asset.bin')
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 180) || 'asset.bin';
|
||||
}
|
||||
|
||||
function normalizePackagePath(value, fallbackName = '') {
|
||||
const clean = String(value || fallbackName || '')
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter((part) => part && part !== '.' && part !== '..')
|
||||
.join('/');
|
||||
return clean.slice(0, 500);
|
||||
}
|
||||
|
||||
function inferKind(mimeType = '', fileName = '') {
|
||||
const mime = mimeType.toLowerCase();
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
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);
|
||||
const fileLockerRoot = path.resolve(config.fileLockerDir);
|
||||
if (!resolved.startsWith(fileLockerRoot)) 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;
|
||||
}
|
||||
Reference in New Issue
Block a user