mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
502 lines
14 KiB
TypeScript
502 lines
14 KiB
TypeScript
import { existsSync, constants as fsConstants, realpathSync } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { app } from "electron";
|
|
import { RECORDINGS_DIR, USER_DATA_PATH } from "../../appPaths";
|
|
import { isSupportedLocalMediaPath } from "../../mediaTypes";
|
|
import {
|
|
LEGACY_PROJECT_FILE_EXTENSIONS,
|
|
MAX_RECENT_PROJECTS,
|
|
PROJECT_FILE_EXTENSION,
|
|
PROJECT_THUMBNAIL_SUFFIX,
|
|
PROJECTS_DIRECTORY_NAME,
|
|
RECENT_PROJECTS_FILE,
|
|
RECORDINGS_SETTINGS_FILE,
|
|
} from "../constants";
|
|
import {
|
|
approvedLocalReadPaths,
|
|
currentProjectPath,
|
|
setCurrentProjectPath,
|
|
setCurrentRecordingSession,
|
|
setCurrentVideoPath,
|
|
setCustomRecordingsDir,
|
|
setRecordingsDirLoaded,
|
|
} from "../state";
|
|
import type { ProjectLibraryEntry, RecordingSessionData } from "../types";
|
|
import {
|
|
getRecordingsDir,
|
|
normalizePath,
|
|
normalizeVideoSourcePath,
|
|
parseJsonWithByteOrderMark,
|
|
} from "../utils";
|
|
|
|
export { normalizePath, normalizeVideoSourcePath };
|
|
|
|
export function getAssetRootPath() {
|
|
if (app.isPackaged) {
|
|
return path.join(process.resourcesPath, "assets");
|
|
}
|
|
|
|
return path.join(app.getAppPath(), "public");
|
|
}
|
|
|
|
export function isPathInsideDirectory(candidatePath: string, directoryPath: string) {
|
|
const normalizedCandidatePath = normalizePath(candidatePath);
|
|
const normalizedDirectoryPath = normalizePath(directoryPath);
|
|
return (
|
|
normalizedCandidatePath === normalizedDirectoryPath ||
|
|
normalizedCandidatePath.startsWith(`${normalizedDirectoryPath}${path.sep}`)
|
|
);
|
|
}
|
|
|
|
export function isAllowedLocalReadPath(candidatePath: string) {
|
|
const allowedPrefixes = [
|
|
RECORDINGS_DIR,
|
|
USER_DATA_PATH,
|
|
getAssetRootPath(),
|
|
app.getPath("temp"),
|
|
];
|
|
const normalizedCandidatePath = normalizePath(candidatePath);
|
|
|
|
// Canonicalize so a symlink placed under an allowed prefix can't smuggle in a
|
|
// target that lives outside it. realpathSync throws when the path doesn't
|
|
// exist yet (e.g. a pending export approved before the file is written) — in
|
|
// that case fall back to the lexical path, which can only succeed via the
|
|
// approvedLocalReadPaths check below since no symlink target exists yet.
|
|
let canonicalCandidatePath = normalizedCandidatePath;
|
|
try {
|
|
canonicalCandidatePath = normalizePath(realpathSync(normalizedCandidatePath));
|
|
} catch {
|
|
// File may not exist yet; keep the lexical path.
|
|
}
|
|
|
|
// Security: only allow paths under app-managed directories or paths the user
|
|
// has explicitly opted into (recording session sources, files chosen via
|
|
// dialog, app-produced exports). The lexical path must satisfy the policy
|
|
// AND the canonical (real) path must satisfy it too, so a symlink under an
|
|
// allowed prefix that points outside the allowlist is rejected. Previously
|
|
// this returned true for any existing file, which made the allowlist a no-op
|
|
// for read-local-file and the local media URL handler.
|
|
const lexicalAllowed =
|
|
allowedPrefixes.some((prefix) => isPathInsideDirectory(normalizedCandidatePath, prefix)) ||
|
|
approvedLocalReadPaths.has(normalizedCandidatePath);
|
|
if (!lexicalAllowed) {
|
|
return false;
|
|
}
|
|
|
|
if (canonicalCandidatePath === normalizedCandidatePath) {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
allowedPrefixes.some((prefix) => isPathInsideDirectory(canonicalCandidatePath, prefix)) ||
|
|
approvedLocalReadPaths.has(canonicalCandidatePath)
|
|
);
|
|
}
|
|
|
|
// Keep loopback media-server access restricted to allowlisted or explicitly
|
|
// approved files. Direct renderer-side read-local-file calls can be more
|
|
// permissive, but URL-based serving must stay scoped so arbitrary paths do not
|
|
// become fetchable inside the app.
|
|
export async function isAllowedLocalMediaPath(candidatePath: string) {
|
|
const normalizedCandidatePath = normalizePath(candidatePath);
|
|
return isAllowedLocalReadPath(normalizedCandidatePath);
|
|
}
|
|
|
|
async function collectApprovedLocalReadPaths(filePath?: string | null): Promise<string[]> {
|
|
const normalizedPath = normalizeVideoSourcePath(filePath);
|
|
if (!normalizedPath) {
|
|
return [];
|
|
}
|
|
|
|
const approvedPaths = [normalizePath(normalizedPath)];
|
|
|
|
try {
|
|
const realPath = await fs.realpath(approvedPaths[0]);
|
|
const normalizedRealPath = normalizePath(realPath);
|
|
if (!approvedPaths.includes(normalizedRealPath)) {
|
|
approvedPaths.push(normalizedRealPath);
|
|
}
|
|
} catch {
|
|
// Ignore missing files; the eventual read will surface the real error.
|
|
}
|
|
|
|
return approvedPaths;
|
|
}
|
|
|
|
export async function rememberApprovedLocalReadPath(filePath?: string | null) {
|
|
const normalizedPath = normalizeVideoSourcePath(filePath);
|
|
if (!normalizedPath) {
|
|
return;
|
|
}
|
|
|
|
const approvedPaths = await collectApprovedLocalReadPaths(normalizedPath);
|
|
for (const approvedPath of approvedPaths) {
|
|
approvedLocalReadPaths.add(approvedPath);
|
|
}
|
|
}
|
|
|
|
export async function resolveApprovedLocalMediaPath(candidatePath: string): Promise<string | null> {
|
|
const normalizedCandidatePath = normalizePath(candidatePath);
|
|
const realPath = await fs.realpath(normalizedCandidatePath).catch(() => null);
|
|
|
|
if (!realPath) {
|
|
return null;
|
|
}
|
|
|
|
const stat = await fs.stat(realPath).catch(() => null);
|
|
if (!stat?.isFile() || !isSupportedLocalMediaPath(realPath)) {
|
|
return null;
|
|
}
|
|
|
|
if (!(await isAllowedLocalMediaPath(realPath))) {
|
|
return null;
|
|
}
|
|
|
|
await rememberApprovedLocalReadPath(candidatePath);
|
|
return realPath;
|
|
}
|
|
|
|
export async function replaceApprovedSessionLocalReadPaths(
|
|
filePaths: Array<string | null | undefined>,
|
|
) {
|
|
const nextApprovedPaths = new Set<string>();
|
|
const approvedPathLists = await Promise.all(
|
|
filePaths.map((filePath) => collectApprovedLocalReadPaths(filePath)),
|
|
);
|
|
|
|
for (const approvedPathList of approvedPathLists) {
|
|
for (const approvedPath of approvedPathList) {
|
|
nextApprovedPaths.add(approvedPath);
|
|
}
|
|
}
|
|
|
|
approvedLocalReadPaths.clear();
|
|
for (const approvedPath of nextApprovedPaths) {
|
|
approvedLocalReadPaths.add(approvedPath);
|
|
}
|
|
}
|
|
|
|
export async function resolveProjectMediaSources(
|
|
project: unknown,
|
|
): Promise<
|
|
| { success: true; videoPath: string; webcamPath: string | null }
|
|
| { success: false; message: string }
|
|
> {
|
|
if (!project || typeof project !== "object") {
|
|
return { success: false, message: "Invalid project file format" };
|
|
}
|
|
|
|
const rawVideoPath = (project as { videoPath?: unknown }).videoPath;
|
|
if (typeof rawVideoPath !== "string") {
|
|
return { success: false, message: "Project file is missing a video path" };
|
|
}
|
|
|
|
const normalizedVideoPath = normalizeVideoSourcePath(rawVideoPath);
|
|
if (!normalizedVideoPath) {
|
|
return { success: false, message: "Project file is missing a valid video path" };
|
|
}
|
|
|
|
try {
|
|
await fs.access(normalizedVideoPath, fsConstants.F_OK);
|
|
} catch {
|
|
return {
|
|
success: false,
|
|
message: `Project video file not found: ${normalizedVideoPath}`,
|
|
};
|
|
}
|
|
|
|
const rawWebcamPath =
|
|
typeof (project as { editor?: { webcam?: { sourcePath?: unknown } } }).editor?.webcam
|
|
?.sourcePath === "string"
|
|
? ((project as { editor?: { webcam?: { sourcePath?: string } } }).editor?.webcam
|
|
?.sourcePath ?? null)
|
|
: null;
|
|
const normalizedWebcamPath = normalizeVideoSourcePath(rawWebcamPath);
|
|
|
|
if (!normalizedWebcamPath) {
|
|
return {
|
|
success: true,
|
|
videoPath: normalizedVideoPath,
|
|
webcamPath: null,
|
|
};
|
|
}
|
|
|
|
try {
|
|
await fs.access(normalizedWebcamPath, fsConstants.F_OK);
|
|
return {
|
|
success: true,
|
|
videoPath: normalizedVideoPath,
|
|
webcamPath: normalizedWebcamPath,
|
|
};
|
|
} catch {
|
|
return {
|
|
success: true,
|
|
videoPath: normalizedVideoPath,
|
|
webcamPath: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function getProjectsDir() {
|
|
const projectsDir = path.join(await getRecordingsDir(), PROJECTS_DIRECTORY_NAME);
|
|
await fs.mkdir(projectsDir, { recursive: true });
|
|
return projectsDir;
|
|
}
|
|
|
|
export async function persistRecordingsDirectorySetting(nextDir: string) {
|
|
setCustomRecordingsDir(path.resolve(nextDir));
|
|
setRecordingsDirLoaded(true);
|
|
await fs.writeFile(
|
|
RECORDINGS_SETTINGS_FILE,
|
|
JSON.stringify({ recordingsDir: path.resolve(nextDir) }, null, 2),
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
export function hasProjectFileExtension(filePath: string) {
|
|
const extension = path.extname(filePath).replace(/^\./, "").toLowerCase();
|
|
return [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS].includes(extension);
|
|
}
|
|
|
|
export function getProjectThumbnailPath(projectPath: string) {
|
|
return `${projectPath}${PROJECT_THUMBNAIL_SUFFIX}`;
|
|
}
|
|
|
|
export async function saveProjectThumbnail(projectPath: string, thumbnailDataUrl?: string | null) {
|
|
const thumbnailPath = getProjectThumbnailPath(projectPath);
|
|
if (thumbnailDataUrl === undefined) {
|
|
return existsSync(thumbnailPath) ? thumbnailPath : null;
|
|
}
|
|
|
|
if (!thumbnailDataUrl) {
|
|
await fs.rm(thumbnailPath, { force: true }).catch(() => undefined);
|
|
return null;
|
|
}
|
|
|
|
const match = thumbnailDataUrl.match(/^data:image\/png;base64,(.+)$/);
|
|
if (!match) {
|
|
throw new Error("Project thumbnail must be a PNG data URL.");
|
|
}
|
|
|
|
await fs.writeFile(thumbnailPath, Buffer.from(match[1], "base64"));
|
|
return thumbnailPath;
|
|
}
|
|
|
|
export async function loadRecentProjectPaths() {
|
|
try {
|
|
const content = await fs.readFile(RECENT_PROJECTS_FILE, "utf-8");
|
|
const parsed = parseJsonWithByteOrderMark<{ paths?: unknown }>(content);
|
|
return Array.isArray(parsed.paths)
|
|
? parsed.paths.filter(
|
|
(value): value is string =>
|
|
typeof value === "string" && value.trim().length > 0,
|
|
)
|
|
: [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function saveRecentProjectPaths(paths: string[]) {
|
|
const normalizedPaths = Array.from(new Set(paths.map((value) => normalizePath(value)))).slice(
|
|
0,
|
|
MAX_RECENT_PROJECTS,
|
|
);
|
|
await fs.writeFile(
|
|
RECENT_PROJECTS_FILE,
|
|
JSON.stringify({ paths: normalizedPaths }, null, 2),
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
export async function rememberRecentProject(projectPath: string) {
|
|
if (!hasProjectFileExtension(projectPath)) {
|
|
return;
|
|
}
|
|
|
|
const existingPaths = await loadRecentProjectPaths();
|
|
await saveRecentProjectPaths([projectPath, ...existingPaths]);
|
|
}
|
|
|
|
export async function buildProjectLibraryEntry(
|
|
projectPath: string,
|
|
projectsDir: string,
|
|
): Promise<ProjectLibraryEntry | null> {
|
|
try {
|
|
const normalizedPath = normalizePath(projectPath);
|
|
if (!hasProjectFileExtension(normalizedPath)) {
|
|
return null;
|
|
}
|
|
|
|
const stats = await fs.stat(normalizedPath);
|
|
if (!stats.isFile()) {
|
|
return null;
|
|
}
|
|
|
|
const thumbnailPath = getProjectThumbnailPath(normalizedPath);
|
|
const thumbnailExists = await fs
|
|
.access(thumbnailPath, fsConstants.R_OK)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
|
|
return {
|
|
path: normalizedPath,
|
|
name: path
|
|
.basename(normalizedPath)
|
|
.replace(
|
|
new RegExp(
|
|
`\\.(${[PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS].join("|")})$`,
|
|
"i",
|
|
),
|
|
"",
|
|
),
|
|
updatedAt: stats.mtimeMs,
|
|
thumbnailPath: thumbnailExists ? thumbnailPath : null,
|
|
isCurrent: Boolean(
|
|
currentProjectPath && normalizePath(currentProjectPath) === normalizedPath,
|
|
),
|
|
isInProjectsDirectory: path.dirname(normalizedPath) === normalizePath(projectsDir),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function listProjectLibraryEntries() {
|
|
const projectsDir = await getProjectsDir();
|
|
const projectPaths: string[] = [];
|
|
|
|
try {
|
|
const entries = await fs.readdir(projectsDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
|
|
const entryPath = path.join(projectsDir, entry.name);
|
|
if (hasProjectFileExtension(entryPath)) {
|
|
projectPaths.push(entryPath);
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore directory read failures and fall back to recent files.
|
|
}
|
|
|
|
const recentProjectPaths = await loadRecentProjectPaths();
|
|
const candidatePaths = Array.from(new Set([...projectPaths, ...recentProjectPaths]));
|
|
const entries = (
|
|
await Promise.all(
|
|
candidatePaths.map((candidatePath) =>
|
|
buildProjectLibraryEntry(candidatePath, projectsDir),
|
|
),
|
|
)
|
|
)
|
|
.filter((entry): entry is ProjectLibraryEntry => entry != null)
|
|
.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
|
|
await saveRecentProjectPaths(entries.map((entry) => entry.path));
|
|
|
|
return {
|
|
projectsDir,
|
|
entries,
|
|
};
|
|
}
|
|
|
|
function isLoadableProjectData(projectData: unknown) {
|
|
if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) {
|
|
return false;
|
|
}
|
|
|
|
const candidate = projectData as {
|
|
version?: unknown;
|
|
projectId?: unknown;
|
|
videoPath?: unknown;
|
|
editor?: unknown;
|
|
};
|
|
|
|
return (
|
|
typeof candidate.version === "number" &&
|
|
(candidate.projectId === undefined || typeof candidate.projectId === "string") &&
|
|
typeof candidate.videoPath === "string" &&
|
|
candidate.videoPath.trim().length > 0 &&
|
|
candidate.editor != null &&
|
|
typeof candidate.editor === "object" &&
|
|
!Array.isArray(candidate.editor)
|
|
);
|
|
}
|
|
|
|
export async function loadProjectFromPath(projectPath: string) {
|
|
const normalizedPath = normalizePath(projectPath);
|
|
let project: unknown;
|
|
try {
|
|
const content = await fs.readFile(normalizedPath, "utf-8");
|
|
project = parseJsonWithByteOrderMark(content);
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
canceled: false,
|
|
message: `Failed to read project file: ${error instanceof Error ? error.message : String(error)}`,
|
|
};
|
|
}
|
|
if (!isLoadableProjectData(project)) {
|
|
return {
|
|
success: false,
|
|
canceled: false,
|
|
message: "Invalid project file format",
|
|
};
|
|
}
|
|
const mediaSources = await resolveProjectMediaSources(project);
|
|
|
|
if (!mediaSources.success) {
|
|
return {
|
|
success: false,
|
|
canceled: false,
|
|
message: mediaSources.message,
|
|
};
|
|
}
|
|
const projectObj = project as Record<string, unknown>;
|
|
const editorObj = projectObj?.editor as Record<string, unknown> | undefined;
|
|
const audioTracks = editorObj?.audioTracks as { sourcePath?: unknown }[] | undefined;
|
|
const audioRegions = editorObj?.audioRegions as { audioPath?: unknown }[] | undefined;
|
|
const approvedProjectPaths: Array<string | null | undefined> = [
|
|
mediaSources.videoPath,
|
|
mediaSources.webcamPath,
|
|
];
|
|
if (Array.isArray(audioTracks)) {
|
|
for (const track of audioTracks) {
|
|
if (typeof track?.sourcePath === "string") {
|
|
approvedProjectPaths.push(track.sourcePath);
|
|
}
|
|
}
|
|
}
|
|
if (Array.isArray(audioRegions)) {
|
|
for (const region of audioRegions) {
|
|
if (typeof region?.audioPath === "string") {
|
|
approvedProjectPaths.push(region.audioPath);
|
|
}
|
|
}
|
|
}
|
|
await replaceApprovedSessionLocalReadPaths(approvedProjectPaths);
|
|
await rememberRecentProject(normalizedPath);
|
|
|
|
setCurrentProjectPath(normalizedPath);
|
|
setCurrentVideoPath(mediaSources.videoPath);
|
|
setCurrentRecordingSession({
|
|
videoPath: mediaSources.videoPath,
|
|
webcamPath: mediaSources.webcamPath,
|
|
timeOffsetMs: 0,
|
|
} as RecordingSessionData);
|
|
|
|
return {
|
|
success: true,
|
|
path: normalizedPath,
|
|
project,
|
|
};
|
|
}
|
|
|
|
export function isTrustedProjectPath(filePath?: string | null): boolean {
|
|
if (!filePath || !currentProjectPath) return false;
|
|
return normalizePath(filePath) === normalizePath(currentProjectPath);
|
|
}
|