IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

trash-service.ts

Buka raw
import "server-only";

import { randomUUID } from "node:crypto";
import { cp, lstat, mkdir, readdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import {
  FilesPathError,
  resolvePathWithinFilesRoot,
  type ResolvedFilesPath,
} from "@/lib/server/modules/files/path-resolver";
import type {
  FileServiceErrorCode,
  TrashRestoreRequest,
  TrashRestoreResponse,
} from "@/lib/shared/contracts/files";
import {
  deleteAllTrashEntriesFromDb,
  deleteTrashEntryFromDb,
  getTrashEntryFromDb,
  upsertTrashEntryInDb,
} from "@/lib/server/modules/files/trash-repository";

export class TrashServiceError 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 = "TrashServiceError";
    this.code = options?.code ?? "internal_error";
    this.statusCode = options?.statusCode ?? 500;
  }
}

type CollisionMode = NonNullable<TrashRestoreRequest["collision"]>;

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

async function resolvePath(
  inputPath: string,
  options: {
    allowMissingLeaf?: boolean;
  } = {},
) {
  const resolved = await resolvePathWithinFilesRoot({
    inputPath,
    allowHiddenSegments: true,
    allowMissingLeaf: options.allowMissingLeaf ?? false,
  });
  const segments = resolved.segments;
  if (segments.some((segment) => isHiddenName(segment))) {
    throw new TrashServiceError("Hidden files are not allowed", {
      code: "hidden_blocked",
      statusCode: 403,
    });
  }

  return {
    rootPath: resolved.rootPath,
    relativePath: resolved.relativePath,
    absolutePath: resolved.absolutePath,
    segments,
    exists: resolved.exists,
  } satisfies ResolvedFilesPath;
}

function toPosixRelative(rootPath: string, absolutePath: string) {
  return path.relative(rootPath, absolutePath).replaceAll(path.sep, "/");
}

function splitNameAndExtension(name: string) {
  const ext = path.extname(name);
  if (!ext) {
    return {
      base: name,
      extension: "",
    };
  }

  return {
    base: name.slice(0, -ext.length),
    extension: ext,
  };
}

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 uniquePathFromTarget(targetAbsolutePath: string) {
  if (!(await pathExists(targetAbsolutePath))) {
    return targetAbsolutePath;
  }

  const directory = path.dirname(targetAbsolutePath);
  const name = path.basename(targetAbsolutePath);
  const { base, extension } = splitNameAndExtension(name);

  for (let index = 2; index <= 10_000; index += 1) {
    const candidate = path.join(
      directory,
      `${base} (${index})${extension}`,
    );
    if (!(await pathExists(candidate))) {
      return candidate;
    }
  }

  throw new TrashServiceError("Unable to resolve unique restore path", {
    code: "internal_error",
    statusCode: 500,
  });
}

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

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

async function assertMutationTargetSafe(rootPath: string, relativePath: string) {
  await resolvePathWithinFilesRoot({
    inputPath: relativePath,
    allowHiddenSegments: true,
    allowMissingLeaf: true,
  });
  const absolutePath = path.join(rootPath, relativePath.replaceAll("/", path.sep));
  return absolutePath;
}

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

  const nodeError = error as NodeJS.ErrnoException;
  if (nodeError?.code === "ENOENT") {
    return new TrashServiceError("File or directory not found", {
      code: "not_found",
      statusCode: 404,
      cause: error,
    });
  }
  if (nodeError?.code === "EEXIST") {
    return new TrashServiceError("Destination already exists", {
      code: "destination_exists",
      statusCode: 409,
      cause: error,
    });
  }
  if (nodeError?.code === "ENOTDIR") {
    return new TrashServiceError("Path is not a directory", {
      code: "not_a_directory",
      statusCode: 400,
      cause: error,
    });
  }
  if (nodeError?.code === "EISDIR") {
    return new TrashServiceError("Path is not a file", {
      code: "not_a_file",
      statusCode: 400,
      cause: error,
    });
  }
  if (nodeError?.code === "EACCES" || nodeError?.code === "EPERM") {
    const deniedPath =
      typeof nodeError.path === "string" ? nodeError.path : undefined;
    return new TrashServiceError(
      `Permission denied${deniedPath ? `: ${deniedPath}` : ""}`,
      {
        code: "permission_denied",
        statusCode: 403,
        cause: error,
      },
    );
  }

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

