Files
baby-nas/web/src/api.ts
T
darroyo 8003db49b3 feat: add invalid_users directive for Samba shares
Samba shares now support an 'invalid users' list (deny list), written
as 'invalid users = u1,u2' in smb.conf. The UI shows a ChipPicker
for valid_users and invalid_users, mutually exclusive, sourced from
the system user list.

feat: add ImportSystemUsers for fresh installations

When NASCTL_IMPORT_ON_BOOT=true, nasctl now imports existing system
users from /etc/passwd (UID 1000-60000) and /etc/group (supplemental
groups), and detects which have Samba accounts via 'pdbedit -L'.
Imported users are marked dirty so the admin can review before applying.
New POST /api/import/users endpoint for manual re-import.

This mirrors the existing import-on-boot flow for smb.conf and /etc/exports.
2026-07-06 15:17:32 -04:00

252 lines
7.4 KiB
TypeScript

export interface SambaShare {
id: number;
name: string;
path: string;
comment: string;
read_only: boolean;
guest_ok: boolean;
valid_users: string[];
valid_groups: string[];
invalid_users: string[];
}
export interface NFSClient {
host: string;
read_only: boolean;
async: boolean;
root_squash: boolean;
subtree_check: boolean;
advanced: NFSAdvanced;
}
export interface NFSAdvanced {
all_squash: boolean;
secure: boolean;
wdelay: boolean;
hide: boolean;
crossmnt: boolean;
[key: string]: boolean;
}
export interface NFSExport {
id: number;
path: string;
clients: NFSClient[];
read_only: boolean;
async: boolean;
root_squash: boolean;
subtree_check: boolean;
fsid: number;
advanced: string;
}
export interface User {
id: number;
username: string;
groups: string[];
smb_enabled: boolean;
disabled: boolean;
}
export interface DirtyModule {
module: string;
marked_at: string;
}
export interface ApplyLogEntry {
id: number;
module: string;
message: string;
success: boolean;
created_at: string;
}
export interface DiskUsage {
path: string;
total_bytes: number;
free_bytes: number;
used_bytes: number;
used_percent: number;
sources: ("manual" | "samba" | "nfs")[];
used_by?: string[];
available: boolean;
error?: string;
}
export interface WatchedMount {
id: number;
path: string;
}
export interface ServiceStatus {
name: string;
active: boolean;
state: string;
}
export interface SystemStatus {
disks: DiskUsage[];
services: ServiceStatus[];
}
export interface VersionInfo {
version: string;
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) {
super(message);
this.status = status;
}
}
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: isFormData ? undefined : body ? { "Content-Type": "application/json" } : undefined,
body: isFormData ? body : body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) {
return undefined as T;
}
const text = await res.text();
if (!text) {
throw new ApiError(res.status, "empty response");
}
const data = JSON.parse(text);
if (!res.ok) {
throw new ApiError(res.status, (data && data.error) || res.statusText);
}
return data as T;
}
export const api = {
// auth
status: () => request<{ authenticated: boolean; username: string }>("GET", "/auth/status"),
login: (username: string, password: string) =>
request<{ username: string }>("POST", "/auth/login", { username, password }),
logout: () => request<{ ok: boolean }>("POST", "/auth/logout"),
changePassword: (oldPassword: string, newPassword: string) =>
request<{ ok: boolean }>("PUT", "/auth/password", { old_password: oldPassword, new_password: newPassword }),
// dirty / apply
dirty: () => request<{ modules: DirtyModule[] | null }>("GET", "/dirty"),
apply: () => request<{ results: { module: string; applied: boolean; error?: string }[] }>("POST", "/apply"),
applyLog: () => request<{ entries: ApplyLogEntry[] }>("GET", "/apply/log"),
systemStatus: () => request<SystemStatus>("GET", "/system/status"),
version: () => request<VersionInfo>("GET", "/version"),
// samba
listShares: () => request<{ shares: SambaShare[] | null }>("GET", "/samba/shares/"),
createShare: (s: Partial<SambaShare>) => request<SambaShare>("POST", "/samba/shares/", s),
updateShare: (id: number, s: Partial<SambaShare>) => request<SambaShare>("PUT", `/samba/shares/${id}/`, s),
deleteShare: (id: number) => request<void>("DELETE", `/samba/shares/${id}/`),
// nfs
listExports: () => request<{ exports: NFSExport[] | null }>("GET", "/nfs/exports/"),
createExport: (e: Partial<NFSExport>) => request<NFSExport>("POST", "/nfs/exports/", e),
updateExport: (id: number, e: Partial<NFSExport>) => request<NFSExport>("PUT", `/nfs/exports/${id}/`, e),
deleteExport: (id: number) => request<void>("DELETE", `/nfs/exports/${id}/`),
// users
listUsers: () => request<{ users: User[] | null }>("GET", "/users/"),
createUser: (u: Partial<User> & { password?: string }) => request<User>("POST", "/users/", u),
updateUser: (id: number, u: Partial<User> & { password?: string }) => request<User>("PUT", `/users/${id}/`, u),
deleteUser: (id: number) => request<void>("DELETE", `/users/${id}/`),
// watched mounts
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}`),
// import
importUsers: () => request<{ imported: number; message?: string }>("POST", "/import/users"),
// 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 {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}