IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

service.ts

Buka raw
import "server-only";

import {
  cp,
  copyFile,
  lstat,
  mkdir,
  readFile,
  readdir,
  rename,
  rm,
  stat,
  writeFile,
} from "node:fs/promises";
import { createWriteStream } from "node:fs";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import path from "node:path";
import { setImmediate as setImmediatePromise } from "node:timers/promises";
import { serverEnv } from "@/lib/server/env";
import { withServerTiming } from "@/lib/server/logging/logger";
import {
  FilesPathError,
  resolvePathWithinFilesRoot,
  type ResolvedFilesPath,
} from "@/lib/server/modules/files/path-resolver";
import {
  isPathStarredInDb,
  listStarredPathsFromDb,
  setPathStarredInDb,
} from "@/lib/server/modules/files/stars-repository";
import { listTrashEntriesFromDb } from "@/lib/server/modules/files/trash-repository";
import type {
  FileInfoResponse,
  FileListEntry,
  FileListResponse,
  FilePasteOperation,
  FilePasteResponse,
  FileReadMode,
  FileReadResponse,
  FileCreateResponse,
  FileRenameResponse,
  FileServiceErrorCode,
  FileToggleStarResponse,
  FileUnzipResponse,
  FileUploadResponse,
  FileWriteResponse,
} from "@/lib/shared/contracts/files";

const TEXT_EXTENSIONS = new Set([
  "txt", "md", "json", "yaml", "yml", "conf", "env", "log", "csv",
  "sh", "bash", "zsh", "fish",
  "py", "js", "ts", "jsx", "tsx", "mjs", "cjs",
  "css", "scss", "sass", "less",
  "html", "htm", "xml", "svg",
  "sql", "ini", "toml", "cfg",
  "go", "rs", "rb", "php", "kt", "kts", "java",
  "c", "cpp", "cc", "cxx", "h", "hpp",
  "swift", "pl", "pm", "lua", "r", "ex", "exs",
  "dockerfile", "makefile", "gitignore", "gitattributes",
  "editorconfig", "prettierrc", "eslintrc", "babelrc",
  "lock", "gradle", "pom",
]);

const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
  png: "image/png",
  jpg: "image/jpeg",
  jpeg: "image/jpeg",
  gif: "image/gif",
  webp: "image/webp",
  svg: "image/svg+xml",
  bmp: "image/bmp",
};

const VIDEO_MIME_BY_EXTENSION: Record<string, string> = {
  mp4: "video/mp4",
  webm: "video/webm",
  ogg: "video/ogg",
  ogv: "video/ogg",
  mov: "video/quicktime",
  mkv: "video/x-matroska",
  avi: "video/x-msvideo",
  m4v: "video/mp4",
};

const AUDIO_MIME_BY_EXTENSION: Record<string, string> = {
  mp3: "audio/mpeg",
  wav: "audio/wav",
  flac: "audio/flac",
  aac: "audio/aac",
  m4a: "audio/mp4",
  opus: "audio/opus",
  oga: "audio/ogg",
  weba: "audio/webm",
};

export const MAX_TEXT_READ_BYTES = 2 * 1024 * 1024;

type ListDirectoryParams = {
  path?: string;
  includeHidden?: boolean;
};

type ReadFileForViewerParams = {
  path: string;
  includeHidden?: boolean;
};

type WriteTextFileParams = {
  path: string;
  content: string;
  expectedMtimeMs?: number;
  includeHidden?: boolean;
};

type CreateEntryParams = {
  parentPath?: string;
  name: string;
  includeHidden?: boolean;
};

type PasteEntryParams = {
  sourcePath: string;
  destinationPath?: string;
  operation: FilePasteOperation;
  collision?: "replace" | "keep-both" | "skip";
  includeHidden?: boolean;
};

type RenameEntryParams = {
  path: string;
  newName: string;
  includeHidden?: boolean;
};

type GetEntryInfoParams = {
  path: string;
  includeHidden?: boolean;
};

type ToggleStarEntryParams = {
  path: string;
  includeHidden?: boolean;
};

export class FileServiceError extends Error {
  readonly code: FileServiceErrorCode;
  readonly statusCode: number;

  constructor(
    message: string,
    options?: {
      code?: FileServiceErrorCode;
      statusCode?: number;
      cause?: unknown;
    },
  ) {
    super(message, {
      cause: options?.cause,
    });
    this.name = "FileServiceError";
    this.code = options?.code ?? "internal_error";
    this.statusCode = options?.statusCode ?? 500;
  }
}

function getExtension(fileName: string) {
  const raw = path.extname(fileName).slice(1).toLowerCase();
  return raw.length > 0 ? raw : null;
}

function isHiddenName(name: string) {
  return name.startsWith(".");
}

function isTrashPathSegments(segments: string[]) {
  return segments[0] === "Trash";
}

function mapFsError(
  error: unknown,
  fallbackMessage: string,
  absolutePath?: string,
) {
  if (error instanceof FileServiceError) return error;
  if (error instanceof FilesPathError) {
    return new FileServiceError(error.message, {
      code: error.code,
      statusCode: error.statusCode,
      cause: error,
    });
  }

  const nodeError = error as NodeJS.ErrnoException;
  if (nodeError?.code === "ENOENT") {
    return new FileServiceError("File or directory not found", {
      code: "not_found",
      statusCode: 404,
      cause: error,
    });
  }
  if (nodeError?.code === "ENOTDIR") {
    return new FileServiceError("Path is not a directory", {
      code: "not_a_directory",
      statusCode: 400,
      cause: error,
    });
  }
  if (nodeError?.code === "EISDIR") {
    return new FileServiceError("Path is not a file", {
      code: "not_a_file",
      statusCode: 400,
      cause: error,
    });
  }
  if (nodeError?.code === "EACCES" || nodeError?.code === "EPERM") {
    return new FileServiceError(
      `Permission denied${absolutePath ? `: ${absolutePath}` : ""}`,
      {
        code: "permission_denied",
        statusCode: 403,
        cause: error,
      },
    );
  }
  if (nodeError?.code === "ELOOP") {
    return new FileServiceError("Symlinks are not allowed", {
      code: "symlink_blocked",
      statusCode: 403,
      cause: error,
    });
  }
  if (nodeError?.code === "EEXIST") {
    return new FileServiceError("Destination already exists", {
      code: "destination_exists",
      statusCode: 409,
      cause: error,
    });
  }
  if (nodeError?.code === "EXDEV") {
    return new FileServiceError("Move across filesystem boundaries failed", {
      code: "internal_error",
      statusCode: 500,
      cause: error,
    });
  }

  return new FileServiceError(fallbackMessage, {
    code: "internal_error",
    statusCode: 500,
    cause: error,
  });
}

