Files
poshmanager/server/models/assetModel.js
2026-06-25 12:05:53 -05:00

290 lines
9.6 KiB
JavaScript

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;
}