diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 5528c0b..65bb89f 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -13,6 +13,7 @@ export type ListVolumesResponses = { */ 200: { volumes: Array<{ + autoRemount: boolean; config: | { backend: "directory"; @@ -155,6 +156,7 @@ export type GetVolumeResponses = { * Volume details */ 200: { + autoRemount: boolean; config: | { backend: "directory"; diff --git a/apps/client/app/components/create-volume-dialog.tsx b/apps/client/app/components/create-volume-dialog.tsx index 2435b0f..afc5ec2 100644 --- a/apps/client/app/components/create-volume-dialog.tsx +++ b/apps/client/app/components/create-volume-dialog.tsx @@ -41,7 +41,7 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => { diff --git a/apps/client/app/components/create-volume-form.tsx b/apps/client/app/components/create-volume-form.tsx index a6d0861..f343ac1 100644 --- a/apps/client/app/components/create-volume-form.tsx +++ b/apps/client/app/components/create-volume-form.tsx @@ -30,6 +30,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for resolver: arktypeResolver(formSchema), defaultValues: initialValues, }); + const { watch, getValues } = form; const watchedBackend = watch("backend"); @@ -40,6 +41,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for const testBackendConnection = useMutation({ ...testConnectionMutation(), onMutate: () => { + setTestMessage(""); setTestStatus("loading"); }, onError: () => { diff --git a/apps/client/app/components/ui/button.tsx b/apps/client/app/components/ui/button.tsx index 38be944..8acc771 100644 --- a/apps/client/app/components/ui/button.tsx +++ b/apps/client/app/components/ui/button.tsx @@ -1,5 +1,6 @@ import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; +import { Loader2 } from "lucide-react"; import type * as React from "react"; import { cn } from "~/lib/utils"; @@ -9,16 +10,13 @@ const buttonVariants = cva( { variants: { variant: { - default: - "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { @@ -44,15 +42,19 @@ function Button({ }: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean; - }) { + } & { loading?: boolean }) { const Comp = asChild ? Slot : "button"; return ( + > + +
{props.children}
+
); } diff --git a/apps/client/app/components/ui/form.tsx b/apps/client/app/components/ui/form.tsx index ff375f9..b541091 100644 --- a/apps/client/app/components/ui/form.tsx +++ b/apps/client/app/components/ui/form.tsx @@ -1,165 +1,136 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { Slot } from "@radix-ui/react-slot" +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; import { - Controller, - FormProvider, - useFormContext, - useFormState, - type ControllerProps, - type FieldPath, - type FieldValues, -} from "react-hook-form" + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; -import { cn } from "~/lib/utils" -import { Label } from "~/components/ui/label" +import { cn } from "~/lib/utils"; +import { Label } from "~/components/ui/label"; -const Form = FormProvider +const Form = FormProvider; type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, > = { - name: TName -} + name: TName; +}; -const FormFieldContext = React.createContext( - {} as FormFieldContextValue -) +const FormFieldContext = React.createContext({} as FormFieldContextValue); const FormField = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, >({ - ...props + ...props }: ControllerProps) => { - return ( - - - - ) -} + return ( + + + + ); +}; const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext) - const itemContext = React.useContext(FormItemContext) - const { getFieldState } = useFormContext() - const formState = useFormState({ name: fieldContext.name }) - const fieldState = getFieldState(fieldContext.name, formState) + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); - if (!fieldContext) { - throw new Error("useFormField should be used within ") - } + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } - const { id } = itemContext + const { id } = itemContext; - return { - id, - name: fieldContext.name, - formItemId: `${id}-form-item`, - formDescriptionId: `${id}-form-item-description`, - formMessageId: `${id}-form-item-message`, - ...fieldState, - } -} + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; type FormItemContextValue = { - id: string -} + id: string; +}; -const FormItemContext = React.createContext( - {} as FormItemContextValue -) +const FormItemContext = React.createContext({} as FormItemContextValue); function FormItem({ className, ...props }: React.ComponentProps<"div">) { - const id = React.useId() + const id = React.useId(); - return ( - -
- - ) + return ( + +
+ + ); } -function FormLabel({ - className, - ...props -}: React.ComponentProps) { - const { error, formItemId } = useFormField() +function FormLabel({ className, ...props }: React.ComponentProps) { + const { error, formItemId } = useFormField(); - return ( -
diff --git a/apps/client/app/routes/details.tsx b/apps/client/app/routes/details.tsx index ea9db0e..a4f3694 100644 --- a/apps/client/app/routes/details.tsx +++ b/apps/client/app/routes/details.tsx @@ -1,5 +1,4 @@ import { useMutation, useQuery } from "@tanstack/react-query"; -import { WifiIcon } from "lucide-react"; import { useNavigate, useParams } from "react-router"; import { toast } from "sonner"; import { getVolume } from "~/api-client"; @@ -88,7 +87,7 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) { <>
-

Volume: {name}

+

{name}

{data.status[0].toUpperCase() + data.status.slice(1)} @@ -100,7 +99,7 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
-
- +
+ -
- - -

Volume Information

-
-
+ + +

Volume Information

+
); diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index aa33b79..833e92e 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -13,7 +13,7 @@ export const volumesTable = sqliteTable("volumes_table", { createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), config: text("config", { mode: "json" }).$type().notNull(), - autoRemount: int("auto_remount").notNull().default(1), + autoRemount: int("auto_remount").$type<1 | 0>().notNull().default(1), }); export type Volume = typeof volumesTable.$inferSelect; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index c7b5a82..b98e299 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -8,6 +8,7 @@ import { driverController } from "./modules/driver/driver.controller"; import { volumeController } from "./modules/volumes/volume.controller"; import { logger } from "./utils/logger"; import { startup } from "./modules/lifecycle/startup"; +import { handleServiceError } from "./utils/errors"; export const generalDescriptor = (app: Hono) => openAPISpecs(app, { @@ -41,6 +42,18 @@ app.get("/", (c) => { return c.json({ message: "Welcome to the Ironmount API" }); }); +app.onError((err, c) => { + logger.error(`${c.req.url}: ${err.message}`); + + if (err.cause instanceof Error) { + logger.error(err.cause.message); + } + + const { status, message } = handleServiceError(err); + + return c.json({ error: message }, status); +}); + const socketPath = "/run/docker/plugins/ironmount.sock"; (async () => { diff --git a/apps/server/src/modules/backends/nfs/nfs-backend.ts b/apps/server/src/modules/backends/nfs/nfs-backend.ts index f2748a2..3d18669 100644 --- a/apps/server/src/modules/backends/nfs/nfs-backend.ts +++ b/apps/server/src/modules/backends/nfs/nfs-backend.ts @@ -1,4 +1,4 @@ -import { exec, execFile as execFileCb } from "node:child_process"; +import { execFile as execFileCb } from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as npath from "node:path"; @@ -9,6 +9,7 @@ import { promisify } from "node:util"; import { withTimeout } from "../../../utils/timeout"; import { OPERATION_TIMEOUT } from "../../../core/constants"; import { toMessage } from "../../../utils/errors"; +import { getMountForPath } from "../../../utils/mountinfo"; const execFile = promisify(execFileCb); @@ -110,6 +111,12 @@ const checkHealth = async (path: string) => { logger.debug(`Checking health of NFS volume at ${path}...`); await fs.access(path); + const mount = await getMountForPath(path); + + if (!mount || !mount.fstype.startsWith("nfs")) { + throw new Error(`Path ${path} is not mounted as NFS.`); + } + const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); await fs.writeFile(testFilePath, "healthcheck"); diff --git a/apps/server/src/modules/lifecycle/startup.ts b/apps/server/src/modules/lifecycle/startup.ts index e3af703..02de27d 100644 --- a/apps/server/src/modules/lifecycle/startup.ts +++ b/apps/server/src/modules/lifecycle/startup.ts @@ -1,4 +1,4 @@ -import { eq, or } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { db } from "../../db/db"; import { logger } from "../../utils/logger"; import { volumesTable } from "../../db/schema"; @@ -7,7 +7,10 @@ import { volumeService } from "../volumes/volume.service"; export const startup = async () => { const volumes = await db.query.volumesTable.findMany({ - where: or(eq(volumesTable.status, "mounted"), eq(volumesTable.autoRemount, 1)), + where: or( + eq(volumesTable.status, "mounted"), + and(eq(volumesTable.autoRemount, 1), eq(volumesTable.status, "error")), + ), }); for (const volume of volumes) { @@ -21,7 +24,7 @@ export const startup = async () => { logger.info("Running health check for all volumes..."); const volumes = await db.query.volumesTable.findMany({ - where: or(eq(volumesTable.status, "mounted")), + where: or(eq(volumesTable.status, "mounted"), eq(volumesTable.status, "error")), }); for (const volume of volumes) { diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index bc69e98..1b1051c 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -1,6 +1,5 @@ import { Hono } from "hono"; import { validator } from "hono-openapi/arktype"; -import { handleServiceError } from "../../utils/errors"; import { createVolumeBody, createVolumeDto, @@ -37,12 +36,7 @@ export const volumeController = new Hono() const body = c.req.valid("json"); const res = await volumeService.createVolume(body.name, body.config); - if (res.error) { - const { message, status } = handleServiceError(res.error); - return c.json(message, status); - } - - return c.json({ message: "Volume created", volume: res.volume }); + return c.json({ message: "Volume created", volume: res.volume }, 201); }) .post("/test-connection", testConnectionDto, validator("json", testConnectionBody), async (c) => { const body = c.req.valid("json"); @@ -52,24 +46,14 @@ export const volumeController = new Hono() }) .delete("/:name", deleteVolumeDto, async (c) => { const { name } = c.req.param(); - const res = await volumeService.deleteVolume(name); + await volumeService.deleteVolume(name); - if (res.error) { - const { message, status } = handleServiceError(res.error); - return c.json(message, status); - } - - return c.json({ message: "Volume deleted" }); + return c.json({ message: "Volume deleted" }, 200); }) .get("/:name", getVolumeDto, async (c) => { const { name } = c.req.param(); const res = await volumeService.getVolume(name); - if (res.error) { - const { message, status } = handleServiceError(res.error); - return c.json(message, status); - } - const response = { ...res.volume, createdAt: res.volume.createdAt.getTime(), @@ -84,11 +68,6 @@ export const volumeController = new Hono() const body = c.req.valid("json"); const res = await volumeService.updateVolume(name, body.config); - if (res.error) { - const { message, status } = handleServiceError(res.error); - return c.json(message, status); - } - const response = { message: "Volume updated", volume: { @@ -105,23 +84,13 @@ export const volumeController = new Hono() }) .post("/:name/mount", mountVolumeDto, async (c) => { const { name } = c.req.param(); - const res = await volumeService.mountVolume(name); + const { error, status } = await volumeService.mountVolume(name); - if (res.error) { - const { message, status } = handleServiceError(res.error); - return c.json(message, status); - } - - return c.json({ message: "Volume mounted successfully" }, 200); + return c.json({ error, status }, error ? 500 : 200); }) .post("/:name/unmount", unmountVolumeDto, async (c) => { const { name } = c.req.param(); - const res = await volumeService.unmountVolume(name); + const { error, status } = await volumeService.unmountVolume(name); - if (res.error) { - const { message, status } = handleServiceError(res.error); - return c.json(message, status); - } - - return c.json({ message: "Volume unmounted successfully" }, 200); + return c.json({ error, status }, error ? 500 : 200); }); diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index ec9b06e..be1b2e2 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -8,11 +8,12 @@ const volumeSchema = type({ path: "string", type: type.enumerated("nfs", "smb", "directory"), status: type.enumerated("mounted", "unmounted", "error", "unknown"), - lastError: "string|null", + lastError: "string | null", createdAt: "number", updatedAt: "number", lastHealthCheck: "number", config: volumeConfigSchema, + autoRemount: "0 | 1", }); export type VolumeDto = typeof volumeSchema.infer; @@ -55,7 +56,6 @@ export const createVolumeResponse = type({ volume: type({ name: "string", path: "string", - createdAt: "number", }), }); @@ -195,7 +195,8 @@ export const testConnectionDto = describeRoute({ * Mount volume */ export const mountVolumeResponse = type({ - message: "string", + error: "string?", + status: type.enumerated("mounted", "unmounted", "error"), }); export const mountVolumeDto = describeRoute({ @@ -222,7 +223,8 @@ export const mountVolumeDto = describeRoute({ * Unmount volume */ export const unmountVolumeResponse = type({ - message: "string", + error: "string?", + status: type.enumerated("mounted", "unmounted", "error"), }); export const unmountVolumeDto = describeRoute({ diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index 1f36be9..55cb505 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -9,7 +9,6 @@ import { config } from "../../core/config"; import { db } from "../../db/db"; import { volumesTable } from "../../db/schema"; import { createVolumeBackend } from "../backends/backend"; -import { logger } from "../../utils/logger"; import { toMessage } from "../../utils/errors"; const listVolumes = async () => { @@ -26,7 +25,7 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => { }); if (existing) { - return { error: new ConflictError("Volume already exists") }; + throw new ConflictError("Volume already exists"); } const volumePathHost = path.join(config.volumeRootHost); @@ -45,82 +44,54 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => { }; const deleteVolume = async (name: string) => { - try { - const volume = await db.query.volumesTable.findFirst({ - where: eq(volumesTable.name, name), - }); + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.name, name), + }); - if (!volume) { - return { error: new NotFoundError("Volume not found") }; - } - - const backend = createVolumeBackend(volume); - await backend.unmount(); - await db.delete(volumesTable).where(eq(volumesTable.name, name)); - return { status: 200 }; - } catch (error) { - return { - error: new InternalServerError("Failed to delete volume", { - cause: error, - }), - }; + if (!volume) { + throw new NotFoundError("Volume not found"); } + + const backend = createVolumeBackend(volume); + await backend.unmount(); + await db.delete(volumesTable).where(eq(volumesTable.name, name)); }; const mountVolume = async (name: string) => { - try { - const volume = await db.query.volumesTable.findFirst({ - where: eq(volumesTable.name, name), - }); + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.name, name), + }); - if (!volume) { - return { error: new NotFoundError("Volume not found") }; - } - - const backend = createVolumeBackend(volume); - await backend.mount(); - - await db - .update(volumesTable) - .set({ status: "mounted", lastHealthCheck: new Date(), lastError: null }) - .where(eq(volumesTable.name, name)); - - return { status: 200 }; - } catch (error) { - return { - error: new InternalServerError("Failed to mount volume", { - cause: error, - }), - }; + if (!volume) { + throw new NotFoundError("Volume not found"); } + + const backend = createVolumeBackend(volume); + const { error, status } = await backend.mount(); + + await db + .update(volumesTable) + .set({ status, lastError: error, lastHealthCheck: new Date() }) + .where(eq(volumesTable.name, name)); + + return { error, status }; }; const unmountVolume = async (name: string) => { - try { - const volume = await db.query.volumesTable.findFirst({ - where: eq(volumesTable.name, name), - }); + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.name, name), + }); - if (!volume) { - return { error: new NotFoundError("Volume not found") }; - } - - const backend = createVolumeBackend(volume); - await backend.unmount(); - - await db - .update(volumesTable) - .set({ status: "unmounted", lastHealthCheck: new Date() }) - .where(eq(volumesTable.name, name)); - - return { status: 200 }; - } catch (error) { - return { - error: new InternalServerError("Failed to unmount volume", { - cause: error, - }), - }; + if (!volume) { + throw new NotFoundError("Volume not found"); } + + const backend = createVolumeBackend(volume); + const { status, error } = await backend.unmount(); + + await db.update(volumesTable).set({ status }).where(eq(volumesTable.name, name)); + + return { error, status }; }; const getVolume = async (name: string) => { @@ -129,121 +100,88 @@ const getVolume = async (name: string) => { }); if (!volume) { - return { error: new NotFoundError("Volume not found") }; + throw new NotFoundError("Volume not found"); } return { volume }; }; const updateVolume = async (name: string, backendConfig: BackendConfig) => { - try { - const existing = await db.query.volumesTable.findFirst({ - where: eq(volumesTable.name, name), - }); + const existing = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.name, name), + }); - if (!existing) { - return { error: new NotFoundError("Volume not found") }; - } - - const [updated] = await db - .update(volumesTable) - .set({ - config: backendConfig, - type: backendConfig.backend, - updatedAt: new Date(), - status: "unmounted", - }) - .where(eq(volumesTable.name, name)) - .returning(); - - if (!updated) { - return { error: new InternalServerError("Failed to update volume") }; - } - - return { volume: updated }; - } catch (error) { - return { - error: new InternalServerError("Failed to update volume", { - cause: error, - }), - }; + if (!existing) { + throw new NotFoundError("Volume not found"); } + + const [updated] = await db + .update(volumesTable) + .set({ + config: backendConfig, + type: backendConfig.backend, + updatedAt: new Date(), + status: "unmounted", + }) + .where(eq(volumesTable.name, name)) + .returning(); + + if (!updated) { + throw new InternalServerError("Failed to update volume"); + } + + return { volume: updated }; }; const testConnection = async (backendConfig: BackendConfig) => { - let tempDir: string | null = null; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ironmount-test-")); - try { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ironmount-test-")); + const mockVolume = { + id: 0, + name: "test-connection", + path: tempDir, + config: backendConfig, + createdAt: new Date(), + updatedAt: new Date(), + lastHealthCheck: new Date(), + type: backendConfig.backend, + status: "unmounted" as const, + lastError: null, + autoRemount: 0 as const, + }; - const mockVolume = { - id: 0, - name: "test-connection", - path: tempDir, - config: backendConfig, - createdAt: new Date(), - updatedAt: new Date(), - lastHealthCheck: new Date(), - type: backendConfig.backend, - status: "unmounted" as const, - lastError: null, - autoRemount: 0, - }; + const backend = createVolumeBackend(mockVolume); + const { error } = await backend.mount(); - const backend = createVolumeBackend(mockVolume); + await backend.unmount(); - await backend.mount(); - await backend.unmount(); + await fs.access(tempDir); + await fs.rm(tempDir, { recursive: true, force: true }); - return { - success: true, - message: "Connection successful", - }; - } catch (error) { - return { - success: false, - message: toMessage(error), - }; - } finally { - if (tempDir) { - try { - await fs.access(tempDir); - await fs.rm(tempDir, { recursive: true, force: true }); - } catch (cleanupError) { - logger.warn("Failed to cleanup temp directory:", cleanupError); - } - } - } + return { + success: !error, + message: error ? toMessage(error) : "Connection successful", + }; }; const checkHealth = async (name: string) => { - try { - const volume = await db.query.volumesTable.findFirst({ - where: eq(volumesTable.name, name), - }); + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.name, name), + }); - if (!volume) { - return { error: new NotFoundError("Volume not found") }; - } - - const backend = createVolumeBackend(volume); - const { error } = await backend.checkHealth(); - - if (error) { - await db - .update(volumesTable) - .set({ status: "error", lastError: error, lastHealthCheck: new Date() }) - .where(eq(volumesTable.name, volume.name)); - - return { error }; - } - - await db.update(volumesTable).set({ lastHealthCheck: new Date() }).where(eq(volumesTable.name, volume.name)); - - return { status: 200 }; - } catch (err) { - return { error: new InternalServerError("Health check failed", { cause: err }) }; + if (!volume) { + throw new NotFoundError("Volume not found"); } + + const backend = createVolumeBackend(volume); + const { error, status } = await backend.checkHealth(); + + await db + .update(volumesTable) + .set({ lastHealthCheck: new Date(), status, lastError: error }) + .where(eq(volumesTable.name, volume.name)); + + return { status, error }; }; export const volumeService = { diff --git a/apps/server/src/utils/errors.ts b/apps/server/src/utils/errors.ts index 18e51cd..4907db7 100644 --- a/apps/server/src/utils/errors.ts +++ b/apps/server/src/utils/errors.ts @@ -1,5 +1,4 @@ import { ConflictError, NotFoundError } from "http-errors-enhanced"; -import { logger } from "./logger"; export const handleServiceError = (error: unknown) => { if (error instanceof ConflictError) { @@ -10,8 +9,7 @@ export const handleServiceError = (error: unknown) => { return { message: error.message, status: 404 as const }; } - logger.error("Unhandled service error:", error); - return { message: "Internal Server Error", status: 500 as const }; + return { message: toMessage(error), status: 500 as const }; }; export const toMessage = (err: unknown): string => { diff --git a/apps/server/src/utils/mountinfo.ts b/apps/server/src/utils/mountinfo.ts new file mode 100644 index 0000000..a235cf0 --- /dev/null +++ b/apps/server/src/utils/mountinfo.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import fs from "node:fs/promises"; + +type MountInfo = { + mountPoint: string; + fstype: string; +}; + +function isPathWithin(base: string, target: string): boolean { + const rel = path.posix.relative(base, target); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + +function unescapeMount(s: string): string { + return s.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8))); +} + +async function readMountInfo(): Promise { + const text = await fs.readFile("/proc/self/mountinfo", "utf-8"); + const result: MountInfo[] = []; + + for (const line of text.split("\n")) { + if (!line) continue; + const sep = line.indexOf(" - "); + + if (sep === -1) continue; + + const left = line.slice(0, sep).split(" "); + const right = line.slice(sep + 3).split(" "); + + // [0]=mount ID, [1]=parent ID, [2]=major:minor, [3]=root, [4]=mount point, [5]=mount options, ... + const mpRaw = left[4]; + const fstype = right[0]; + + if (!mpRaw || !fstype) continue; + + result.push({ mountPoint: unescapeMount(mpRaw), fstype }); + } + return result; +} + +export async function getMountForPath(p: string): Promise { + const mounts = await readMountInfo(); + + let best: MountInfo | undefined; + for (const m of mounts) { + if (!isPathWithin(m.mountPoint, p)) continue; + if (!best || m.mountPoint.length > best.mountPoint.length) { + best = m; + } + } + return best; +}