IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

preferences-service.ts

Buka raw
import "server-only";

import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { memoizeAsync } from "@/lib/server/cache/memoize-async";
import { SYSTEM_TIMEZONE_OPTIONS, type SystemPreferences } from "@/lib/shared/contracts/system";

const ALLOWED_TIMEZONE_SET = new Set<string>(SYSTEM_TIMEZONE_OPTIONS);
const PREFERENCES_CACHE_TTL_MS = 30_000;

function normalizeHostname(input: string) {
  let hostname = input.trim().toLowerCase().replace(/\s+/g, "");
  if (hostname.endsWith(".local")) {
    hostname = hostname.slice(0, -6);
  }
  if (!hostname) {
    hostname = "homeio";
  }
  if (!/^[a-z0-9][a-z0-9.-]{0,62}$/.test(hostname)) {
    throw new Error(
      "Invalid hostname. Use lowercase letters, numbers, hyphens, and dots.",
    );
  }
  return hostname;
}

async function readCommandOutput(command: string, args: string[]) {
  const { stdout } = await execFileAsync(command, args);
  return stdout.trim();
}

function execFileAsync(command: string, args: string[]) {
  return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
    execFile(command, args, (error, stdout = "", stderr = "") => {
      if (error) {
        const execError = error as Error & { stdout?: string; stderr?: string };
        execError.stdout = stdout;
        execError.stderr = stderr;
        reject(execError);
        return;
      }

      resolve({ stdout, stderr });
    });
  });
}

function resolveTimezoneFilePath() {
  return process.env.HOMEIO_TIMEZONE_FILE_PATH ?? "/etc/timezone";
}

function resolveHostsFilePath() {
  return process.env.HOMEIO_HOSTS_FILE_PATH ?? "/etc/hosts";
}

function isCommandUnavailable(error: unknown) {
  if (!(error instanceof Error)) return false;
  const message = error.message.toLowerCase();
  return message.includes("enoent") || message.includes("no such file");
}

function isOperationNotPermitted(error: unknown) {
  if (!(error instanceof Error)) return false;
  const message = error.message.toLowerCase();
  return message.includes("operation not permitted") || message.includes("eperm");
}

function isIgnorableAvahiError(error: unknown) {
  if (!(error instanceof Error)) return false;
  const stdout = (error as Error & { stdout?: string }).stdout ?? "";
  const stderr = (error as Error & { stderr?: string }).stderr ?? "";
  const haystack = `${error.message}\n${stdout}\n${stderr}`.toLowerCase();
  return (
    haystack.includes("avahi-daemon.service could not be found") ||
    haystack.includes("unit avahi-daemon.service not found") ||
    haystack.includes("not found") ||
    haystack.includes("enoent")
  );
}

async function readHostname() {
  try {
    const hostname = await readCommandOutput("hostnamectl", ["--static"]);
    if (hostname.length > 0) return hostname;
  } catch {
    // fallback below
  }

  return readCommandOutput("hostname", []);
}

async function readTimezone() {
  try {
    const timezone = await readCommandOutput("timedatectl", ["show", "--property=Timezone", "--value"]);
    if (timezone.length > 0) return timezone;
  } catch {
    // fallback below
  }

  try {
    const timezone = await readFile(resolveTimezoneFilePath(), "utf8");
    if (timezone.trim().length > 0) return timezone.trim();
  } catch {
    // fallback below
  }

  return "UTC";
}

async function syncHostsFile(hostname: string) {
  let currentHosts = "";
  const hostsFilePath = resolveHostsFilePath();
  try {
    currentHosts = await readFile(hostsFilePath, "utf8");
  } catch {
    currentHosts = "";
  }

  const hostsEntry = hostname.includes(".")
    ? `127.0.1.1 ${hostname}`
    : `127.0.1.1 ${hostname} ${hostname}.local`;

  let nextHosts: string;
  if (/^127\.0\.1\.1/m.test(currentHosts)) {
    nextHosts = currentHosts.replace(/^127\.0\.1\.1.*$/m, hostsEntry);
  } else {
    nextHosts = `${currentHosts.trimEnd()}\n${hostsEntry}\n`;

  }

  try {
    await writeFile(hostsFilePath, nextHosts, "utf8");
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "EACCES") throw error;
    // /etc/hosts is root-owned in containers — skip silently
  }
}

async function restartAvahiDaemon() {
  try {
    await execFileAsync("systemctl", ["restart", "avahi-daemon"]);
  } catch (error) {
    if (isIgnorableAvahiError(error)) {
      return;
    }
    throw error;
  }
}

async function readSystemPreferences(): Promise<SystemPreferences> {
  const [hostname, timezone] = await Promise.all([readHostname(), readTimezone()]);
  return { hostname, timezone };
}

// Settings reads these on every open, and each read spawns hostnamectl and
// timedatectl. They change through updateSystemPreferences, which drops the
// cache, or at the shell, which the TTL catches up with.
const cachedPreferences = memoizeAsync(readSystemPreferences, PREFERENCES_CACHE_TTL_MS);

export async function getSystemPreferences(): Promise<SystemPreferences> {
  return { ...(await cachedPreferences()) };
}

export function resetPreferencesCacheForTests() {
  cachedPreferences.invalidate();
}

export async function updateSystemPreferences(input: SystemPreferences) {
  try {
    await applySystemPreferences(input);
  } finally {
    cachedPreferences.invalidate();
  }
  return getSystemPreferences();
}

async function applySystemPreferences(input: SystemPreferences) {
  const hostname = normalizeHostname(input.hostname);
  // Compare with the system as it is now, not as it was cached.
  const current = await readSystemPreferences();
  const timezone = input.timezone.trim();

  if (!ALLOWED_TIMEZONE_SET.has(timezone) && timezone !== current.timezone) {
    throw new Error("Invalid timezone. Choose one of the supported timezone options.");
  }

  if (hostname !== current.hostname) {
    try {
      await execFileAsync("hostnamectl", ["set-hostname", hostname]);
    } catch (error) {
      if (!isCommandUnavailable(error)) throw error;
      try {
        await execFileAsync("hostname", [hostname]);
      } catch (fallbackError) {
        if (!isOperationNotPermitted(fallbackError)) throw fallbackError;
        // container without SYS_ADMIN — system hostname unchanged, hosts file still updated below
      }
    }
    await syncHostsFile(hostname);
    await restartAvahiDaemon();
  }

  if (timezone !== current.timezone) {
    try {
      await execFileAsync("timedatectl", ["set-timezone", timezone]);
    } catch (error) {
      if (!isCommandUnavailable(error)) throw error;
      try {
        await writeFile(resolveTimezoneFilePath(), `${timezone}\n`, "utf8");
      } catch (writeError) {
        if ((writeError as NodeJS.ErrnoException).code !== "EACCES") throw writeError;
        // /etc/timezone is root-owned in containers — skip silently
      }
    }
  }
}
byte
6.735
baris
217
Jenis
ts
Commit terakhir
16 Oktober 2026