IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

network-storage.ts

Buka raw
import "server-only";

import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { lstat, mkdir, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { serverEnv } from "@/lib/server/env";
import { logServerAction } from "@/lib/server/logging/logger";
import {
  FilesPathError,
  resolvePathWithinFilesRoot,
} from "@/lib/server/modules/files/path-resolver";
import {
  deleteNetworkShareFromDb,
  getNetworkShareByMountPathFromDb,
  getNetworkShareFromDb,
  insertNetworkShareInDb,
  listNetworkSharesFromDb,
  touchNetworkShareInDb,
  type NetworkShareRecord,
} from "@/lib/server/modules/files/network-shares-repository";
import { decryptSecret, encryptSecret } from "@/lib/server/modules/files/secrets";
import type {
  CreateNetworkShareRequest,
  DiscoverServersResponse,
  DiscoverSharesResponse,
  FileServiceErrorCode,
  NetworkShare,
  NetworkShareStatus,
} from "@/lib/shared/contracts/files";

const execFileAsync = promisify(execFile);

const WATCH_INTERVAL_MS = 60_000;

let watcherTimer: NodeJS.Timeout | null = null;
let watcherRunning = false;
let watcherTickInFlight = false;

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

type ResolvedMountPath = {
  rootPath: string;
  relativePath: string;
  absolutePath: string;
};

function sanitizeSegment(input: string, fallback: string) {
  const sanitized = input
    .trim()
    .replace(/[^a-zA-Z0-9\-\.\' \(\)_]/g, "")
    .replace(/\s+/g, " ")
    .trim();

  return sanitized.length > 0 ? sanitized : fallback;
}

async function resolveMountPath(mountPath: string): Promise<ResolvedMountPath> {
  try {
    const resolved = await resolvePathWithinFilesRoot({
      inputPath: mountPath,
      requiredPrefix: "Network",
      allowHiddenSegments: false,
      allowMissingLeaf: true,
    });

    return {
      rootPath: resolved.rootPath,
      relativePath: resolved.relativePath,
      absolutePath: resolved.absolutePath,
    };
  } catch (error) {
    if (error instanceof FilesPathError) {
      throw new NetworkStorageError(error.message, {
        code: error.code,
        statusCode: error.statusCode,
        cause: error,
      });
    }
    throw error;
  }
}

function mountPathForShare(host: string, share: string) {
  const hostSegment = sanitizeSegment(host, "host");
  const shareSegment = sanitizeSegment(share, "share");
  return path.posix.join("Network", hostSegment, shareSegment);
}

function resolveMountIdentity() {
  const processWithIds = process as NodeJS.Process & {
    geteuid?: () => number;
    getegid?: () => number;
  };

  const uid = serverEnv.FILES_NETWORK_MOUNT_UID
    ?? (typeof processWithIds.geteuid === "function" ? processWithIds.geteuid() : 0);
  const gid = serverEnv.FILES_NETWORK_MOUNT_GID
    ?? (typeof processWithIds.getegid === "function" ? processWithIds.getegid() : 0);

  return {
    uid,
    gid,
  };
}


function mapCommandError(
  error: unknown,
  fallbackMessage: string,
  code: FileServiceErrorCode,
) {
  if (error instanceof NetworkStorageError) return error;

  return new NetworkStorageError(fallbackMessage, {
    code,
    statusCode: code === "share_not_found" ? 404 : 500,
    cause: error,
  });
}

function isCommandUnavailable(error: unknown) {
  if (!error || typeof error !== "object") return false;

  const err = error as {
    code?: string;
    message?: string;
    stderr?: string;
  };

  if (err.code === "ENOENT") return true;

  const combined = `${err.message ?? ""}\n${err.stderr ?? ""}`.toLowerCase();
  return (
    combined.includes("not found") ||
    combined.includes("no such file or directory")
  );
}

function errorText(error: unknown) {
  if (!error || typeof error !== "object") return "";
  const err = error as {
    message?: string;
    stderr?: string | Buffer;
  };
  const stderr =
    typeof err.stderr === "string"
      ? err.stderr
      : Buffer.isBuffer(err.stderr)
        ? err.stderr.toString("utf8")
        : "";
  return `${err.message ?? ""}\n${stderr}`.toLowerCase();
}

function isUnmountMissingError(error: unknown) {
  const text = errorText(error);
  return (
    text.includes("not mounted") ||
    text.includes("is not mounted") ||
    text.includes("not found")
  );
}

async function runCommand(command: string, args: string[]) {
  const result = await execFileAsync(command, args, {
    timeout: 15_000,
    maxBuffer: 1024 * 1024,
  });

  if (typeof result === "string" || Buffer.isBuffer(result)) {
    return {
      stdout: result,
      stderr: "",
    };
  }

  if (result && typeof result === "object" && "stdout" in result) {
    const castResult = result as { stdout?: string | Buffer; stderr?: string | Buffer };
    return {
      stdout: castResult.stdout ?? "",
      stderr: castResult.stderr ?? "",
    };
  }

  return {
    stdout: "",
    stderr: "",
  };
}

async function isMounted(mountPath: string) {
  const resolved = await resolveMountPath(mountPath);

  try {
    await runCommand("mountpoint", ["-q", resolved.absolutePath]);
    return true;
  } catch {
    return false;
  }
}

async function isShareActive(record: NetworkShareRecord) {
  try {
    return await isMounted(record.mountPath);
  } catch (error) {
    const mappedError =
      error instanceof NetworkStorageError
        ? error
        : mapCommandError(error, "Failed to resolve network share state", "internal_error");

    if (
      mappedError.code === "not_found" ||
      mappedError.code === "invalid_path" ||
      mappedError.code === "path_outside_root"
    ) {
      return false;
    }

    logServerAction({
      level: "warn",
      layer: "service",
      action: "files.network.shares.remove.state",
      status: "error",
      message:
        "Unable to verify whether network share is still mounted; continuing cleanup",
      meta: {
        shareId: record.id,
        mountPath: record.mountPath,
      },
      error,
    });

    return false;
  }
}

function toPublicShare(record: NetworkShareRecord): NetworkShare {
  return {
    id: record.id,
    host: record.host,
    share: record.share,
    username: record.username,
    mountPath: record.mountPath,
  };
}

function toShareStatus(record: NetworkShareRecord, mounted: boolean): NetworkShareStatus {
  return {
    ...toPublicShare(record),
    isMounted: mounted,
  };
}

async function mountShareRecord(record: NetworkShareRecord) {
  const resolved = await resolveMountPath(record.mountPath);
  await mkdir(resolved.absolutePath, {
    recursive: true,
  });

  const alreadyMounted = await isMounted(record.mountPath);
  if (alreadyMounted) {
    return;
  }

  const password = decryptSecret({
    ciphertext: record.passwordCiphertext,
    iv: record.passwordIv,
    tag: record.passwordTag,
  });

  const smbPath = `//${record.host}/${record.share}`;
  const identity = resolveMountIdentity();

  // Write credentials to a temporary file (mode 600) instead of embedding
  // them in the mount command line.  On Linux, /proc/PID/cmdline is readable
  // by all local users, so passing password= as a CLI arg leaks the secret.
  const credsPath = `/tmp/homeio-mount-${randomUUID()}.creds`;
  try {
    await writeFile(
      credsPath,
      `username=${record.username}\npassword=${password}\n`,
      { mode: 0o600 },
    );

    const mountOptions = [
      `credentials=${credsPath}`,
      `uid=${identity.uid}`,
      `gid=${identity.gid}`,
      "iocharset=utf8",
    ].join(",");

    await runCommand("mount", [
      "-t",
      "cifs",
      smbPath,
      resolved.absolutePath,
      "-o",
      mountOptions,
    ]);
  } finally {
    // Always remove the credentials file — whether mount succeeded or failed.
    await rm(credsPath, { force: true }).catch(() => undefined);
  }
}

async function unmountShareRecord(
  record: NetworkShareRecord,
  options?: {
    tolerateMissing?: boolean;
  },
) {
  const tolerateMissing = options?.tolerateMissing ?? false;
  let resolved: ResolvedMountPath;

  try {
    resolved = await resolveMountPath(record.mountPath);
  } catch (error) {
    const mappedError =
      error instanceof NetworkStorageError
        ? error
        : mapCommandError(error, "Failed to unmount network share", "unmount_failed");
    if (
      tolerateMissing &&
      (mappedError.code === "not_found" ||
        mappedError.code === "invalid_path" ||
        mappedError.code === "path_outside_root")
    ) {
      return;
    }
    throw mappedError;
  }
  const mounted = await isMounted(record.mountPath);

  if (mounted) {
    try {
      await runCommand("umount", [resolved.absolutePath]);
    } catch (error) {
      if (!isUnmountMissingError(error)) {
        throw error;
      }
    }
  }

  try {
    await rm(resolved.absolutePath, {
      recursive: true,
      force: true,
    });
  } catch (error) {
    const nodeError = error as NodeJS.ErrnoException;
    if (
      !tolerateMissing ||
      (nodeError?.code !== "ENOENT" && nodeError?.code !== "ENOTDIR")
    ) {
      throw error;
    }
  }

  const hostDirectory = path.dirname(resolved.absolutePath);
  try {
    const entries = await readdir(hostDirectory);
    if (entries.length === 0) {
      await rm(hostDirectory, {
        recursive: false,
        force: true,
      });
    }
  } catch {
    // ignored
  }
}

async function runWatcherTick() {
  if (watcherTickInFlight) return;
  watcherTickInFlight = true;

  try {
    const shares = await listNetworkSharesFromDb();
    await Promise.all(
      shares.map(async (share) => {
        try {
          const mounted = await isMounted(share.mountPath);
          if (!mounted) {
            await mountShareRecord(share);
            await touchNetworkShareInDb(share.id);
          }
        } catch (error) {
          logServerAction({
            level: "warn",
            layer: "service",
            action: "files.network.watch.mount",
            status: "error",
            message: "Failed to mount network share during watcher tick",
            meta: {
              shareId: share.id,
              mountPath: share.mountPath,
            },
            error,
          });
        }
      }),
    );
  } finally {
    watcherTickInFlight = false;
  }
}

export function startNetworkStorageWatcher() {
  if (watcherRunning) return;

  watcherRunning = true;
  watcherTimer = setInterval(() => {
    void runWatcherTick();
  }, WATCH_INTERVAL_MS);
  watcherTimer.unref?.();
  void runWatcherTick();
}

export async function stopNetworkStorageWatcher() {
  watcherRunning = false;

  if (watcherTimer) {
    clearInterval(watcherTimer);
    watcherTimer = null;
  }
}

export async function getShareInfo(): Promise<NetworkShareStatus[]> {
  const shares = await listNetworkSharesFromDb();

  const statuses = await Promise.all(
    shares.map(async (share) => {
      let mounted = false;
      try {
        mounted = await isMounted(share.mountPath);
      } catch (error) {
        logServerAction({
          level: "warn",
          layer: "service",
          action: "files.network.shares.get.status",
          status: "error",
          message: "Failed to resolve network share mount status",
          meta: {
            shareId: share.id,
            mountPath: share.mountPath,
          },
          error,
        });
      }
      return toShareStatus(share, mounted);
    }),
  );

  return statuses;
}

export async function addShare(input: CreateNetworkShareRequest) {
  const host = input.host.trim();
  const share = input.share.trim();
  const username = input.username.trim();
  const password = input.password;

  if (!host || !share || !username || !password) {
    throw new NetworkStorageError("Invalid share payload", {
      code: "invalid_path",
      statusCode: 400,
    });
  }

  const mountPath = mountPathForShare(host, share);
  const existing = await getNetworkShareByMountPathFromDb(mountPath);
  if (existing) {
    throw new NetworkStorageError("Network share already exists", {
      code: "share_exists",
      statusCode: 409,
    });
  }

  const encrypted = encryptSecret(password);
  const created = await insertNetworkShareInDb({
    id: randomUUID(),
    host,
    share,
    username,
    mountPath,
    passwordCiphertext: encrypted.ciphertext,
    passwordIv: encrypted.iv,
    passwordTag: encrypted.tag,
  });

  try {
    await mountShareRecord(created);
    await touchNetworkShareInDb(created.id);

    return {
      ...toPublicShare(created),
      isMounted: true,
    } satisfies NetworkShareStatus;
  } catch (error) {
    try {
      await deleteNetworkShareFromDb(created.id);
    } catch (rollbackError) {
      throw new NetworkStorageError(
        "Failed to mount network share and rollback reservation",
        {
          code: "internal_error",
          statusCode: 500,
          cause: {
            error,
            rollbackError,
          },
        },
      );
    }
    throw mapCommandError(error, "Failed to mount network share", "mount_failed");
  }
}

export async function removeShare(shareId: string) {
  const share = await getNetworkShareFromDb(shareId);
  if (!share) {
    throw new NetworkStorageError("Network share not found", {
      code: "share_not_found",
      statusCode: 404,
    });
  }

  try {
    await unmountShareRecord(share, {
      tolerateMissing: true,
    });
  } catch (error) {
    const mappedError = mapCommandError(
      error,
      "Failed to unmount network share",
      "unmount_failed",
    );
    const tolerateCleanupError =
      mappedError.code === "not_found" ||
      mappedError.code === "invalid_path" ||
      mappedError.code === "path_outside_root";

    if (!tolerateCleanupError) {
      const stillMounted = await isShareActive(share);
      if (stillMounted) {
        throw mappedError;
      }
    }

    logServerAction({
      level: "warn",
      layer: "service",
      action: "files.network.shares.remove.cleanup",
      status: "error",
      message: "Network share cleanup was already complete; removing stale DB record",
      meta: {
        shareId: share.id,
        mountPath: share.mountPath,
      },
      error,
    });
  }

  await deleteNetworkShareFromDb(share.id);
  return {
    removed: true,
    id: share.id,
  };
}

export async function mountShare(shareId: string) {
  const share = await getNetworkShareFromDb(shareId);
  if (!share) {
    throw new NetworkStorageError("Network share not found", {
      code: "share_not_found",
      statusCode: 404,
    });
  }

  try {
    await mountShareRecord(share);
    const updated = (await touchNetworkShareInDb(share.id)) ?? share;
    return toShareStatus(updated, true);
  } catch (error) {
    throw mapCommandError(error, "Failed to mount network share", "mount_failed");
  }
}

export async function unmountShare(shareId: string) {
  const share = await getNetworkShareFromDb(shareId);
  if (!share) {
    throw new NetworkStorageError("Network share not found", {
      code: "share_not_found",
      statusCode: 404,
    });
  }

  try {
    await unmountShareRecord(share);
    const updated = (await touchNetworkShareInDb(share.id)) ?? share;
    return toShareStatus(updated, false);
  } catch (error) {
    throw mapCommandError(error, "Failed to unmount network share", "unmount_failed");
  }
}

export async function discoverServers(): Promise<DiscoverServersResponse> {
  try {
    const { stdout } = await runCommand("avahi-browse", [
      "--resolve",
      "--terminate",
      "_smb._tcp",
      "--parsable",
    ]);

    const output =
      typeof stdout === "string"
        ? stdout
        : Buffer.isBuffer(stdout)
          ? stdout.toString("utf8")
          : "";
    const servers = output
      .split("\n")
      .map((line) => line.split(";")[6]?.trim() ?? "")
      .filter((value) => value.length > 0);

    return {
      servers: Array.from(new Set(servers)).sort((a, b) =>
        a.localeCompare(b),
      ),
    };
  } catch (error) {
    if (isCommandUnavailable(error)) {
      logServerAction({
        level: "warn",
        layer: "service",
        action: "files.network.discover.servers.unavailable",
        status: "error",
        message: "SMB server discovery command is unavailable",
        error,
      });
      return { servers: [] };
    }

    throw mapCommandError(error, "Failed to discover SMB servers", "internal_error");
  }
}

export async function discoverShares(input: {
  host: string;
  username: string;
  password: string;
}): Promise<DiscoverSharesResponse> {
  const host = input.host.trim();
  const username = input.username.trim();
  const password = input.password;

  if (!host || !username || !password) {
    throw new NetworkStorageError("Invalid share discovery payload", {
      code: "invalid_path",
      statusCode: 400,
    });
  }

  try {
    const { stdout } = await runCommand("smbclient", [
      "--list",
      `//${host}`,
      "--user",
      username,
      "--password",
      password,
      "--grepable",
    ]);

    const output =
      typeof stdout === "string"
        ? stdout
        : Buffer.isBuffer(stdout)
          ? stdout.toString("utf8")
          : "";
    const shares = output
      .split("\n")
      .map((line) => line.trim())
      .filter((line) => line.length > 0)
      .filter((line) => line.split("|").length === 3)
      .map((line) => line.split("|")[1]?.trim() ?? "")
      .filter((shareName) => shareName.length > 0 && shareName !== "IPC$");

    return {
      shares: Array.from(new Set(shares)).sort((a, b) =>
        a.localeCompare(b),
      ),
    };
  } catch (error) {
    if (isCommandUnavailable(error)) {
      logServerAction({
        level: "warn",
        layer: "service",
        action: "files.network.discover.shares.unavailable",
        status: "error",
        message: "SMB share discovery command is unavailable",
        meta: { host },
        error,
      });
      return { shares: [] };
    }

    throw mapCommandError(
      error,
      "Failed to discover SMB shares for host",
      "internal_error",
    );
  }
}

export async function isShareMounted(shareId: string) {
  const share = await getNetworkShareFromDb(shareId);
  if (!share) {
    throw new NetworkStorageError("Network share not found", {
      code: "share_not_found",
      statusCode: 404,
    });
  }

  const mounted = await isMounted(share.mountPath);
  return toShareStatus(share, mounted);
}

export async function assertMountPathIsSafe(mountPath: string) {
  const resolved = await resolveMountPath(mountPath);
  const info = await lstat(resolved.absolutePath).catch(() => null);
  if (info?.isSymbolicLink()) {
    throw new NetworkStorageError("Symlinks are not allowed", {
      code: "symlink_blocked",
      statusCode: 403,
    });
  }

  return resolved;
}
byte
19.252
baris
762
Jenis
ts
Commit terakhir
16 Oktober 2026