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:
+80
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user