async function assertTrashPath(pathInput: string) {
  const resolved = await resolvePath(pathInput);
  if (
    resolved.relativePath !== "Trash" &&
    !resolved.relativePath.startsWith("Trash/")
  ) {
    throw new TrashServiceError("Path is not in Trash", {
      code: "invalid_path",
      statusCode: 400,
    });
  }
  return resolved;
}

async function pruneEmptyAncestors(basePath: string, absolutePath: string) {
  let current = path.dirname(absolutePath);
  const boundary = path.resolve(basePath);

  while (current.startsWith(boundary) && current !== boundary) {
    try {
      const files = await readdir(current);
      if (files.length > 0) return;
      await rm(current, {
        recursive: false,
      });
      current = path.dirname(current);
    } catch (error) {
      const nodeError = error as NodeJS.ErrnoException;
      if (
        nodeError?.code === "ENOTEMPTY" ||
        nodeError?.code === "ENOENT" ||
        nodeError?.code === "ENOTDIR"
      ) {
        return;
      }

      return;
    }
  }
}

export async function moveToTrash(pathInput: string) {
  let source: ResolvedFilesPath;

  try {
    source = await resolvePath(pathInput);
    if (
      source.relativePath === "Trash" ||
      source.relativePath.startsWith("Trash/")
    ) {
      throw new TrashServiceError("Cannot trash items already in Trash", {
        code: "invalid_path",
        statusCode: 400,
      });
    }

    const sourceInfo = await lstat(source.absolutePath);
    if (sourceInfo.isSymbolicLink()) {
      throw new TrashServiceError("Symlinks are not allowed", {
        code: "symlink_blocked",
        statusCode: 403,
      });
    }

    const trashRoot = await resolvePath("Trash");
    const trashTarget = await uniquePathFromTarget(
      path.join(trashRoot.absolutePath, path.basename(source.absolutePath)),
    );
    const sourceCheck = await resolvePath(source.relativePath);
    if (sourceCheck.absolutePath !== source.absolutePath) {
      throw new TrashServiceError("Source path changed during operation", {
        code: "invalid_path",
        statusCode: 409,
      });
    }
    const trashRelativeTarget = toPosixRelative(source.rootPath, trashTarget);
    await assertMutationTargetSafe(source.rootPath, trashRelativeTarget);

    await movePath(source.absolutePath, trashTarget);

    // Use the public virtual path (e.g. "Trash/name") rather than the
    // filesystem-relative path (e.g. ".Trash/name") so that DB lookups in
    // restoreFromTrash and deleteFromTrash — which use trashPath.relativePath
    // from the path resolver — always match.
    const trashBasename = path.basename(trashTarget);
    const trashPath = `${trashRoot.relativePath}/${trashBasename}`;
    await upsertTrashEntryInDb({
      id: randomUUID(),
      trashPath,
      originalPath: source.relativePath,
      deletedAt: new Date(),
    });

    return {
      trashPath,
      originalPath: source.relativePath,
    };
  } catch (error) {
    throw mapFsError(error, "Failed to move item to Trash");
  }
}