function isTextExtension(extension: string | null) {
  if (!extension) return false;
  return TEXT_EXTENSIONS.has(extension);
}

export function getMimeTypeForExtension(extension: string | null) {
  if (!extension) return null;
  if (extension in IMAGE_MIME_BY_EXTENSION) return IMAGE_MIME_BY_EXTENSION[extension];
  if (extension in VIDEO_MIME_BY_EXTENSION) return VIDEO_MIME_BY_EXTENSION[extension];
  if (extension in AUDIO_MIME_BY_EXTENSION) return AUDIO_MIME_BY_EXTENSION[extension];
  if (extension === "pdf") return "application/pdf";
  if (TEXT_EXTENSIONS.has(extension)) return "text/plain; charset=utf-8";
  return null;
}

function toViewerMode(extension: string | null): FileReadMode {
  if (!extension) return "binary_unsupported";
  if (TEXT_EXTENSIONS.has(extension)) return "text";
  if (extension in IMAGE_MIME_BY_EXTENSION) return "image";
  if (extension === "pdf") return "pdf";
  if (extension in VIDEO_MIME_BY_EXTENSION) return "video";
  if (extension in AUDIO_MIME_BY_EXTENSION) return "audio";
  return "binary_unsupported";
}

function toPublicPath(segments: string[]) {
  return segments.join("/");
}

function toPermissionsString(mode: number) {
  return `0${(mode & 0o777).toString(8).padStart(3, "0")}`;
}

function toEntryType(info: Awaited<ReturnType<typeof lstat>>) {
  if (info.isDirectory()) return "folder" as const;
  if (info.isFile()) return "file" as const;
  return null;
}

function sortDirectoryEntries(entries: FileListEntry[]) {
  return entries.sort((left, right) => {
    if (left.type !== right.type) {
      return left.type === "folder" ? -1 : 1;
    }
    return left.name.localeCompare(right.name);
  });
}

function ensureSafeName(rawName: string, includeHidden: boolean) {
  const name = rawName.trim();
  if (name.length === 0 || name === "." || name === "..") {
    throw new FileServiceError("Invalid path", {
      code: "invalid_path",
      statusCode: 400,
    });
  }
  if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
    throw new FileServiceError("Invalid path", {
      code: "invalid_path",
      statusCode: 400,
    });
  }
  if (!includeHidden && isHiddenName(name)) {
    throw new FileServiceError("Hidden files are not allowed", {
      code: "hidden_blocked",
      statusCode: 403,
    });
  }
  return name;
}

async function pathExists(absolutePath: string) {
  try {
    await lstat(absolutePath);
    return true;
  } catch (error) {
    const nodeError = error as NodeJS.ErrnoException;
    if (nodeError?.code === "ENOENT") {
      return false;
    }
    throw error;
  }
}

async function resolveFilePath(
  inputPath: string | undefined,
  includeHidden: boolean,
  options: {
    allowEmpty?: boolean;
    allowMissingLeaf?: boolean;
  } = {},
) {
  const preResolved = await resolvePathWithinFilesRoot({
    inputPath,
    allowEmpty: options.allowEmpty ?? true,
    allowHiddenSegments: true,
    allowMissingLeaf: options.allowMissingLeaf ?? false,
  });
  if (!includeHidden && !isTrashPathSegments(preResolved.segments)) {
    const hiddenSegment = preResolved.segments.find((segment) =>
      isHiddenName(segment),
    );
    if (hiddenSegment) {
      throw new FileServiceError("Hidden files are not allowed", {
        code: "hidden_blocked",
        statusCode: 403,
      });
    }
  }
  return preResolved;
}

async function assertDirectoryPath(
  inputPath: string | undefined,
  includeHidden: boolean,
  options: { allowEmpty?: boolean } = {},
) {
  const resolved = await resolveFilePath(inputPath, includeHidden, {
    allowEmpty: options.allowEmpty ?? true,
  });
  const info = await lstat(resolved.absolutePath);
  if (info.isSymbolicLink()) {
    throw new FileServiceError("Symlinks are not allowed", {
      code: "symlink_blocked",
      statusCode: 403,
    });
  }
  if (!info.isDirectory()) {
    throw new FileServiceError("Path is not a directory", {
      code: "not_a_directory",
      statusCode: 400,
    });
  }
  return resolved;
}

function joinRelativePath(baseSegments: string[], leafName: string) {
  if (baseSegments.length === 0) return leafName;
  return `${baseSegments.join("/")}/${leafName}`;
}

