2026-07-02 11:38:21 +02:00

405 lines
10 KiB
TypeScript

import AssetsDBApi from '../db/api/assets.ts';
import { createEntityService } from '../factories/service.factory.ts';
import db from '../db/models/index.ts';
import {
assertCreateOptions,
assertDeleteByIdsOptions,
assertIdOptions,
assertUpdateOptions,
} from '../contracts/entity-options.ts';
import ValidationError from './notifications/errors/validation.ts';
import FileService from './file.ts';
import { probeMediaMetadata } from './videoProcessing.ts';
import { logger } from '../utils/logger.ts';
import type {
AssetCreateOptions,
AssetData,
AssetDeleteByIdsOptions,
AssetEmbedUrlResult,
AssetFindByOptions,
AssetListFilter,
AssetMimePatternMap,
AssetMimeValidationResult,
AssetRemoveOptions,
AssetRecord,
AssetUpdateOptions,
ServiceOptions,
} from '../types/index.ts';
const VALID_MIME_PATTERNS: AssetMimePatternMap = {
image: {
prefixes: ['image/'],
description: 'image (jpeg, png, gif, webp, svg, etc.)',
},
video: {
prefixes: ['video/'],
description: 'video (mp4, webm, mov, etc.)',
},
audio: {
prefixes: ['audio/'],
description: 'audio (mp3, wav, ogg, etc.)',
},
embed: {
prefixes: [],
description: 'embed (360/3D iframe)',
skipValidation: true,
},
};
const ALLOWED_EMBED_DOMAINS = [
'matterport.com',
'my.matterport.com',
'kuula.co',
'roundme.com',
'sketchfab.com',
'youtube.com',
'www.youtube.com',
'vimeo.com',
'player.vimeo.com',
'google.com',
'maps.google.com',
'360stories.com',
];
function extractEmbedUrl(embedCode: string | null | undefined): AssetEmbedUrlResult {
if (!embedCode?.trim()) {
throw new ValidationError('Embed code is required');
}
const srcMatch = embedCode.match(/src=["']([^"']+)["']/i);
const urlString = srcMatch?.[1] ?? embedCode.trim();
let url: URL;
try {
url = new URL(urlString);
} catch {
throw new ValidationError('Invalid embed URL format');
}
if (url.protocol !== 'https:') {
throw new ValidationError('Embed URL must use HTTPS');
}
const hostname = url.hostname.replace(/^www\./, '');
const isAllowed = ALLOWED_EMBED_DOMAINS.some(
(domain) => hostname === domain || hostname.endsWith(`.${domain}`),
);
if (!isAllowed) {
throw new ValidationError(
`Untrusted domain: ${hostname}. Allowed: ${ALLOWED_EMBED_DOMAINS.join(', ')}`,
);
}
const provider = hostname.split('.').slice(-2, -1)[0] ?? hostname;
return { url: urlString, provider };
}
function validateAssetMimeType(
assetType: string | null | undefined,
mimeType: string | null | undefined,
): AssetMimeValidationResult {
if (!assetType) {
return { valid: true };
}
const patterns = VALID_MIME_PATTERNS[assetType];
if (!patterns) {
return { valid: true };
}
if (patterns.skipValidation) {
return { valid: true, skipValidation: true };
}
if (!mimeType) {
return { valid: true };
}
const normalizedMime = mimeType.toLowerCase().trim();
const isValid = patterns.prefixes.some((prefix) =>
normalizedMime.startsWith(prefix),
);
if (!isValid) {
return {
valid: false,
error: `Invalid file type for ${assetType}. Expected ${patterns.description}, got "${mimeType}"`,
};
}
return { valid: true };
}
function buildCreateServiceOptions(
options: AssetCreateOptions,
): Pick<AssetCreateOptions, 'currentUser' | 'transaction' | 'runtimeContext'> {
const serviceOptions: Pick<
AssetCreateOptions,
'currentUser' | 'transaction' | 'runtimeContext'
> = {};
if (options.currentUser !== undefined) {
serviceOptions.currentUser = options.currentUser;
}
if (options.transaction !== undefined) {
serviceOptions.transaction = options.transaction;
}
if (options.runtimeContext !== undefined) {
serviceOptions.runtimeContext = options.runtimeContext;
}
return serviceOptions;
}
function buildUpdateServiceOptions(
options: AssetUpdateOptions,
): Pick<AssetUpdateOptions, 'currentUser' | 'transaction' | 'runtimeContext'> {
const serviceOptions: Pick<
AssetUpdateOptions,
'currentUser' | 'transaction' | 'runtimeContext'
> = {};
if (options.currentUser !== undefined) {
serviceOptions.currentUser = options.currentUser;
}
if (options.transaction !== undefined) {
serviceOptions.transaction = options.transaction;
}
if (options.runtimeContext !== undefined) {
serviceOptions.runtimeContext = options.runtimeContext;
}
return serviceOptions;
}
function buildAssetFindByOptions(options: AssetUpdateOptions): AssetFindByOptions {
const findByOptions: AssetFindByOptions = {};
if (options.transaction !== undefined) {
findByOptions.transaction = options.transaction;
}
if (options.runtimeContext !== undefined) {
findByOptions.runtimeContext = options.runtimeContext;
}
return findByOptions;
}
function getCurrentUserId(
options: ServiceOptions,
): string | null {
return options.currentUser?.id ?? null;
}
async function softDeleteAssetVariants(
assetIds: readonly string[],
options: ServiceOptions & Required<Pick<ServiceOptions, 'transaction'>>,
): Promise<void> {
if (assetIds.length === 0) return;
const now = new Date();
await db.asset_variants.update(
{
deletedAt: now,
updatedAt: now,
updatedById: getCurrentUserId(options),
},
{
where: {
assetId: assetIds,
deletedAt: null,
},
transaction: options.transaction,
},
);
}
const BaseService = createEntityService<
AssetRecord,
AssetData,
AssetData,
AssetListFilter
>(AssetsDBApi, {
entityName: 'assets',
});
export default class AssetsService extends BaseService {
static async enrichStoredMediaMetadata(data: AssetData): Promise<AssetData> {
const assetType = data.asset_type;
const storageKey = data.storage_key;
if (!storageKey) return data;
if (assetType !== 'video' && assetType !== 'audio') {
return data;
}
let tempFile = null;
try {
tempFile = await FileService.downloadToTempFile(storageKey);
const metadata = await probeMediaMetadata(tempFile.filePath);
if (!metadata) {
return data;
}
return {
...data,
duration_sec: metadata.durationSec ?? data.duration_sec ?? null,
width_px: metadata.widthPx ?? data.width_px ?? null,
height_px: metadata.heightPx ?? data.height_px ?? null,
frame_rate: metadata.frameRate ?? data.frame_rate ?? null,
};
} catch (error) {
logger.warn(
{ err: error, storageKey, assetType },
'Failed to probe stored media metadata',
);
return data;
} finally {
if (tempFile) {
await tempFile.cleanup().catch((cleanupError: unknown) => {
logger.warn(
{ err: cleanupError, storageKey },
'Failed to cleanup media probe temp file',
);
});
}
}
}
static override async create(
options: AssetCreateOptions,
): Promise<AssetRecord> {
assertCreateOptions(options, 'Service');
let { data } = options;
const assetType = data.asset_type;
const mimeType = data.mime_type;
const validation = validateAssetMimeType(assetType, mimeType);
if (!validation.valid) {
throw new ValidationError(validation.error);
}
if (assetType === 'embed') {
const { url, provider } = extractEmbedUrl(data.embed_code);
data = {
...data,
cdn_url: url,
embed_provider: provider,
};
}
data = await AssetsService.enrichStoredMediaMetadata(data);
const serviceOptions = buildCreateServiceOptions(options);
return super.create({
data,
...serviceOptions,
});
}
static override async update(
options: AssetUpdateOptions,
): Promise<AssetRecord> {
assertUpdateOptions(options, 'Service');
let { data } = options;
const { id } = options;
const existingAsset = await AssetsDBApi.findBy(
{ id },
buildAssetFindByOptions(options),
);
if (data.asset_type || data.mime_type) {
const assetType = data.asset_type || existingAsset?.asset_type;
const mimeType = data.mime_type || existingAsset?.mime_type;
if (assetType && mimeType) {
const validation = validateAssetMimeType(assetType, mimeType);
if (!validation.valid) {
throw new ValidationError(validation.error);
}
}
}
if (data.embed_code) {
const { url, provider } = extractEmbedUrl(data.embed_code);
data = {
...data,
cdn_url: url,
embed_provider: provider,
};
}
const metadataInput = existingAsset ? { ...existingAsset, ...data } : data;
data = await AssetsService.enrichStoredMediaMetadata(metadataInput);
const serviceOptions = buildUpdateServiceOptions(options);
return super.update({
id,
data,
...serviceOptions,
});
}
static override async deleteByIds(
options: AssetDeleteByIdsOptions,
): Promise<AssetRecord[]> {
assertDeleteByIdsOptions(options, 'Service');
const { transaction: externalTransaction } = options;
const transaction =
externalTransaction || (await db.sequelize.transaction());
const ownsTransaction = !externalTransaction;
try {
const result = await super.deleteByIds({
...options,
transaction,
});
await softDeleteAssetVariants(
result.map((asset) => asset.id),
{ ...options, transaction },
);
if (ownsTransaction) await transaction.commit();
return result;
} catch (error) {
if (ownsTransaction) await transaction.rollback();
throw error;
}
}
static override async remove(
options: AssetRemoveOptions,
): Promise<AssetRecord> {
assertIdOptions(options, 'Service', 'remove');
const { transaction: externalTransaction } = options;
const transaction =
externalTransaction || (await db.sequelize.transaction());
const ownsTransaction = !externalTransaction;
try {
const result = await super.remove({
...options,
transaction,
});
await softDeleteAssetVariants([result.id], { ...options, transaction });
if (ownsTransaction) await transaction.commit();
return result;
} catch (error) {
if (ownsTransaction) await transaction.rollback();
throw error;
}
}
}