import { lstat, realpath, rm } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { implement, ORPCError } from "@orpc/server"; import { UnsupportedMediaOperationError } from "@rakazo/adapter-kit"; import type { MediaServices } from "@rakazo/adapters"; import type { Actor, BrandKit, MediaCapabilities, MediaSourceKind } from "@rakazo/contracts"; import { appContract, MediaInferenceOperationSchema } from "@rakazo/contracts"; import type { PrismaClient } from "@rakazo/db"; import { InvalidMediaInputError, InvalidProjectWorkspacePathError, IsolationError, lockMediaKey, MediaStateConflictError, mapMediaSource, resolveProjectWorkspaceDirectory, } from "@rakazo/db"; import { summarizeProfile } from "@rakazo/media"; import { PathOutsideRootError, resolveInside } from "@rakazo/media/node"; import { beginMediaUpload, completeMediaUpload, UploadChunkError } from "./media-uploads.js"; export interface MediaRouterDeps { prisma: PrismaClient; /** Null when Media Studio is not configured: everything but capabilities refuses. */ services?: MediaServices | null; /** DATA_DIR; a folder source may neither sit inside it nor contain it. */ dataDir: string; /** Application install directory a folder source may not point into. */ appRoot?: string; } export const MEDIA_NOT_CONFIGURED = "Media Studio is not configured"; const DEFAULT_APP_ROOT = fileURLToPath(new URL("../../../", import.meta.url)); /** Translate domain errors into RPC errors; everything else passes through. */ export function mediaRpcError(error: unknown): unknown { if (error instanceof ORPCError) return error; if (error instanceof IsolationError) return new ORPCError("NOT_FOUND"); if ( error instanceof InvalidMediaInputError || error instanceof InvalidProjectWorkspacePathError ) { return new ORPCError("BAD_REQUEST", { message: error.message }); } if (error instanceof PathOutsideRootError) { return new ORPCError("BAD_REQUEST", { message: "That path is not allowed." }); } if (error instanceof MediaStateConflictError) { return new ORPCError("CONFLICT", { message: error.message }); } if (error instanceof UploadChunkError) { return new ORPCError(error.status === 409 ? "CONFLICT" : "BAD_REQUEST", { message: error.message, }); } if (error instanceof UnsupportedMediaOperationError) { return new ORPCError("PRECONDITION_FAILED", { message: error.message }); } return error; } export function unconfiguredCapabilities(): MediaCapabilities { const unavailable = { available: false, experimental: false, model: null, license: null, commercialUse: null, }; return { configured: false, operations: Object.fromEntries( MediaInferenceOperationSchema.options.map((operation) => [operation, unavailable]), ) as MediaCapabilities["operations"], accelerator: "none", hardwareEncoders: [], }; } function overlaps(a: string, b: string): boolean { const inside = (root: string, candidate: string) => { const relative = path.relative(root, candidate); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); }; return inside(a, b) || inside(b, a); } async function realOrResolved(target: string): Promise { return realpath(target).catch(() => path.resolve(target)); } /** * A host folder for a folder source: absolute, an existing directory, not a * symlink, and apart from DATA_DIR and the application itself. Returns the real path. */ export async function validateMediaFolder( input: string, forbidden: { dataDir: string; appRoot: string }, ): Promise { const trimmed = input.trim(); if (!path.isAbsolute(trimmed)) { throw new InvalidMediaInputError("Use an absolute folder path."); } const info = await lstat(trimmed).catch(() => null); if (!info) throw new InvalidMediaInputError("That folder does not exist or is not readable."); if (info.isSymbolicLink()) throw new InvalidMediaInputError("Choose the folder, not a link to it."); const resolved = await resolveProjectWorkspaceDirectory(trimmed); if (path.parse(resolved).root === resolved) { throw new InvalidMediaInputError("Choose a specific folder."); } const dataDir = await realOrResolved(forbidden.dataDir); const appRoot = await realOrResolved(forbidden.appRoot); const relativeToApp = path.relative(appRoot, resolved); const insideApp = relativeToApp === "" || (!relativeToApp.startsWith("..") && !path.isAbsolute(relativeToApp)); if (overlaps(dataDir, resolved) || insideApp) { throw new InvalidMediaInputError("That folder belongs to evOo itself."); } return resolved; } /** Artifacts referenced from media records must belong to the caller's space. */ export async function assertSpaceArtifacts( prisma: PrismaClient, spaceId: string, artifactIds: string[], ): Promise { const ids = [...new Set(artifactIds)]; if (ids.length === 0) return; const owned = await prisma.artifact.count({ where: { spaceId, id: { in: ids } } }); if (owned !== ids.length) throw new IsolationError(); } function brandKitArtifactIds(kit: BrandKit): string[] { return [ ...kit.fonts.map((font) => font.artifactId), ...kit.logoArtifactIds, ...(kit.lutArtifactId ? [kit.lutArtifactId] : []), ...kit.musicBeds.map((bed) => bed.artifactId), ]; } async function removeMediaPath(mediaRoot: string, relative: string | null): Promise { if (!relative) return; await rm(await resolveInside(mediaRoot, relative), { recursive: true, force: true }); } export function createMediaRouter(deps: MediaRouterDeps) { const os = implement(appContract.media).$context<{ actor: Actor | null; signal?: AbortSignal }>(); const authed = os.use(async ({ context, next }) => { if (!context.actor) throw new ORPCError("UNAUTHORIZED"); try { return await next({ context: { ...context, actor: context.actor } }); } catch (error) { throw mediaRpcError(error); } }); const media = authed.use(async ({ next }) => { if (!deps.services) { throw new ORPCError("PRECONDITION_FAILED", { message: MEDIA_NOT_CONFIGURED }); } return next({ context: { services: deps.services } }); }); const forbiddenRoots = { dataDir: deps.dataDir, appRoot: deps.appRoot ?? DEFAULT_APP_ROOT }; async function knownProfiles(services: MediaServices, profileIds: string[] | undefined) { if (!profileIds) return; const { profiles } = await services.deps.profiles(); const unknown = profileIds.filter((id) => !profiles.has(id)); if (unknown.length > 0) { throw new InvalidMediaInputError(`Unknown profile: ${unknown.join(", ")}`); } } return os.router({ capabilities: authed.capabilities.handler(async ({ context }) => { const services = deps.services; if (!services) return unconfiguredCapabilities(); const hardwareEncoders = await services.deps.renderer.hardwareEncoders().catch(() => []); const inference = services.deps.inference; if (!inference) return { ...unconfiguredCapabilities(), hardwareEncoders }; const capabilities = await inference .capabilities(context.signal) .catch(() => ({ ...unconfiguredCapabilities(), configured: true })); return { ...capabilities, configured: true, hardwareEncoders }; }), sources: { list: media.sources.list.handler(({ context }) => context.services.deps.repos.listSources(context.actor.spaceId), ), createFolder: media.sources.createFolder.handler(async ({ context, input }) => { // A folder lives on the host, and the host is the deployment owner's machine. if (!context.actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN"); const folder = await validateMediaFolder(input.path, forbiddenRoots); const { repos } = context.services.deps; const source = await repos.createSource(context.actor.spaceId, { kind: "folder", label: input.label, ownerUserId: context.actor.userId, config: { path: folder }, }); await context.services.ingest.requestSync(context.actor, source.id); return source; }), registerClient: media.sources.registerClient.handler(async ({ context, input }) => { const { spaceId, userId } = context.actor; const kind: MediaSourceKind = input.kind; // One source per device: re-registering returns the existing one. return deps.prisma.$transaction(async (tx) => { await lockMediaKey(tx, `source:${spaceId}:${kind}:${input.deviceId}`); const existing = await tx.mediaSource.findFirst({ where: { spaceId, kind, config: { path: ["deviceId"], equals: input.deviceId } }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], include: { _count: { select: { assets: true } } }, }); if (existing) return mapMediaSource(existing, existing._count.assets); const created = await tx.mediaSource.create({ data: { spaceId, kind, label: input.label, ownerUserId: userId, config: { deviceId: input.deviceId }, }, }); return mapMediaSource(created, 0); }); }), pushItems: media.sources.pushItems.handler(async ({ context, input }) => { await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId); return context.services.ingest.pushItems(context.actor, input); }), sync: media.sources.sync.handler(async ({ context, input }) => { await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId); await context.services.ingest.requestSync(context.actor, input.sourceId); return { ok: true as const }; }), remove: media.sources.remove.handler(async ({ context, input }) => { await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId); await context.services.ingest.removeSource(context.actor, input.sourceId); return { ok: true as const }; }), }, assets: { search: media.assets.search.handler(({ context, input }) => context.services.deps.repos.searchAssets(context.actor.spaceId, input), ), get: media.assets.get.handler(({ context, input }) => context.services.deps.repos.getAsset(context.actor.spaceId, input.assetId), ), setPaidOnly: media.assets.setPaidOnly.handler(async ({ context, input }) => { await context.services.deps.repos.setSegmentsPaidOnly( context.actor.spaceId, input.segmentIds, input.paidOnly, ); return { ok: true as const }; }), requestUpload: media.assets.requestUpload.handler(async ({ context, input }) => ({ awaiting: await context.services.deps.repos.requestUpload( context.actor.spaceId, input.assetIds, ), })), }, uploads: { begin: media.uploads.begin.handler(({ context, input }) => beginMediaUpload(context.services, context.actor, input), ), status: media.uploads.status.handler(({ context, input }) => context.services.deps.repos.uploadStatus(context.actor.spaceId, input.uploadId), ), complete: media.uploads.complete.handler(({ context, input }) => completeMediaUpload(context.services, context.actor, input.uploadId), ), }, collections: { list: media.collections.list.handler(({ context, input }) => context.services.deps.repos.listCollections(context.actor.spaceId, input), ), get: media.collections.get.handler(({ context, input }) => context.services.deps.repos.getCollection(context.actor.spaceId, input.collectionId), ), rename: media.collections.rename.handler(({ context, input }) => context.services.deps.repos.renameCollection( context.actor.spaceId, input.collectionId, input.title, ), ), merge: media.collections.merge.handler(({ context, input }) => context.services.deps.repos.mergeCollections(context.actor.spaceId, input.collectionIds), ), split: media.collections.split.handler(({ context, input }) => context.services.deps.repos.splitCollection( context.actor.spaceId, input.collectionId, input.atAssetId, ), ), createManual: media.collections.createManual.handler(({ context, input }) => context.services.deps.repos.createManualCollection( context.actor.spaceId, input.title, input.assetIds, ), ), recluster: media.collections.recluster.handler(async ({ context }) => { await context.services.ingest.recluster(context.actor); return { ok: true as const }; }), }, people: { list: media.people.list.handler(({ context }) => context.services.deps.repos.listPeople(context.actor.spaceId), ), update: media.people.update.handler(({ context, input }) => context.services.deps.repos.updatePerson(context.actor.spaceId, input.personId, { displayName: input.displayName, isSelf: input.isSelf, confirmed: input.confirmed, anonymizeByDefault: input.anonymizeByDefault, }), ), merge: media.people.merge.handler(({ context, input }) => context.services.deps.repos.mergePeople(context.actor.spaceId, input.personIds), ), assignTrack: media.people.assignTrack.handler(({ context, input }) => context.services.deps.repos.assignTrack( context.actor.spaceId, input.trackId, input.personId, ), ), // The only consent write path: the signed-in member is the recorder. recordConsent: media.people.recordConsent.handler(async ({ context, input }) => { await assertSpaceArtifacts( deps.prisma, context.actor.spaceId, input.consentArtifactId ? [input.consentArtifactId] : [], ); return context.services.deps.repos.recordConsent( { spaceId: context.actor.spaceId, recordedByUserId: context.actor.userId }, input, ); }), remove: media.people.remove.handler(async ({ context, input }) => { await context.services.deps.repos.getPerson(context.actor.spaceId, input.personId); await context.services.privacy.removePerson(context.actor, input.personId); return { ok: true as const }; }), }, marks: { list: media.marks.list.handler(({ context, input }) => context.services.deps.repos.listMarks(context.actor.spaceId, { ...(input.personId ? { personId: input.personId } : {}), ...(input.assetId ? { assetId: input.assetId } : {}), }), ), create: media.marks.create.handler(async ({ context, input }) => { const { repos } = context.services.deps; await repos.getAssetRecord(context.actor.spaceId, input.assetId); if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId); return context.services.privacy.createMark(context.actor, input); }), update: media.marks.update.handler(async ({ context, input }) => { const { repos } = context.services.deps; await repos.getMark(context.actor.spaceId, input.markId); if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId); return context.services.privacy.updateMark(context.actor, input); }), remove: media.marks.remove.handler(async ({ context, input }) => { const { repos, mediaRoot } = context.services.deps; const { maskDir } = await repos.removeMark(context.actor.spaceId, input.markId); await removeMediaPath(mediaRoot, maskDir); return { ok: true as const }; }), }, brandKits: { list: media.brandKits.list.handler(({ context }) => context.services.deps.repos.listBrandKits(context.actor.spaceId), ), upsert: media.brandKits.upsert.handler(async ({ context, input }) => { await assertSpaceArtifacts( deps.prisma, context.actor.spaceId, brandKitArtifactIds(input.kit), ); return context.services.deps.repos.upsertBrandKit(context.actor.spaceId, input); }), remove: media.brandKits.remove.handler(async ({ context, input }) => { await context.services.deps.repos.removeBrandKit(context.actor.spaceId, input.id); return { ok: true as const }; }), templates: media.brandKits.templates.handler(({ context }) => context.services.deps.motion.templates(), ), introVariants: media.brandKits.introVariants.handler(async ({ context, input }) => { await context.services.deps.repos.getBrandKit(context.actor.spaceId, input.id); return context.services.marketing.introVariants(context.actor, { brandKitId: input.id, count: input.count, }); }), }, profiles: { list: media.profiles.list.handler(async ({ context }) => { const { profiles } = await context.services.deps.profiles(); const now = context.services.deps.now(); return [...profiles.values()].map(({ profile, origin }) => summarizeProfile(profile, origin, now), ); }), get: media.profiles.get.handler(async ({ context, input }) => { const { profiles } = await context.services.deps.profiles(); const entry = profiles.get(input.profileId); if (!entry) throw new ORPCError("NOT_FOUND"); return entry.profile; }), }, projects: { list: media.projects.list.handler(({ context, input }) => context.services.deps.repos.listProjects(context.actor.spaceId, input), ), get: media.projects.get.handler(({ context, input }) => context.services.deps.repos.getProject(context.actor.spaceId, input.projectId), ), create: media.projects.create.handler(async ({ context, input }) => { await knownProfiles(context.services, input.profileIds); return context.services.deps.repos.createProject( context.actor.spaceId, context.actor.userId, input, ); }), update: media.projects.update.handler(async ({ context, input }) => { await knownProfiles(context.services, input.profileIds); const { projectId, ...patch } = input; return context.services.deps.repos.updateProject(context.actor.spaceId, projectId, patch); }), remove: media.projects.remove.handler(async ({ context, input }) => { const { repos, mediaRoot } = context.services.deps; const { spaceId } = context.actor; await repos.getProjectRecord(spaceId, input.projectId); const renders = await repos.listRenders(spaceId, input.projectId); await repos.removeProject(spaceId, input.projectId); for (const render of renders) await removeMediaPath(mediaRoot, `${spaceId}/renders/${render.id}`); return { ok: true as const }; }), autoPlan: media.projects.autoPlan.handler(async ({ context, input }) => { await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId); await knownProfiles(context.services, input.profileIds); return context.services.marketing.autoPlan(context.actor, input); }), }, plans: { submit: media.plans.submit.handler(async ({ context, input }) => { await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId); return context.services.marketing.submitPlan(context.actor, { projectId: input.projectId, edl: input.edl, rationale: input.rationale, createdBy: "user", }); }), }, renders: { start: media.renders.start.handler(async ({ context, input }) => { await context.services.deps.repos.getPlan(context.actor.spaceId, input.planId); return context.services.render.start(context.actor, input); }), get: media.renders.get.handler(({ context, input }) => context.services.deps.repos.getRender(context.actor.spaceId, input.renderId), ), cancel: media.renders.cancel.handler(async ({ context, input }) => { await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId); return context.services.render.cancel(context.actor, input.renderId); }), thumbnails: media.renders.thumbnails.handler(async ({ context, input }) => { await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId); return context.services.marketing.thumbnails(context.actor, input); }), }, marketing: { copy: media.marketing.copy.handler(async ({ context, input }) => { await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId); return context.services.marketing.copy(context.actor, input); }), }, publications: { list: media.publications.list.handler(({ context, input }) => context.services.deps.repos.listPublications(context.actor.spaceId, input), ), create: media.publications.create.handler(async ({ context, input }) => { await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId); return context.services.marketing.createPublication(context.actor, input); }), bundle: media.publications.bundle.handler(async ({ context, input }) => { await context.services.deps.repos.getPublication( context.actor.spaceId, input.publicationId, ); return context.services.marketing.bundle(context.actor, input.publicationId); }), markPublished: media.publications.markPublished.handler(({ context, input }) => context.services.deps.repos.markPublicationPublished( context.actor.spaceId, input.publicationId, input.externalUrl, context.services.deps.now(), ), ), cancel: media.publications.cancel.handler(async ({ context, input }) => { await context.services.deps.repos.getPublication( context.actor.spaceId, input.publicationId, ); return context.services.marketing.cancelPublication(context.actor, input.publicationId); }), }, metrics: { mappings: media.metrics.mappings.handler(({ context }) => context.services.deps.repos.listMetricMappings(context.actor.spaceId), ), saveMapping: media.metrics.saveMapping.handler(({ context, input }) => context.services.deps.repos.saveMetricMapping(context.actor.spaceId, input), ), importCsv: media.metrics.importCsv.handler(async ({ context, input }) => { await context.services.deps.repos.getMetricMapping(context.actor.spaceId, input.mappingId); return context.services.marketing.importMetricsCsv(context.actor, input); }), record: media.metrics.record.handler(({ context, input }) => context.services.deps.repos.recordMetric(context.actor.spaceId, { ...input, source: "manual", }), ), funnel: media.metrics.funnel.handler(({ context, input }) => context.services.deps.repos.funnel(context.actor.spaceId, input.projectId), ), }, }); }