export async function listDirectory(params: ListDirectoryParams = {}): Promise<FileListResponse> {
  return withServerTiming(
    {
      level: "debug",
      layer: "service",
      action: "files.listDirectory",
      meta: {
        path: params.path ?? "",
        includeHidden: Boolean(params.includeHidden),
      },
      onSuccessMeta: (result) => ({
        entryCount: (result as FileListResponse).entries.length,
      }),
    },
    async () => {
      const includeHidden = Boolean(params.includeHidden);
      let resolved: ResolvedFilesPath | undefined;

      try {
        resolved = await resolveFilePath(params.path, includeHidden);
        const info = await lstat(resolved.absolutePath);
        if (info.isSymbolicLink()) {
          throw new FileServiceError("Symlinks are not allowed", {
            code: "symlink_blocked",
            statusCode: 403,
          });
        }
        if (!info.isDirectory()) {
          throw new FileServiceError("Path is not a directory", {
            code: "not_a_directory",
            statusCode: 400,
          });
        }
      } catch (error) {
        throw mapFsError(
          error,
          "Failed to list directory",
          resolved?.absolutePath,
        );
      }

      try {
        const entries = await readdir(resolved.absolutePath, {
          withFileTypes: true,
        });
        const output: FileListEntry[] = [];
        const listingTrash = isTrashPathSegments(resolved.segments);

        const [starredPathSet, trashMetaMap] = await Promise.all([
          listStarredPathsFromDb().then((paths) => new Set(paths)),
          listingTrash
            ? listTrashEntriesFromDb().then(
                (rows) => new Map(rows.map((r) => [r.trashPath, r])),
              )
            : Promise.resolve(new Map<string, { originalPath: string; deletedAt: Date }>()),
        ]);

        const visibleEntries = entries.filter(
          (entry) => includeHidden || listingTrash || !isHiddenName(entry.name),
        );

        const statResults = await Promise.all(
          visibleEntries.map(async (entry) => {
            const absoluteEntryPath = path.join(resolved.absolutePath, entry.name);
            const info = await lstat(absoluteEntryPath);
            return { entry, info };
          }),
        );

        for (const { entry, info } of statResults) {
          if (info.isSymbolicLink()) continue;

          const type = info.isDirectory() ? "folder" : info.isFile() ? "file" : null;
          if (!type) continue;

          const entryPublicPath = toPublicPath([...resolved.segments, entry.name]);
          const trashMeta = trashMetaMap.get(entryPublicPath);
          output.push({
            name: entry.name,
            path: entryPublicPath,
            type,
            ext: type === "file" ? getExtension(entry.name) : null,
            sizeBytes: type === "file" ? info.size : null,
            modifiedAt: info.mtime.toISOString(),
            mtimeMs: info.mtimeMs,
            starred: starredPathSet.has(entryPublicPath),
            ...(trashMeta && {
              trashOriginalPath: trashMeta.originalPath,
              trashDeletedAt: trashMeta.deletedAt.toISOString(),
            }),
          });
        }

        return {
          root: resolved.rootPath,
          cwd: resolved.relativePath,
          entries: sortDirectoryEntries(output),
        };
      } catch (error) {
        throw mapFsError(
          error,
          "Failed to list directory",
          resolved.absolutePath,
        );
      }
    },
  );
}

export async function readFileForViewer(params: ReadFileForViewerParams): Promise<FileReadResponse> {
  return withServerTiming(
    {
      level: "debug",
      layer: "service",
      action: "files.readFile",
      meta: {
        path: params.path,
        includeHidden: Boolean(params.includeHidden),
      },
      onSuccessMeta: (result) => {
        const response = result as FileReadResponse;
        return {
          mode: response.mode,
          sizeBytes: response.sizeBytes,
        };
      },
    },
    async () => {
      let resolved: ResolvedFilesPath | undefined;
      let info: Awaited<ReturnType<typeof stat>>;

      try {
        resolved = await resolveFilePath(params.path, Boolean(params.includeHidden), {
          allowEmpty: false,
        });
        const linkInfo = await lstat(resolved.absolutePath);
        if (linkInfo.isSymbolicLink()) {
          throw new FileServiceError("Symlinks are not allowed", {
            code: "symlink_blocked",
            statusCode: 403,
          });
        }
        if (!linkInfo.isFile()) {
          throw new FileServiceError("Path is not a file", {
            code: "not_a_file",
            statusCode: 400,
          });
        }
        info = await stat(resolved.absolutePath);
      } catch (error) {
        throw mapFsError(
          error,
          "Failed to open file",
          resolved?.absolutePath,
        );
      }

      const name = path.basename(resolved.absolutePath);
      const ext = getExtension(name);
      const mode = toViewerMode(ext);
      const mimeType = getMimeTypeForExtension(ext);

      if (mode === "text") {
        if (info.size > MAX_TEXT_READ_BYTES) {
          return {
            root: resolved.rootPath,
            path: resolved.relativePath,
            name,
            ext,
            mode: "too_large",
            mimeType,
            sizeBytes: info.size,
            modifiedAt: info.mtime.toISOString(),
            mtimeMs: info.mtimeMs,
            content: null,
          };
        }

        try {
          const content = await readFile(resolved.absolutePath, "utf8");
          return {
            root: resolved.rootPath,
            path: resolved.relativePath,
            name,
            ext,
            mode,
            mimeType,
            sizeBytes: info.size,
            modifiedAt: info.mtime.toISOString(),
            mtimeMs: info.mtimeMs,
            content,
          };
        } catch (error) {
          throw mapFsError(
            error,
            "Failed to read file",
            resolved.absolutePath,
          );
        }
      }

      return {
        root: resolved.rootPath,
        path: resolved.relativePath,
        name,
        ext,
        mode,
        mimeType,
        sizeBytes: info.size,
        modifiedAt: info.mtime.toISOString(),
        mtimeMs: info.mtimeMs,
        content: null,
      };
    },
  );
}

