mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
6 Commits
feat/resti
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fcc9ada74 | ||
|
|
8a9d5fc3c8 | ||
|
|
219dec1c9c | ||
|
|
3bda6e81ae | ||
|
|
269116c25e | ||
|
|
c8fc5a1273 |
@@ -42,7 +42,7 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
ironmount:
|
ironmount:
|
||||||
image: ghcr.io/nicotsx/ironmount:v0.2.0
|
image: ghcr.io/nicotsx/ironmount:v0.3.0
|
||||||
container_name: ironmount
|
container_name: ironmount
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
@@ -54,7 +54,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
- /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared
|
- /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rshared
|
||||||
- ironmount_data:/data
|
- ironmount_data:/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface Props {
|
|||||||
selectedFile?: string;
|
selectedFile?: string;
|
||||||
onFileSelect?: (filePath: string) => void;
|
onFileSelect?: (filePath: string) => void;
|
||||||
onFolderExpand?: (folderPath: string) => void;
|
onFolderExpand?: (folderPath: string) => void;
|
||||||
|
onFolderHover?: (folderPath: string) => void;
|
||||||
expandedFolders?: Set<string>;
|
expandedFolders?: Set<string>;
|
||||||
loadingFolders?: Set<string>;
|
loadingFolders?: Set<string>;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -38,6 +39,7 @@ export const FileTree = memo((props: Props) => {
|
|||||||
onFileSelect,
|
onFileSelect,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
onFolderExpand,
|
onFolderExpand,
|
||||||
|
onFolderHover,
|
||||||
expandedFolders = new Set(),
|
expandedFolders = new Set(),
|
||||||
loadingFolders = new Set(),
|
loadingFolders = new Set(),
|
||||||
className,
|
className,
|
||||||
@@ -137,6 +139,7 @@ export const FileTree = memo((props: Props) => {
|
|||||||
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
|
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
|
||||||
loading={loadingFolders.has(fileOrFolder.fullPath)}
|
loading={loadingFolders.has(fileOrFolder.fullPath)}
|
||||||
onToggle={toggleCollapseState}
|
onToggle={toggleCollapseState}
|
||||||
|
onHover={onFolderHover}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -154,9 +157,10 @@ interface FolderProps {
|
|||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onToggle: (fullPath: string) => void;
|
onToggle: (fullPath: string) => void;
|
||||||
|
onHover?: (fullPath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => {
|
const Folder = memo(({ folder, collapsed, loading, onToggle, onHover }: FolderProps) => {
|
||||||
const { depth, name, fullPath } = folder;
|
const { depth, name, fullPath } = folder;
|
||||||
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
|
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
|
||||||
|
|
||||||
@@ -164,6 +168,12 @@ const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => {
|
|||||||
onToggle(fullPath);
|
onToggle(fullPath);
|
||||||
}, [onToggle, fullPath]);
|
}, [onToggle, fullPath]);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (collapsed) {
|
||||||
|
onHover?.(fullPath);
|
||||||
|
}
|
||||||
|
}, [onHover, fullPath, collapsed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeButton
|
<NodeButton
|
||||||
className={cn("group hover:bg-accent/50 text-foreground")}
|
className={cn("group hover:bg-accent/50 text-foreground")}
|
||||||
@@ -178,6 +188,7 @@ const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
>
|
>
|
||||||
<FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" />
|
<FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" />
|
||||||
<span className="truncate">{name}</span>
|
<span className="truncate">{name}</span>
|
||||||
@@ -219,9 +230,10 @@ interface ButtonProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onMouseEnter?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeButton = memo(({ depth, icon, onClick, className, children }: ButtonProps) => {
|
const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
|
||||||
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
|
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -230,6 +242,7 @@ const NodeButton = memo(({ depth, icon, onClick, className, children }: ButtonPr
|
|||||||
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
||||||
style={{ paddingLeft }}
|
style={{ paddingLeft }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Welcome, <span className="text-strong-accent">{loaderData.user?.username}</span>
|
Welcome, <span className="text-strong-accent">{loaderData.user?.username}</span>
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
|
<Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
|
||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="default" size="sm" className="relative overflow-hidden">
|
<Button variant="default" size="sm" className="relative overflow-hidden">
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { FolderOpen } from "lucide-react";
|
import { FolderOpen } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen";
|
import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { listFiles } from "~/api-client/sdk.gen";
|
import { listFiles } from "~/api-client/sdk.gen";
|
||||||
import { FileTree } from "~/components/file-tree";
|
import { FileTree } from "~/components/file-tree";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { parseError } from "~/lib/errors";
|
|
||||||
import type { Volume } from "~/lib/types";
|
import type { Volume } from "~/lib/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -21,6 +20,7 @@ interface FileEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FilesTabContent = ({ volume }: Props) => {
|
export const FilesTabContent = ({ volume }: Props) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
|
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
|
||||||
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
|
||||||
@@ -88,6 +88,21 @@ export const FilesTabContent = ({ volume }: Props) => {
|
|||||||
[volume.name, fetchedFolders],
|
[volume.name, fetchedFolders],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Prefetch folder contents on hover
|
||||||
|
const handleFolderHover = useCallback(
|
||||||
|
(folderPath: string) => {
|
||||||
|
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
|
||||||
|
queryClient.prefetchQuery(
|
||||||
|
listFilesOptions({
|
||||||
|
path: { name: volume.name },
|
||||||
|
query: { path: folderPath },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[volume.name, fetchedFolders, loadingFolders, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
|
||||||
|
|
||||||
if (volume.status !== "mounted") {
|
if (volume.status !== "mounted") {
|
||||||
@@ -133,6 +148,7 @@ export const FilesTabContent = ({ volume }: Props) => {
|
|||||||
<FileTree
|
<FileTree
|
||||||
files={fileArray}
|
files={fileArray}
|
||||||
onFolderExpand={handleFolderExpand}
|
onFolderExpand={handleFolderExpand}
|
||||||
|
onFolderHover={handleFolderHover}
|
||||||
expandedFolders={expandedFolders}
|
expandedFolders={expandedFolders}
|
||||||
loadingFolders={loadingFolders}
|
loadingFolders={loadingFolders}
|
||||||
className="p-2"
|
className="p-2"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
deleteVolumeMutation,
|
deleteVolumeMutation,
|
||||||
@@ -38,6 +38,8 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
|||||||
export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const activeTab = searchParams.get("tab") || "info";
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...getVolumeOptions({ path: { name: name ?? "" } }),
|
...getVolumeOptions({ path: { name: name ?? "" } }),
|
||||||
@@ -131,7 +133,7 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs defaultValue="info" className="mt-4">
|
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })} className="mt-4">
|
||||||
<TabsList className="mb-2">
|
<TabsList className="mb-2">
|
||||||
<TabsTrigger value="info">Configuration</TabsTrigger>
|
<TabsTrigger value="info">Configuration</TabsTrigger>
|
||||||
<TabsTrigger value="files">Files</TabsTrigger>
|
<TabsTrigger value="files">Files</TabsTrigger>
|
||||||
|
|||||||
15
apps/server/drizzle/0006_secret_micromacro.sql
Normal file
15
apps/server/drizzle/0006_secret_micromacro.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `repositories_table` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`backend` text NOT NULL,
|
||||||
|
`config` text NOT NULL,
|
||||||
|
`compression_mode` text DEFAULT 'auto',
|
||||||
|
`status` text DEFAULT 'unknown',
|
||||||
|
`last_checked` integer,
|
||||||
|
`last_error` text,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `volumes_table` DROP COLUMN `path`;
|
||||||
311
apps/server/drizzle/meta/0006_snapshot.json
Normal file
311
apps/server/drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "16f360b6-fb61-44f3-a7f7-2bae78ebf7ca",
|
||||||
|
"prevId": "75f0aac0-aa63-4577-bfb6-4638a008935f",
|
||||||
|
"tables": {
|
||||||
|
"repositories_table": {
|
||||||
|
"name": "repositories_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"name": "backend",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"compression_mode": {
|
||||||
|
"name": "compression_mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'auto'"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'unknown'"
|
||||||
|
},
|
||||||
|
"last_checked": {
|
||||||
|
"name": "last_checked",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"repositories_table_name_unique": {
|
||||||
|
"name": "repositories_table_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions_table": {
|
||||||
|
"name": "sessions_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_table_user_id_users_table_id_fk": {
|
||||||
|
"name": "sessions_table_user_id_users_table_id_fk",
|
||||||
|
"tableFrom": "sessions_table",
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users_table": {
|
||||||
|
"name": "users_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_table_username_unique": {
|
||||||
|
"name": "users_table_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"volumes_table": {
|
||||||
|
"name": "volumes_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'unmounted'"
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_health_check": {
|
||||||
|
"name": "last_health_check",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auto_remount": {
|
||||||
|
"name": "auto_remount",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"volumes_table_name_unique": {
|
||||||
|
"name": "volumes_table_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,13 @@
|
|||||||
"when": 1759416698274,
|
"when": 1759416698274,
|
||||||
"tag": "0005_simple_alice",
|
"tag": "0005_simple_alice",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1760734377440,
|
||||||
|
"tag": "0006_secret_micromacro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const OPERATION_TIMEOUT = 5000;
|
export const OPERATION_TIMEOUT = 5000;
|
||||||
export const VOLUME_MOUNT_BASE = "/var/lib/docker/volumes/ironmount";
|
export const VOLUME_MOUNT_BASE = "/var/lib/ironmount/volumes";
|
||||||
export const DATABASE_URL = "/data/ironmount.db";
|
export const DATABASE_URL = "/data/ironmount.db";
|
||||||
export const RESTIC_PASS_FILE = "/data/secrets/restic.pass";
|
export const RESTIC_PASS_FILE = "/data/secrets/restic.pass";
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|||||||
export const volumesTable = sqliteTable("volumes_table", {
|
export const volumesTable = sqliteTable("volumes_table", {
|
||||||
id: int().primaryKey({ autoIncrement: true }),
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
name: text().notNull().unique(),
|
name: text().notNull().unique(),
|
||||||
path: text().notNull(),
|
|
||||||
type: text().$type<BackendType>().notNull(),
|
type: text().$type<BackendType>().notNull(),
|
||||||
status: text().$type<BackendStatus>().notNull().default("unmounted"),
|
status: text().$type<BackendStatus>().notNull().default("unmounted"),
|
||||||
lastError: text("last_error"),
|
lastError: text("last_error"),
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
|
import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas";
|
||||||
import type { VolumeBackend } from "../backend";
|
|
||||||
import { logger } from "../../../utils/logger";
|
|
||||||
import { withTimeout } from "../../../utils/timeout";
|
|
||||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||||
import { toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
|
import { logger } from "../../../utils/logger";
|
||||||
import { getMountForPath } from "../../../utils/mountinfo";
|
import { getMountForPath } from "../../../utils/mountinfo";
|
||||||
|
import { withTimeout } from "../../../utils/timeout";
|
||||||
|
import type { VolumeBackend } from "../backend";
|
||||||
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils";
|
||||||
|
|
||||||
const mount = async (config: BackendConfig, path: string) => {
|
const mount = async (config: BackendConfig, path: string) => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as npath from "node:path";
|
|
||||||
import * as fs from "node:fs/promises";
|
|
||||||
import { execFile as execFileCb } from "node:child_process";
|
import { execFile as execFileCb } from "node:child_process";
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as npath from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||||
import { logger } from "../../../utils/logger";
|
|
||||||
import { toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
|
import { logger } from "../../../utils/logger";
|
||||||
|
|
||||||
const execFile = promisify(execFileCb);
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,17 @@ export const driverController = new Hono()
|
|||||||
Err: "",
|
Err: "",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.post("/VolumeDriver.Path", (c) => {
|
.post("/VolumeDriver.Path", async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
if (!body.Name) {
|
||||||
|
return c.json({ Err: "Volume name is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { volume } = await volumeService.getVolume(body.Name.replace(/^im-/, ""));
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Mountpoint: `/mnt/something`,
|
Mountpoint: `${VOLUME_MOUNT_BASE}/${volume.name}/_data`,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.post("/VolumeDriver.Get", async (c) => {
|
.post("/VolumeDriver.Get", async (c) => {
|
||||||
|
|||||||
42
apps/server/src/modules/lifecycle/cleanup.ts
Normal file
42
apps/server/src/modules/lifecycle/cleanup.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { VOLUME_MOUNT_BASE } from "../../core/constants";
|
||||||
|
import { toMessage } from "../../utils/errors";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
import { readMountInfo } from "../../utils/mountinfo";
|
||||||
|
import { executeUnmount } from "../backends/utils/backend-utils";
|
||||||
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
|
import { volumeService } from "../volumes/volume.service";
|
||||||
|
|
||||||
|
export const cleanupDanglingMounts = async () => {
|
||||||
|
const allVolumes = await volumeService.listVolumes();
|
||||||
|
const allSystemMounts = await readMountInfo();
|
||||||
|
|
||||||
|
for (const mount of allSystemMounts) {
|
||||||
|
if (mount.mountPoint.includes("ironmount") && mount.mountPoint.endsWith("_data")) {
|
||||||
|
const matchingVolume = allVolumes.find((v) => getVolumePath(v.name) === mount.mountPoint);
|
||||||
|
if (!matchingVolume) {
|
||||||
|
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
|
||||||
|
await executeUnmount(mount.mountPoint);
|
||||||
|
|
||||||
|
await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => {
|
||||||
|
logger.warn(`Failed to remove dangling mount directory ${path.dirname(mount.mountPoint)}: ${toMessage(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allIronmountDirs = await fs.readdir(VOLUME_MOUNT_BASE).catch(() => []);
|
||||||
|
|
||||||
|
for (const dir of allIronmountDirs) {
|
||||||
|
const volumePath = getVolumePath(dir);
|
||||||
|
const matchingVolume = allVolumes.find((v) => getVolumePath(v.name) === volumePath);
|
||||||
|
if (!matchingVolume) {
|
||||||
|
const fullPath = path.join(VOLUME_MOUNT_BASE, dir);
|
||||||
|
logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`);
|
||||||
|
await fs.rmdir(fullPath, { recursive: true }).catch((err) => {
|
||||||
|
logger.warn(`Failed to remove dangling mount directory ${fullPath}: ${toMessage(err)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,11 +3,13 @@ import { getTasks, schedule } from "node-cron";
|
|||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { volumesTable } from "../../db/schema";
|
import { volumesTable } from "../../db/schema";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { volumeService } from "../volumes/volume.service";
|
|
||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
|
import { volumeService } from "../volumes/volume.service";
|
||||||
|
import { cleanupDanglingMounts } from "./cleanup";
|
||||||
|
|
||||||
export const startup = async () => {
|
export const startup = async () => {
|
||||||
await restic.ensurePassfile();
|
await restic.ensurePassfile();
|
||||||
|
cleanupDanglingMounts();
|
||||||
|
|
||||||
const volumes = await db.query.volumesTable.findMany({
|
const volumes = await db.query.volumesTable.findMany({
|
||||||
where: or(
|
where: or(
|
||||||
@@ -23,6 +25,11 @@ export const startup = async () => {
|
|||||||
const existingTasks = getTasks();
|
const existingTasks = getTasks();
|
||||||
existingTasks.forEach(async (task) => await task.destroy());
|
existingTasks.forEach(async (task) => await task.destroy());
|
||||||
|
|
||||||
|
schedule("0 * * * *", async () => {
|
||||||
|
logger.debug("Running hourly cleanup of dangling mounts...");
|
||||||
|
await cleanupDanglingMounts();
|
||||||
|
});
|
||||||
|
|
||||||
schedule("* * * * *", async () => {
|
schedule("* * * * *", async () => {
|
||||||
logger.debug("Running health check for all volumes...");
|
logger.debug("Running health check for all volumes...");
|
||||||
|
|
||||||
|
|||||||
5
apps/server/src/modules/volumes/helpers.ts
Normal file
5
apps/server/src/modules/volumes/helpers.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { VOLUME_MOUNT_BASE } from "../../core/constants";
|
||||||
|
|
||||||
|
export const getVolumePath = (name: string) => {
|
||||||
|
return `${VOLUME_MOUNT_BASE}/${name}/_data`;
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { validator } from "hono-openapi";
|
import { validator } from "hono-openapi";
|
||||||
|
import { getVolumePath } from "./helpers";
|
||||||
import {
|
import {
|
||||||
createVolumeBody,
|
createVolumeBody,
|
||||||
createVolumeDto,
|
createVolumeDto,
|
||||||
@@ -29,6 +30,7 @@ export const volumeController = new Hono()
|
|||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
volumes: volumes.map((volume) => ({
|
volumes: volumes.map((volume) => ({
|
||||||
|
path: getVolumePath(volume.name),
|
||||||
...volume,
|
...volume,
|
||||||
updatedAt: volume.updatedAt.getTime(),
|
updatedAt: volume.updatedAt.getTime(),
|
||||||
createdAt: volume.createdAt.getTime(),
|
createdAt: volume.createdAt.getTime(),
|
||||||
@@ -63,6 +65,7 @@ export const volumeController = new Hono()
|
|||||||
const response = {
|
const response = {
|
||||||
volume: {
|
volume: {
|
||||||
...res.volume,
|
...res.volume,
|
||||||
|
path: getVolumePath(res.volume.name),
|
||||||
createdAt: res.volume.createdAt.getTime(),
|
createdAt: res.volume.createdAt.getTime(),
|
||||||
updatedAt: res.volume.updatedAt.getTime(),
|
updatedAt: res.volume.updatedAt.getTime(),
|
||||||
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
|
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
|
||||||
@@ -95,6 +98,7 @@ export const volumeController = new Hono()
|
|||||||
message: "Volume updated",
|
message: "Volume updated",
|
||||||
volume: {
|
volume: {
|
||||||
...res.volume,
|
...res.volume,
|
||||||
|
path: getVolumePath(res.volume.name),
|
||||||
createdAt: res.volume.createdAt.getTime(),
|
createdAt: res.volume.createdAt.getTime(),
|
||||||
updatedAt: res.volume.updatedAt.getTime(),
|
updatedAt: res.volume.updatedAt.getTime(),
|
||||||
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
|
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
|
||||||
@@ -131,5 +135,7 @@ export const volumeController = new Hono()
|
|||||||
path: result.path,
|
path: result.path,
|
||||||
} satisfies ListFilesResponseDto;
|
} satisfies ListFilesResponseDto;
|
||||||
|
|
||||||
|
c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
|
||||||
|
|
||||||
return c.json(response, 200);
|
return c.json(response, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { toMessage } from "../../utils/errors";
|
|||||||
import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
import { getStatFs, type StatFs } from "../../utils/mountinfo";
|
||||||
import { createVolumeBackend } from "../backends/backend";
|
import { createVolumeBackend } from "../backends/backend";
|
||||||
import type { UpdateVolumeBody } from "./volume.dto";
|
import type { UpdateVolumeBody } from "./volume.dto";
|
||||||
|
import { getVolumePath } from "./helpers";
|
||||||
|
|
||||||
const listVolumes = async () => {
|
const listVolumes = async () => {
|
||||||
const volumes = await db.query.volumesTable.findMany({});
|
const volumes = await db.query.volumesTable.findMany({});
|
||||||
@@ -31,14 +32,11 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
|
|||||||
throw new ConflictError("Volume already exists");
|
throw new ConflictError("Volume already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
const volumePathHost = path.join(VOLUME_MOUNT_BASE);
|
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(volumesTable)
|
.insert(volumesTable)
|
||||||
.values({
|
.values({
|
||||||
name: slug,
|
name: slug,
|
||||||
config: backendConfig,
|
config: backendConfig,
|
||||||
path: path.join(volumePathHost, slug, "_data"),
|
|
||||||
type: backendConfig.backend,
|
type: backendConfig.backend,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -120,7 +118,7 @@ const getVolume = async (name: string) => {
|
|||||||
|
|
||||||
let statfs: Partial<StatFs> = {};
|
let statfs: Partial<StatFs> = {};
|
||||||
if (volume.status === "mounted") {
|
if (volume.status === "mounted") {
|
||||||
statfs = (await getStatFs(`${VOLUME_MOUNT_BASE}/${name}/_data`).catch(() => {})) ?? {};
|
statfs = await getStatFs(`${VOLUME_MOUNT_BASE}/${name}/_data`).catch(() => ({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { volume, statfs };
|
return { volume, statfs };
|
||||||
@@ -266,10 +264,12 @@ const listFiles = async (name: string, subPath?: string) => {
|
|||||||
throw new InternalServerError("Volume is not mounted");
|
throw new InternalServerError("Volume is not mounted");
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedPath = subPath ? path.join(volume.path, subPath) : volume.path;
|
const volumePath = getVolumePath(volume.name);
|
||||||
|
|
||||||
|
const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath;
|
||||||
|
|
||||||
const normalizedPath = path.normalize(requestedPath);
|
const normalizedPath = path.normalize(requestedPath);
|
||||||
if (!normalizedPath.startsWith(volume.path)) {
|
if (!normalizedPath.startsWith(volumePath)) {
|
||||||
throw new InternalServerError("Invalid path");
|
throw new InternalServerError("Invalid path");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ const listFiles = async (name: string, subPath?: string) => {
|
|||||||
const files = await Promise.all(
|
const files = await Promise.all(
|
||||||
entries.map(async (entry) => {
|
entries.map(async (entry) => {
|
||||||
const fullPath = path.join(normalizedPath, entry.name);
|
const fullPath = path.join(normalizedPath, entry.name);
|
||||||
const relativePath = path.relative(volume.path, fullPath);
|
const relativePath = path.relative(volumePath, fullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(fullPath);
|
const stats = await fs.stat(fullPath);
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { ConflictError, NotFoundError } from "http-errors-enhanced";
|
import { ConflictError, NotFoundError } from "http-errors-enhanced";
|
||||||
|
import { sanitizeSensitiveData } from "./sanitize";
|
||||||
|
|
||||||
export const handleServiceError = (error: unknown) => {
|
export const handleServiceError = (error: unknown) => {
|
||||||
if (error instanceof ConflictError) {
|
if (error instanceof ConflictError) {
|
||||||
return { message: error.message, status: 409 as const };
|
return { message: sanitizeSensitiveData(error.message), status: 409 as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof NotFoundError) {
|
if (error instanceof NotFoundError) {
|
||||||
return { message: error.message, status: 404 as const };
|
return { message: sanitizeSensitiveData(error.message), status: 404 as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { message: toMessage(error), status: 500 as const };
|
return { message: sanitizeSensitiveData(toMessage(error)), status: 500 as const };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toMessage = (err: unknown): string => {
|
export const toMessage = (err: unknown): string => {
|
||||||
return err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return sanitizeSensitiveData(message);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createLogger, format, transports } from "winston";
|
import { createLogger, format, transports } from "winston";
|
||||||
|
import { sanitizeSensitiveData } from "./sanitize";
|
||||||
|
|
||||||
const { printf, combine, colorize } = format;
|
const { printf, combine, colorize } = format;
|
||||||
|
|
||||||
@@ -14,14 +15,14 @@ const winstonLogger = createLogger({
|
|||||||
const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => {
|
const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => {
|
||||||
const stringMessages = messages.flatMap((m) => {
|
const stringMessages = messages.flatMap((m) => {
|
||||||
if (m instanceof Error) {
|
if (m instanceof Error) {
|
||||||
return [m.message, m.stack];
|
return [sanitizeSensitiveData(m.message), m.stack ? sanitizeSensitiveData(m.stack) : undefined].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof m === "object") {
|
if (typeof m === "object") {
|
||||||
return JSON.stringify(m, null, 2);
|
return sanitizeSensitiveData(JSON.stringify(m, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
return m;
|
return sanitizeSensitiveData(String(m));
|
||||||
});
|
});
|
||||||
|
|
||||||
winstonLogger.log(level, stringMessages.join(" "));
|
winstonLogger.log(level, stringMessages.join(" "));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
type MountInfo = {
|
type MountInfo = {
|
||||||
mountPoint: string;
|
mountPoint: string;
|
||||||
@@ -21,7 +21,7 @@ function unescapeMount(s: string): string {
|
|||||||
return s.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
return s.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readMountInfo(): Promise<MountInfo[]> {
|
export async function readMountInfo(): Promise<MountInfo[]> {
|
||||||
const text = await fs.readFile("/proc/self/mountinfo", "utf-8");
|
const text = await fs.readFile("/proc/self/mountinfo", "utf-8");
|
||||||
const result: MountInfo[] = [];
|
const result: MountInfo[] = [];
|
||||||
|
|
||||||
@@ -59,11 +59,27 @@ export async function getMountForPath(p: string): Promise<MountInfo | undefined>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getStatFs(mountPoint: string) {
|
export async function getStatFs(mountPoint: string) {
|
||||||
const stats = await fs.statfs(mountPoint);
|
const s = await fs.statfs(mountPoint, { bigint: true });
|
||||||
|
|
||||||
const total = Number(stats.blocks) * Number(stats.bsize);
|
const unit = s.bsize > 0n ? s.bsize : 1n;
|
||||||
const free = Number(stats.bfree) * Number(stats.bsize);
|
|
||||||
const used = total - free;
|
|
||||||
|
|
||||||
return { total, used, free };
|
const blocks = s.blocks > 0n ? s.blocks : 0n;
|
||||||
|
|
||||||
|
let bfree = s.bfree > 0n ? s.bfree : 0n;
|
||||||
|
if (bfree > blocks) bfree = blocks;
|
||||||
|
|
||||||
|
const bavail = s.bavail > 0n ? s.bavail : 0n;
|
||||||
|
|
||||||
|
const totalB = blocks * unit;
|
||||||
|
const usedB = (blocks - bfree) * unit;
|
||||||
|
const freeB = bavail * unit;
|
||||||
|
|
||||||
|
const MAX = BigInt(Number.MAX_SAFE_INTEGER);
|
||||||
|
const toNumber = (x: bigint) => (x > MAX ? Number.MAX_SAFE_INTEGER : Number(x));
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: toNumber(totalB),
|
||||||
|
used: toNumber(usedB),
|
||||||
|
free: toNumber(freeB),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
18
apps/server/src/utils/sanitize.ts
Normal file
18
apps/server/src/utils/sanitize.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Sanitizes sensitive information from strings
|
||||||
|
* This removes passwords and credentials from logs and error messages
|
||||||
|
*/
|
||||||
|
export const sanitizeSensitiveData = (text: string): string => {
|
||||||
|
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
|
||||||
|
|
||||||
|
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");
|
||||||
|
|
||||||
|
sanitized = sanitized.replace(/(\S+)\s+(\S+)\s+(\S+)/g, (match, url, user, _pass) => {
|
||||||
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
|
return `${url} ${user} ***`;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
};
|
||||||
@@ -17,7 +17,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
- /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared
|
- /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rshared
|
||||||
- ironmount_data:/data
|
- ironmount_data:/data
|
||||||
|
|
||||||
- ./apps/client/app:/app/apps/client/app
|
- ./apps/client/app:/app/apps/client/app
|
||||||
@@ -39,7 +39,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /run/docker/plugins:/run/docker/plugins
|
- /run/docker/plugins:/run/docker/plugins
|
||||||
- /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared
|
- /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rshared
|
||||||
- ironmount_data:/data
|
- ironmount_data:/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Reference in New Issue
Block a user