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 { 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 { 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>, ): Promise { 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 { 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 { 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 { 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 { 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 { 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; } } }