export async function writeTextFile(params: WriteTextFileParams): Promise<FileWriteResponse> {
  return withServerTiming(
    {
      layer: "service",
      action: "files.writeFile",
      meta: {
        path: params.path,
        byteLength: Buffer.byteLength(params.content, "utf8"),
        includeHidden: Boolean(params.includeHidden),
      },
      onSuccessMeta: (result) => ({
        sizeBytes: (result as FileWriteResponse).sizeBytes,
      }),
    },
    async () => {
      const byteLength = Buffer.byteLength(params.content, "utf8");
      if (byteLength > MAX_TEXT_READ_BYTES) {
        throw new FileServiceError("Payload too large", {
          code: "payload_too_large",
          statusCode: 413,
        });
      }

      let resolved: ResolvedFilesPath | undefined;
      let currentInfo: Awaited<ReturnType<typeof stat>>;

      try {
        resolved = await resolveFilePath(params.path, Boolean(params.includeHidden), {
          allowEmpty: false,
        });
        const linkInfo = await lstat(resolved.absolutePath);
        if (linkInfo.isSymbolicLink()) {
          throw new FileServiceError("Symlinks are not allowed", {
            code: "symlink_blocked",
            statusCode: 403,
          });
        }
        if (!linkInfo.isFile()) {
          throw new FileServiceError("Path is not a file", {
            code: "not_a_file",
            statusCode: 400,
          });
        }
        currentInfo = await stat(resolved.absolutePath);
      } catch (error) {
        throw mapFsError(
          error,
          "Failed to save file",
          resolved?.absolutePath,
        );
      }

      const ext = getExtension(path.basename(resolved.absolutePath));
      if (!isTextExtension(ext)) {
        throw new FileServiceError("Unsupported file type for save", {
          code: "unsupported_file",
          statusCode: 415,
        });
      }

      if (
        typeof params.expectedMtimeMs === "number" &&
        Number.isFinite(params.expectedMtimeMs) &&
        currentInfo.mtimeMs !== params.expectedMtimeMs
      ) {
        throw new FileServiceError("File changed since it was opened", {
          code: "write_conflict",
          statusCode: 409,
        });
      }

      try {
        const target = await resolvePathWithinFilesRoot({
          inputPath: params.path,
          allowHiddenSegments: Boolean(params.includeHidden),
        });
        if (target.absolutePath !== resolved.absolutePath) {
          throw new FileServiceError("File changed while resolving target path", {
            code: "write_conflict",
            statusCode: 409,
          });
        }

        await writeFile(resolved.absolutePath, params.content, "utf8");
        const savedInfo = await stat(resolved.absolutePath);
        return {
          root: resolved.rootPath,
          path: resolved.relativePath,
          sizeBytes: savedInfo.size,
          modifiedAt: savedInfo.mtime.toISOString(),
          mtimeMs: savedInfo.mtimeMs,
        };
      } catch (error) {
        throw mapFsError(
          error,
          "Failed to save file",
          resolved.absolutePath,
        );
      }
    },
  );
}

export async function resolveReadableFileAbsolutePath(input: {
  path: string;
  includeHidden?: boolean;
}) {
  let resolved: ResolvedFilesPath | undefined;

  try {
    resolved = await resolveFilePath(input.path, Boolean(input.includeHidden), {
      allowEmpty: false,
    });
    const linkInfo = await lstat(resolved.absolutePath);
    if (linkInfo.isSymbolicLink()) {
      throw new FileServiceError("Symlinks are not allowed", {
        code: "symlink_blocked",
        statusCode: 403,
      });
    }
    if (!linkInfo.isFile()) {
      throw new FileServiceError("Path is not a file", {
        code: "not_a_file",
        statusCode: 400,
      });
    }
  } catch (error) {
    throw mapFsError(
      error,
      "Failed to resolve file",
      resolved?.absolutePath,
    );
  }

  return {
    root: resolved.rootPath,
    path: resolved.relativePath,
    absolutePath: resolved.absolutePath,
  };
}

export async function resolveDownloadableFolderAbsolutePath(input: {
  path: string;
  includeHidden?: boolean;
}) {
  let resolved: ResolvedFilesPath | undefined;

  try {
    resolved = await resolveFilePath(input.path, Boolean(input.includeHidden), {
      allowEmpty: false,
    });
    const linkInfo = await lstat(resolved.absolutePath);
    if (linkInfo.isSymbolicLink()) {
      throw new FileServiceError("Symlinks are not allowed", {
        code: "symlink_blocked",
        statusCode: 403,
      });
    }
    if (!linkInfo.isDirectory()) {
      throw new FileServiceError("Path is not a directory", {
        code: "not_a_directory",
        statusCode: 400,
      });
    }
  } catch (error) {
    throw mapFsError(
      error,
      "Failed to resolve folder",
      resolved?.absolutePath,
    );
  }

  const folderName = path.basename(resolved.absolutePath);
  const parentAbsolutePath = path.dirname(resolved.absolutePath);

  return {
    root: resolved.rootPath,
    path: resolved.relativePath,
    absolutePath: resolved.absolutePath,
    parentAbsolutePath,
    folderName,
  };
}

export async function createDirectoryEntry(
  params: CreateEntryParams,
): Promise<FileCreateResponse> {
  return withServerTiming(
    {
      layer: "service",
      action: "files.createDirectory",
      meta: {
        parentPath: params.parentPath ?? "",
        name: params.name,
        includeHidden: Boolean(params.includeHidden),
      },
    },
    async () => {
      const includeHidden = Boolean(params.includeHidden);
      const name = ensureSafeName(params.name, includeHidden);
      let parentPath: ResolvedFilesPath | undefined;

      try {
        parentPath = await assertDirectoryPath(params.parentPath, includeHidden, {
          allowEmpty: true,
        });
        const relativePath = joinRelativePath(parentPath.segments, name);
        const targetPath = await resolveFilePath(relativePath, includeHidden, {
          allowEmpty: false,
          allowMissingLeaf: true,
        });

        if (targetPath.exists || (await pathExists(targetPath.absolutePath))) {
          throw new FileServiceError("Destination already exists", {
            code: "destination_exists",
            statusCode: 409,
          });
        }

        await mkdir(targetPath.absolutePath, {
          recursive: false,
        });

        return {
          root: targetPath.rootPath,
          path: targetPath.relativePath,
          type: "folder",
        };
      } catch (error) {
        throw mapFsError(error, "Failed to create folder", parentPath?.absolutePath);
      }
    },
  );
}

