feat: file manager with browse, search, upload, chmod/chown

- Full file browser page at /files with lazy-load, breadcrumbs, drag&drop
- FileBrowserModal component for path selection from Samba/NFS forms
- PathField component replaces bare inputs in share/export forms
- Backend: /api/files/* routes with List, Mkdir, Rename, Delete, Chmod, Chown, Upload, Download, Preview, Search
- Reuses NASCTL_ALLOWED_ROOTS for path validation
- NASCTL_UPLOAD_MAX_BYTES (100MB) and NASCTL_PREVIEW_MAX_BYTES (256KB) env vars
- Capabilities endpoint returns chmod/chown availability (requires root)
- Version bump: 0.3.2 -> 0.4.0
This commit is contained in:
2026-07-06 01:56:40 -04:00
parent 0da789cd96
commit 50ed8abe95
17 changed files with 2209 additions and 30 deletions
+80 -2
View File
@@ -75,6 +75,58 @@ export interface VersionInfo {
commit: string;
}
export interface FileEntry {
name: string;
path: string;
is_dir: boolean;
size: number;
mode: string;
mode_num: number;
mod_time: number;
}
export interface FileInfo {
path: string;
name: string;
is_dir: boolean;
size: number;
mode: string;
mode_num: number;
mod_time: number;
uid: number;
gid: number;
total_bytes?: number;
free_bytes?: number;
}
export interface DirList {
entries: FileEntry[];
path: string;
total: number;
page: number;
limit: number;
has_more: boolean;
}
export interface SearchHit {
name: string;
path: string;
is_dir: boolean;
size: number;
}
export interface FileCapabilities {
chmod: boolean;
chown: boolean;
}
export interface FilePreview {
type: "text" | "image" | "binary";
content?: string;
mime: string;
size: number;
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
@@ -84,10 +136,11 @@ export class ApiError extends Error {
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const isFormData = body instanceof FormData;
const res = await fetch(`/api${path}`, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
headers: isFormData ? undefined : body ? { "Content-Type": "application/json" } : undefined,
body: isFormData ? body : body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) {
return undefined as T;
@@ -138,6 +191,31 @@ export const api = {
listWatchedMounts: () => request<{ mounts: WatchedMount[] }>("GET", "/system/watched-mounts"),
createWatchedMount: (path: string) => request<WatchedMount>("POST", "/system/watched-mounts", { path }),
deleteWatchedMount: (id: number) => request<void>("DELETE", `/system/watched-mounts/${id}`),
// files
fileCapabilities: () => request<FileCapabilities>("GET", "/files/capabilities"),
fileRoots: () => request<{ roots: string[]; unrestricted: boolean }>("GET", "/files/roots"),
listFiles: (path: string, page = 1, limit = 200) =>
request<DirList>("GET", `/files/?path=${encodeURIComponent(path)}&page=${page}&limit=${limit}`),
fileInfo: (path: string) => request<FileInfo>("GET", `/files/info?path=${encodeURIComponent(path)}`),
mkdirFile: (path: string) => request<void>("POST", "/files/mkdir", { path }),
renameFile: (path: string, newName: string) =>
request<void>("POST", "/files/rename", { path, newName }),
chmodFile: (path: string, mode: string) =>
request<void>("POST", "/files/chmod", { path, mode }),
chownFile: (path: string, uid: number, gid: number) =>
request<void>("POST", "/files/chown", { path, uid, gid }),
deleteFile: (path: string) => request<void>("DELETE", `/files/?path=${encodeURIComponent(path)}`),
uploadFile: (dir: string, file: File) => {
const fd = new FormData();
fd.append("file", file);
return request<{ path: string }>("POST", `/files/upload?path=${encodeURIComponent(dir)}`, fd);
},
filePreview: (path: string) =>
request<FilePreview>("GET", `/files/preview?path=${encodeURIComponent(path)}`),
searchFiles: (path: string, q: string, limit = 100) =>
request<{ results: SearchHit[] }>(`GET`, `/files/search?path=${encodeURIComponent(path)}&q=${encodeURIComponent(q)}&limit=${limit}`),
fileDownloadUrl: (path: string) => `/api/files/download?path=${encodeURIComponent(path)}`,
};
export function formatBytes(bytes: number): string {