export async function restoreFromTrash(
  pathInput: string,
  collisionMode: CollisionMode = "keep-both",
): Promise<TrashRestoreResponse> {
  let trashPath: ResolvedFilesPath;

  try {
    trashPath = await assertTrashPath(pathInput);
    const sourceInfo = await lstat(trashPath.absolutePath);
    if (sourceInfo.isSymbolicLink()) {
      throw new TrashServiceError("Symlinks are not allowed", {
        code: "symlink_blocked",
        statusCode: 403,
      });
    }

    const entry = await getTrashEntryFromDb(trashPath.relativePath);
    if (!entry) {
      throw new TrashServiceError("Trash metadata missing", {
        code: "trash_meta_missing",
        statusCode: 404,
      });
    }

    const restoreTarget = await resolvePath(entry.originalPath, {
      allowMissingLeaf: true,
    });
    await mkdir(path.dirname(restoreTarget.absolutePath), {
      recursive: true,
    });

    let destinationAbsolutePath = restoreTarget.absolutePath;
    const destinationExists = await pathExists(destinationAbsolutePath);
    if (destinationExists) {
      if (collisionMode === "fail") {
        throw new TrashServiceError("Destination already exists", {
          code: "destination_exists",
          statusCode: 409,
        });
      }
      if (collisionMode === "replace") {
        const destinationRelativePath = toPosixRelative(
          trashPath.rootPath,
          destinationAbsolutePath,
        );
        await assertMutationTargetSafe(
          trashPath.rootPath,
          destinationRelativePath,
        );
        await rm(destinationAbsolutePath, {
          recursive: true,
          force: true,
        });
      }
      if (collisionMode === "keep-both") {
        destinationAbsolutePath = await uniquePathFromTarget(destinationAbsolutePath);
      }
    }

    let sourceAbsolutePath = trashPath.absolutePath;
    try {
      const sourceCheck = await resolvePath(trashPath.relativePath);
      if (sourceCheck.absolutePath !== trashPath.absolutePath) {
        throw new TrashServiceError("Source path changed during operation", {
          code: "invalid_path",
          statusCode: 409,
        });
      }
      sourceAbsolutePath = sourceCheck.absolutePath;
    } catch (error) {
      const mappedError = mapFsError(error, "Failed to restore item from Trash");
      if (mappedError.code !== "not_found") {
        throw mappedError;
      }

      // Treat resolver races as recoverable when the source still exists at the
      // already-validated path from the initial Trash lookup.
      const sourceStillExists = await pathExists(trashPath.absolutePath);
      if (!sourceStillExists) {
        throw mappedError;
      }
    }
    const destinationRelativePath = toPosixRelative(
      trashPath.rootPath,
      destinationAbsolutePath,
    );
    await assertMutationTargetSafe(trashPath.rootPath, destinationRelativePath);

    await movePath(sourceAbsolutePath, destinationAbsolutePath);
    await deleteTrashEntryFromDb(trashPath.relativePath);
    const trashRoot = await assertTrashPath("Trash");
    await pruneEmptyAncestors(trashRoot.absolutePath, trashPath.absolutePath);

    return {
      restoredPath: toPosixRelative(trashPath.rootPath, destinationAbsolutePath),
      sourceTrashPath: trashPath.relativePath,
    };
  } catch (error) {
    throw mapFsError(error, "Failed to restore item from Trash");
  }
}

export async function deleteFromTrash(pathInput: string) {
  let trashPath: ResolvedFilesPath;

  try {
    trashPath = await assertTrashPath(pathInput);
    const sourceInfo = await lstat(trashPath.absolutePath);
    if (sourceInfo.isSymbolicLink()) {
      throw new TrashServiceError("Symlinks are not allowed", {
        code: "symlink_blocked",
        statusCode: 403,
      });
    }

    await assertMutationTargetSafe(trashPath.rootPath, trashPath.relativePath);
    await rm(trashPath.absolutePath, {
      recursive: true,
      force: false,
    });
    await deleteTrashEntryFromDb(trashPath.relativePath);
    const trashRoot = await assertTrashPath("Trash");
    await pruneEmptyAncestors(trashRoot.absolutePath, trashPath.absolutePath);

    return {
      deleted: true,
      path: trashPath.relativePath,
    };
  } catch (error) {
    throw mapFsError(error, "Failed to permanently delete item from Trash");
  }
}

export async function emptyTrash() {
  let trashRoot: ResolvedFilesPath;

  try {
    trashRoot = await assertTrashPath("Trash");
    const entries = await readdir(trashRoot.absolutePath);

    for (const name of entries) {
      const absolutePath = path.join(trashRoot.absolutePath, name);
      await rm(absolutePath, {
        recursive: true,
        force: true,
      });
    }

    const removed = await deleteAllTrashEntriesFromDb();

    return {
      deletedCount: removed.length,
    };
  } catch (error) {
    throw mapFsError(error, "Failed to empty Trash");
  }
}
byte
13.618
baris
485
Jenis
ts
Commit terakhir
16 Oktober 2026