export async function createFileEntry(
  params: CreateEntryParams,
): Promise<FileCreateResponse> {
  return withServerTiming(
    {
      layer: "service",
      action: "files.createFile",
      meta: {
        parentPath: params.parentPath ?? "",
        name: params.name,
        includeHidden: Boolean(params.includeHidden),
      },
    },
    async () => {
      const includeHidden = Boolean(params.includeHidden);
      const name = ensureSafeName(params.name, includeHidden);
      let parentPath: ResolvedFilesPath | undefined;

      try {
        parentPath = await assertDirectoryPath(params.parentPath, includeHidden, {
          allowEmpty: true,
        });
        const relativePath = joinRelativePath(parentPath.segments, name);
        const targetPath = await resolveFilePath(relativePath, includeHidden, {
          allowEmpty: false,
          allowMissingLeaf: true,
        });

        if (targetPath.exists || (await pathExists(targetPath.absolutePath))) {
          throw new FileServiceError("Destination already exists", {
            code: "destination_exists",
            statusCode: 409,
          });
        }

        await writeFile(targetPath.absolutePath, "", {
          encoding: "utf8",
          flag: "wx",
        });

        return {
          root: targetPath.rootPath,
          path: targetPath.relativePath,
          type: "file",
        };
      } catch (error) {
        throw mapFsError(error, "Failed to create file", parentPath?.absolutePath);
      }
    },
  );
}

async function movePath(
  sourceAbsolutePath: string,
  destinationAbsolutePath: string,
  isDirectory: boolean,
) {
  try {
    await rename(sourceAbsolutePath, destinationAbsolutePath);
    return;
  } catch (error) {
    const nodeError = error as NodeJS.ErrnoException;
    if (nodeError?.code !== "EXDEV") {
      throw error;
    }
  }

  if (isDirectory) {
    await cp(sourceAbsolutePath, destinationAbsolutePath, {
      recursive: true,
      force: false,
      errorOnExist: true,
    });
  } else {
    await copyFile(sourceAbsolutePath, destinationAbsolutePath);
  }
  await rm(sourceAbsolutePath, {
    recursive: true,
    force: true,
  });
}

export async function pasteEntry(params: PasteEntryParams): Promise<FilePasteResponse> {
  return withServerTiming(
    {
      layer: "service",
      action: "files.pasteEntry",
      meta: {
        sourcePath: params.sourcePath,
        destinationPath: params.destinationPath ?? "",
        operation: params.operation,
        includeHidden: Boolean(params.includeHidden),
      },
    },
    async () => {
      const includeHidden = Boolean(params.includeHidden);
      let sourcePath: ResolvedFilesPath | undefined;
      let destinationDirectory: ResolvedFilesPath | undefined;
      let targetPath: ResolvedFilesPath | undefined;

      try {
        sourcePath = await resolveFilePath(params.sourcePath, includeHidden, {
          allowEmpty: false,
        });
        const sourceInfo = await lstat(sourcePath.absolutePath);
        if (sourceInfo.isSymbolicLink()) {
          throw new FileServiceError("Symlinks are not allowed", {
            code: "symlink_blocked",
            statusCode: 403,
          });
        }
        if (!sourceInfo.isDirectory() && !sourceInfo.isFile()) {
          throw new FileServiceError("Unsupported file type", {
            code: "unsupported_file",
            statusCode: 415,
          });
        }

        destinationDirectory = await assertDirectoryPath(
          params.destinationPath,
          includeHidden,
          { allowEmpty: true },
        );
        const targetRelativePath = joinRelativePath(
          destinationDirectory.segments,
          path.basename(sourcePath.relativePath),
        );
        targetPath = await resolveFilePath(targetRelativePath, includeHidden, {
          allowEmpty: false,
          allowMissingLeaf: true,
        });

        if (targetPath.absolutePath === sourcePath.absolutePath) {
          throw new FileServiceError("Destination already exists", {
            code: "destination_exists",
            statusCode: 409,
          });
        }

        const targetExists = targetPath.exists || (await pathExists(targetPath.absolutePath));

        if (targetExists) {
          if (!params.collision || params.collision === "skip") {
            if (!params.collision) {
              throw new FileServiceError("Destination already exists", {
                code: "destination_exists",
                statusCode: 409,
              });
            }
            // skip: return the existing path without error
            return {
              root: targetPath.rootPath,
              path: targetPath.relativePath,
              type: sourceInfo.isDirectory() ? "folder" : "file",
            };
          }

          if (params.collision === "keep-both") {
            // Find a unique name: "name (1)", "name (2)", etc.
            const baseName = path.basename(sourcePath.relativePath);
            const ext = sourceInfo.isDirectory() ? "" : path.extname(baseName);
            const stem = sourceInfo.isDirectory() ? baseName : baseName.slice(0, baseName.length - ext.length);
            let counter = 1;
            let uniqueRelativePath: string;
            do {
              const candidate = `${stem} (${counter})${ext}`;
              uniqueRelativePath = joinRelativePath(destinationDirectory.segments, candidate);
              counter++;
            } while (await pathExists(path.join(destinationDirectory.absolutePath, `${stem} (${counter - 1})${ext}`)));
            targetPath = await resolveFilePath(uniqueRelativePath, includeHidden, {
              allowEmpty: false,
              allowMissingLeaf: true,
            });
          } else if (params.collision === "replace") {
            // Delete the existing target first
            await rm(targetPath.absolutePath, { recursive: true, force: true });
          }
        }

        if (
          params.operation === "move" &&
          sourceInfo.isDirectory() &&
          targetPath.relativePath.startsWith(`${sourcePath.relativePath}/`)
        ) {
          throw new FileServiceError("Cannot move folder into itself", {
            code: "invalid_path",
            statusCode: 400,
          });
        }

        if (params.operation === "copy") {
          await cp(sourcePath.absolutePath, targetPath.absolutePath, {
            recursive: sourceInfo.isDirectory(),
            force: params.collision === "replace",
            errorOnExist: params.collision !== "replace",
          });
        } else {
          await movePath(
            sourcePath.absolutePath,
            targetPath.absolutePath,
            sourceInfo.isDirectory(),
          );
          const wasStarred = await isPathStarredInDb(sourcePath.relativePath);
          if (wasStarred) {
            await setPathStarredInDb(sourcePath.relativePath, false);
            await setPathStarredInDb(targetPath.relativePath, true);
          }
        }

        return {
          root: targetPath.rootPath,
          path: targetPath.relativePath,
          type: sourceInfo.isDirectory() ? "folder" : "file",
        };
      } catch (error) {
        throw mapFsError(
          error,
          `Failed to ${params.operation === "copy" ? "copy" : "move"} item`,
          targetPath?.absolutePath ?? destinationDirectory?.absolutePath ?? sourcePath?.absolutePath,
        );
      }
    },
  );
}

export async function renameEntry(params: RenameEntryParams): Promise<FileRenameResponse> {
  return withServerTiming(
    {
      layer: "service",
      action: "files.renameEntry",
      meta: {
        path: params.path,
        newName: params.newName,
        includeHidden: Boolean(params.includeHidden),
      },
    },
    async () => {
      const includeHidden = Boolean(params.includeHidden);
      const newName = ensureSafeName(params.newName, includeHidden);
      let sourcePath: ResolvedFilesPath | undefined;
      let targetPath: ResolvedFilesPath | undefined;

      try {
        sourcePath = await resolveFilePath(params.path, includeHidden, {
          allowEmpty: false,
        });
        const sourceInfo = await lstat(sourcePath.absolutePath);
        if (sourceInfo.isSymbolicLink()) {
          throw new FileServiceError("Symlinks are not allowed", {
            code: "symlink_blocked",
            statusCode: 403,
          });
        }

        const parentRelativePath = sourcePath.segments.slice(0, -1).join("/");
        const nextRelativePath = joinRelativePath(
          parentRelativePath ? parentRelativePath.split("/") : [],
          newName,
        );
        targetPath = await resolveFilePath(nextRelativePath, includeHidden, {
          allowEmpty: false,
          allowMissingLeaf: true,
        });

        if (targetPath.absolutePath === sourcePath.absolutePath) {
          return {
            root: sourcePath.rootPath,
            oldPath: sourcePath.relativePath,
            path: sourcePath.relativePath,
            type: toEntryType(sourceInfo) ?? "file",
          };
        }

        if (targetPath.exists || (await pathExists(targetPath.absolutePath))) {
          throw new FileServiceError("Destination already exists", {
            code: "destination_exists",
            statusCode: 409,
          });
        }

        await rename(sourcePath.absolutePath, targetPath.absolutePath);
        const nextType = toEntryType(sourceInfo);
        if (!nextType) {
          throw new FileServiceError("Unsupported file type", {
            code: "unsupported_file",
            statusCode: 415,
          });
        }

        const wasStarred = await isPathStarredInDb(sourcePath.relativePath);
        if (wasStarred) {
          await setPathStarredInDb(sourcePath.relativePath, false);
          await setPathStarredInDb(targetPath.relativePath, true);
        }

        return {
          root: targetPath.rootPath,
          oldPath: sourcePath.relativePath,
          path: targetPath.relativePath,
          type: nextType,
        };
      } catch (error) {
        throw mapFsError(
          error,
          "Failed to rename item",
          targetPath?.absolutePath ?? sourcePath?.absolutePath,
        );
      }
    },
  );
}

export async function getEntryInfo(params: GetEntryInfoParams): Promise<FileInfoResponse> {
  return withServerTiming(
    {
      level: "debug",
      layer: "service",
      action: "files.getEntryInfo",
      meta: {
        path: params.path,
        includeHidden: Boolean(params.includeHidden),
      },
    },
    async () => {
      const includeHidden = Boolean(params.includeHidden);
      let resolved: ResolvedFilesPath | undefined;

      try {
        resolved = await resolveFilePath(params.path, includeHidden, {
          allowEmpty: false,
        });
        const linkInfo = await lstat(resolved.absolutePath);
        if (linkInfo.isSymbolicLink()) {
          throw new FileServiceError("Symlinks are not allowed", {
            code: "symlink_blocked",
            statusCode: 403,
          });
        }

        const entryType = toEntryType(linkInfo);
        if (!entryType) {
          throw new FileServiceError("Unsupported file type", {
            code: "unsupported_file",
            statusCode: 415,
          });
        }

        const entryName = path.basename(resolved.absolutePath);
        const entryExt = entryType === "file" ? getExtension(entryName) : null;
        const starred = await isPathStarredInDb(resolved.relativePath);

        return {
          root: resolved.rootPath,
          path: resolved.relativePath,
          absolutePath: resolved.absolutePath,
          name: entryName,
          type: entryType,
          ext: entryExt,
          sizeBytes: linkInfo.size,
          modifiedAt: linkInfo.mtime.toISOString(),
          createdAt: linkInfo.birthtime.toISOString(),
          permissions: toPermissionsString(linkInfo.mode),
          starred,
        };
      } catch (error) {
        throw mapFsError(error, "Failed to get file info", resolved?.absolutePath);
      }
    },
  );
}

export async function toggleStarEntry(
  params: ToggleStarEntryParams,
): Promise<FileToggleStarResponse> {
  return withServerTiming(
    {
      layer: "service",
      action: "files.toggleStar",
      meta: {
        path: params.path,
        includeHidden: Boolean(params.includeHidden),
      },
    },
    async () => {
      const includeHidden = Boolean(params.includeHidden);
      let resolved: ResolvedFilesPath | undefined;

      try {
        resolved = await resolveFilePath(params.path, includeHidden, {
          allowEmpty: false,
        });
        const linkInfo = await lstat(resolved.absolutePath);
        if (linkInfo.isSymbolicLink()) {
          throw new FileServiceError("Symlinks are not allowed", {
            code: "symlink_blocked",
            statusCode: 403,
          });
        }

        const entryType = toEntryType(linkInfo);
        if (!entryType) {
          throw new FileServiceError("Unsupported file type", {
            code: "unsupported_file",
            statusCode: 415,
          });
        }

        const currentlyStarred = await isPathStarredInDb(resolved.relativePath);
        const nextStarred = !currentlyStarred;
        await setPathStarredInDb(resolved.relativePath, nextStarred);

        return {
          path: resolved.relativePath,
          starred: nextStarred,
        };
      } catch (error) {
        throw mapFsError(error, "Failed to update star status", resolved?.absolutePath);
      }
    },
  );
}

export async function getStarredEntries(): Promise<FileListResponse> {
  const starredPaths = await listStarredPathsFromDb();
  const output: FileListEntry[] = [];

  await Promise.all(
    starredPaths.map(async (starredPath) => {
      try {
        const resolved = await resolveFilePath(starredPath, true, {
          allowEmpty: false,
        });
        const info = await lstat(resolved.absolutePath);
        if (info.isSymbolicLink()) return;
        const type = info.isDirectory() ? "folder" : info.isFile() ? "file" : null;
        if (!type) return;
        output.push({
          name: path.basename(resolved.relativePath),
          path: resolved.relativePath,
          type,
          ext: type === "file" ? getExtension(path.basename(resolved.relativePath)) : null,
          sizeBytes: type === "file" ? info.size : null,
          modifiedAt: info.mtime.toISOString(),
          mtimeMs: info.mtimeMs,
          starred: true,
        });
      } catch {
        // File no longer exists — silently skip stale star
      }
    }),
  );

  output.sort((a, b) => a.name.localeCompare(b.name));

  const rootResolved = await resolveFilePath("", true, { allowEmpty: true });

  return {
    root: rootResolved.rootPath,
    cwd: "Starred",
    entries: output,
  };
}

export type SearchFilesParams = {
  query: string;
  /** Relative path within FILES_ROOT to search under (default: root) */
  basePath?: string;
  includeHidden?: boolean;
  /** Maximum results to return (default: 200) */
  limit?: number;
  /**
   * Wall-clock budget for the recursive walk. Defaults to
   * FILES_SEARCH_TIMEOUT_MS so we never block the event loop on large
   * NAS mounts. When the deadline expires the response is returned
   * with `truncated: true` instead of throwing.
   */
  timeoutMs?: number;
};

export async function searchFiles(params: SearchFilesParams): Promise<FileListResponse> {
  return withServerTiming(
    {
      level: "debug",
      layer: "service",
      action: "files.search",
      meta: {
        basePath: params.basePath ?? "",
        includeHidden: Boolean(params.includeHidden),
        limit: params.limit ?? 200,
        queryLength: params.query.trim().length,
      },
      onSuccessMeta: (result) => ({
        entryCount: (result as FileListResponse).entries.length,
      }),
    },
    async () => {
      const includeHidden = Boolean(params.includeHidden);
      const query = params.query.trim().toLowerCase();
      const limit = params.limit ?? 200;
      const timeoutMs = params.timeoutMs ?? serverEnv.FILES_SEARCH_TIMEOUT_MS;
      const deadline = Date.now() + timeoutMs;

      if (query.length === 0) {
        throw new FileServiceError("Search query is empty", {
          code: "invalid_path",
          statusCode: 400,
        });
      }

      let baseResolved: ResolvedFilesPath;
      try {
        baseResolved = await resolveFilePath(params.basePath, includeHidden);
        const info = await lstat(baseResolved.absolutePath);
        if (!info.isDirectory()) {
          throw new FileServiceError("Path is not a directory", {
            code: "not_a_directory",
            statusCode: 400,
          });
        }
      } catch (error) {
        throw mapFsError(error, "Failed to resolve search base path");
      }

      const output: FileListEntry[] = [];
      let truncated = false;

      async function walk(absoluteDir: string, relativeDir: string) {
        if (output.length >= limit) return;
        if (Date.now() > deadline) {
          truncated = true;
          return;
        }

        let dirEntries: { name: string; isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean }[];
        try {
          dirEntries = (await readdir(absoluteDir, { withFileTypes: true })).map((d) => ({
            name: d.name.toString(),
            isDirectory: () => d.isDirectory(),
            isFile: () => d.isFile(),
            isSymbolicLink: () => d.isSymbolicLink(),
          }));
        } catch {
          return;
        }

        for (const entry of dirEntries) {
          if (output.length >= limit) return;
          if (Date.now() > deadline) {
            truncated = true;
            return;
          }

          if (!includeHidden && isHiddenName(entry.name)) continue;
          if (relativeDir === "" && entry.name === "Trash") continue;

          const absoluteEntryPath = path.join(absoluteDir, entry.name);
          let info: Awaited<ReturnType<typeof lstat>>;
          try {
            info = await lstat(absoluteEntryPath);
          } catch {
            continue;
          }
          if (info.isSymbolicLink()) continue;

          const type = info.isDirectory() ? "folder" : info.isFile() ? "file" : null;
          if (!type) continue;

          const entryRelativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;

          if (entry.name.toLowerCase().includes(query)) {
            output.push({
              name: entry.name,
              path: entryRelativePath,
              type,
              ext: type === "file" ? getExtension(entry.name) : null,
              sizeBytes: type === "file" ? info.size : null,
              modifiedAt: info.mtime.toISOString(),
              mtimeMs: info.mtimeMs,
            });
          }

          if (type === "folder") {
            // Yield to the event loop between subdirectory walks so the
            // system metrics SSE heartbeat (and other request handlers)
            // don't starve while we recurse a large tree on a Pi.
            await setImmediatePromise();
            await walk(absoluteEntryPath, entryRelativePath);
          }
        }
      }

      await walk(baseResolved.absolutePath, baseResolved.relativePath);
      output.sort((a, b) => a.name.localeCompare(b.name));

      const response: FileListResponse = {
        root: baseResolved.rootPath,
        cwd: baseResolved.relativePath,
        entries: output,
      };
      if (truncated) {
        response.truncated = true;
      }
      return response;
    },
  );
}

export type UploadFilesParams = {
  /** Relative path of the target directory within FILES_ROOT */
  destinationPath: string;
  files: { name: string; size: number; stream: () => ReadableStream }[];
  includeHidden?: boolean;
};

type UnzipEntryParams = {
  path: string;
  includeHidden?: boolean;
};

export async function unzipEntry(params: UnzipEntryParams): Promise<FileUnzipResponse> {
  return withServerTiming(
    { level: "debug", layer: "service", action: "files.unzipEntry" },
    async () => {
      const includeHidden = Boolean(params.includeHidden);
      let resolved: ResolvedFilesPath;

      try {
        resolved = await resolveFilePath(params.path, includeHidden);
        const info = await lstat(resolved.absolutePath);
        if (info.isSymbolicLink()) {
          throw new FileServiceError("Symlinks are not allowed", {
            code: "symlink_blocked",
            statusCode: 403,
          });
        }
        if (!info.isFile()) {
          throw new FileServiceError("Path is not a file", {
            code: "not_a_file",
            statusCode: 400,
          });
        }
      } catch (error) {
        if (error instanceof FileServiceError) throw error;
        throw mapFsError(error, "Failed to access zip file");
      }

      const fileName = resolved.segments[resolved.segments.length - 1] ?? "";
      if (getExtension(fileName) !== "zip") {
        throw new FileServiceError("File is not a zip archive", {
          code: "unsupported_file",
          statusCode: 400,
        });
      }

      const parentAbsolutePath = path.dirname(resolved.absolutePath);
      const destinationPath =
        resolved.segments.length > 1 ? resolved.segments.slice(0, -1).join("/") : "";

      const execFileAsync = promisify(execFile);

      // Pre-inspection: Ensure archive contains no symlinks, absolute paths, or Zip Slip path traversals
      try {
        const { stdout: zipList } = await execFileAsync("unzip", ["-Z", "-1", resolved.absolutePath]);
        const entries = zipList.split("\n");
        for (const entry of entries) {
          const trimmed = entry.trim();
          if (!trimmed) continue;
          if (
            trimmed.startsWith("/") ||
            trimmed.startsWith("\\") ||
            trimmed.includes("\0") ||
            /^[a-zA-Z]:/.test(trimmed)
          ) {
            throw new FileServiceError("Zip archive contains invalid paths", {
              code: "invalid_path",
              statusCode: 400,
            });
          }
          const segments = trimmed.split(/[/\\]/);
          if (segments.includes("..")) {
            throw new FileServiceError("Zip archive contains path traversal entries", {
              code: "invalid_path",
              statusCode: 400,
            });
          }
          const candidatePath = path.resolve(parentAbsolutePath, trimmed);
          const relative = path.relative(parentAbsolutePath, candidatePath);
          if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
            throw new FileServiceError("Zip archive entries escape destination directory", {
              code: "path_outside_root",
              statusCode: 400,
            });
          }
        }

        // Check for symbolic links in zip details
        const { stdout: zipDetails } = await execFileAsync("unzip", ["-Z", resolved.absolutePath]);
        for (const line of zipDetails.split("\n")) {
          const lineTrimmed = line.trim();
          if (!lineTrimmed) continue;
          if (lineTrimmed.startsWith("l")) {
            throw new FileServiceError("Zip archive contains symbolic links", {
              code: "symlink_blocked",
              statusCode: 403,
            });
          }
        }
      } catch (err) {
        if (err instanceof FileServiceError) throw err;
        throw new FileServiceError("Failed to inspect zip archive", {
          code: "internal_error",
          statusCode: 500,
          cause: err,
        });
      }

      await new Promise<void>((resolve, reject) => {
        const proc = spawn("unzip", ["-o", resolved.absolutePath, "-d", parentAbsolutePath]);
        proc.on("close", (code) => {
          // unzip exit code 1 = warnings only (acceptable)
          if (code === 0 || code === 1) resolve();
          else reject(new FileServiceError(`unzip exited with code ${String(code)}`, { code: "internal_error", statusCode: 500 }));
        });
        proc.on("error", (err) => {
          reject(new FileServiceError("Failed to run unzip", { code: "internal_error", statusCode: 500, cause: err }));
        });
      });

      return { destinationPath };
    },
  );
}

export async function uploadFiles(params: UploadFilesParams): Promise<FileUploadResponse> {
  const includeHidden = Boolean(params.includeHidden);
  const destination = await assertDirectoryPath(params.destinationPath, includeHidden, {
    allowEmpty: true,
  });

  const uploaded: FileUploadResponse["uploaded"] = [];
  const skipped: string[] = [];

  await Promise.all(
    params.files.map(async ({ name, size, stream }) => {
      let safeName: string;
      try {
        safeName = ensureSafeName(name, includeHidden);
      } catch {
        skipped.push(name);
        return;
      }

      const relativePath = joinRelativePath(destination.segments, safeName);
      let targetPath: ResolvedFilesPath;
      try {
        targetPath = await resolveFilePath(relativePath, includeHidden, {
          allowEmpty: false,
          allowMissingLeaf: true,
        });
      } catch {
        skipped.push(name);
        return;
      }

      if (targetPath.exists || (await pathExists(targetPath.absolutePath))) {
        skipped.push(name);
        return;
      }

      await pipeline(
        Readable.fromWeb(stream() as import("stream/web").ReadableStream),
        createWriteStream(targetPath.absolutePath),
      );
      uploaded.push({
        name: safeName,
        path: targetPath.relativePath,
        sizeBytes: size,
      });
    }),
  );

  return { uploaded, skipped };
}
byte
49.253
baris
1.633
Jenis
ts
Commit terakhir
16 Oktober 2026