nhfs
Version:
NHFS — A sleek HTTP file server for the web built with Next.js and HeroUI. (Alpha)
450 lines (366 loc) • 10.3 kB
text/typescript
import { existsSync, stat, Stats } from 'node:fs';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import fileTypeMap from './fileTypeMap';
import {
Result,
FileEntry,
FilePermissions,
FileTypes,
GetDataResult,
CopyFileErrorTypes,
FileErrorTypes,
DeleteFileResult,
FileOperationError,
} from '@/types';
import { FileErrorMap } from '@/types/fileErrors';
import { log } from '@/lib/log';
import { sanitizeUrlPath } from '@/lib/helpers';
import { BASE_DIR } from '@/env';
export function getErrorMsg(code: FileErrorTypes) {
return FileErrorMap[code]?.message || FileErrorMap.UNKNOWN.message;
}
function resolveWithBaseDir(
relPath: string,
): { ok: true; path: string } | FileOperationError {
const sanitized = path.normalize(relPath).replace(/^(\.\.(\/|\\|$))+/, ''); // Checking for ../
const fullPath = path.resolve(BASE_DIR, sanitized);
if (!fullPath.startsWith(BASE_DIR)) {
return {
ok: false,
code: 'EPATHINJECTION',
msg: FileErrorMap.EPATHINJECTION.message,
};
}
return { ok: true, path: fullPath };
}
export function convertParams(params: string[]): string {
const path = params.join('/');
return sanitizeUrlPath(path);
}
export function fileExists(relPath: string): Result<boolean, FileErrorTypes> {
const resolved = resolveWithBaseDir(relPath);
if (!resolved.ok) {
return {
ok: false,
error: resolved.code,
};
}
if (!existsSync(resolved.path)) {
return {
ok: true,
value: false,
};
}
return { ok: true, value: true };
}
export async function getData(
params: string[] = ['./'],
): Promise<GetDataResult> {
const relPath = convertParams(params);
const resolvedPath = resolveWithBaseDir(relPath);
if (!resolvedPath.ok) {
return {
ok: false,
error: {
code: resolvedPath.code,
msg: resolvedPath.msg,
},
};
}
if (!existsSync(resolvedPath.path)) {
return {
ok: false,
error: {
code: 'ENOENT',
msg: 'No such file or directory',
},
};
}
const permissions = await checkFilePermissions(relPath);
if (permissions === 'EACCES') {
return {
ok: false,
error: {
code: 'EACCES',
msg: 'Permission denied',
},
};
}
const fileStat = await fs.stat(resolvedPath.path);
if (fileStat.isDirectory()) {
let filesInDirectory: FileEntry[] = [];
const data = await fs.readdir(resolvedPath.path, { withFileTypes: true });
for (const file of data) {
const childRelPath = path.join(relPath, file.name);
const fullPath = resolveWithBaseDir(childRelPath);
const filePermissions: FilePermissions =
await checkFilePermissions(childRelPath);
const fileType: FileTypes = await checkFileType(childRelPath);
if (filePermissions === 'EACCES' || !fullPath.ok) {
filesInDirectory.push({
name: file.name,
path: childRelPath,
parentPath: path.dirname(childRelPath),
permissions: 'EACCES',
type: fileType,
size: 0,
});
continue;
}
const fileDetails = await fs.stat(fullPath.path);
filesInDirectory.push({
name: file.name,
type: fileType,
path: childRelPath,
parentPath: path.dirname(childRelPath),
permissions: filePermissions,
time: {
create: fileStat.ctime,
access: fileStat.atime,
modified: fileStat.mtime,
},
size: fileDetails.size,
});
}
const sortedFiles = filesInDirectory.sort((a, b) => {
if (a.type === 'dir' && b.type !== 'dir') return -1;
if (a.type !== 'dir' && b.type === 'dir') return 1;
return a.name.localeCompare(b.name);
});
return {
ok: true,
value: {
name: path.basename(resolvedPath.path),
path: relPath,
parentPath: path.dirname(relPath),
children: sortedFiles,
type: 'dir',
time: {
create: fileStat.ctime,
access: fileStat.atime,
modified: fileStat.mtime,
},
},
};
}
if (fileStat.isFile()) {
const fileType = await checkFileType(relPath);
return {
ok: true,
value: {
name: path.basename(resolvedPath.path),
path: relPath,
parentPath: path.dirname(resolvedPath.path),
type: fileType,
permissions: permissions,
size: fileStat.size,
time: {
create: fileStat.ctime,
access: fileStat.atime,
modified: fileStat.mtime,
},
},
};
}
return {
ok: false,
error: {
code: 'UNKNOWN',
msg: 'Could not getData because of an unknown error',
},
};
}
export async function checkFileType(relPath: string): Promise<FileTypes> {
const resolvedPath = resolveWithBaseDir(relPath);
if (!resolvedPath.ok) {
return 'other';
}
const fileStat = await fs.stat(resolvedPath.path);
if (fileStat.isDirectory()) {
return 'dir';
}
if (fileStat.isSymbolicLink()) {
return 'symlink';
}
if (fileStat.isFile()) {
const fileExtension = path.extname(resolvedPath.path);
for (const [type, extensions] of Object.entries(fileTypeMap)) {
if (
extensions.includes(fileExtension.slice(1) /* Remove the first dot */)
) {
return type as FileTypes;
}
}
}
return 'other';
}
const getStats = async (absolutePath: string): Promise<Stats | false> => {
try {
const stats = await fs.stat(absolutePath);
return stats;
} catch (err) {
console.error(`Error checking permissions for '${absolutePath}'`);
return false;
}
};
export async function checkFilePermissions(
relPath: string,
): Promise<FilePermissions> {
const resolvedPath = resolveWithBaseDir(relPath);
if (!resolvedPath.ok) {
log.error('checkFilePermissions(): Invalid path.', relPath);
return 'EACCES';
}
// TODO: Test getStats function. (not using it for now)
// const stats = await getStats(resolvedPath.path);
// if (
// !stats ||
// !stats.isFile() ||
// !stats.isDirectory ||
// !stats.isSymbolicLink
// ) {
// return 'ENOENT';
// }
if (!existsSync(resolvedPath.path)) {
log.error("checkFilePermissions(): File doesn't exist.", relPath);
return 'ENOENT';
}
const permissions: FilePermissions = [];
try {
await fs.access(resolvedPath.path, fs.constants.R_OK);
permissions[0] = 'read';
} catch {}
try {
await fs.access(resolvedPath.path, fs.constants.W_OK);
permissions[1] = 'write';
} catch {}
try {
await fs.access(resolvedPath.path, fs.constants.X_OK);
permissions[2] = 'execute';
} catch {}
return permissions.some(Boolean) ? permissions : 'EACCES';
}
export async function deleteFile(relPath: string): Promise<DeleteFileResult> {
const resolvedPath = resolveWithBaseDir(relPath);
if (!resolvedPath.ok) {
return { ok: false, error: resolvedPath.code };
}
const fullPath = resolvedPath.path;
if (!existsSync(fullPath)) {
return { ok: false, error: 'ENOENT' };
}
try {
await fs.rm(fullPath, {
force: true,
recursive: true,
});
return { ok: true, value: relPath };
} catch (error: any) {
const code = (error.code as FileErrorTypes) || 'UNKNOWN';
return { ok: false, error: code };
}
}
export async function copyFile(
source: string,
destination: string,
{ move }: { move?: boolean } = { move: false },
): Promise<Result<string, CopyFileErrorTypes>> {
if (!source || !destination) {
return { ok: false, error: 'MISSING_PATH' };
}
const srcResolved = resolveWithBaseDir(source);
const destResolved = resolveWithBaseDir(destination);
if (!srcResolved.ok || !destResolved.ok) {
return { ok: false, error: 'SOURCE_NOT_FOUND' };
}
const destinationDir = path.dirname(destResolved.path);
if (!existsSync(srcResolved.path)) {
return { ok: false, error: 'SOURCE_NOT_FOUND' };
}
if (!existsSync(destinationDir)) {
return { ok: false, error: 'DEST_DIR_NOT_FOUND' };
}
if (existsSync(destResolved.path)) {
return { ok: false, error: 'DEST_ALREADY_EXISTS' };
}
const [srcPerms, destPerms] = await Promise.all([
checkFilePermissions(srcResolved.path),
checkFilePermissions(destinationDir),
]);
if (!srcPerms.includes('read')) {
return { ok: false, error: 'NO_READ_PERMISSION' };
}
if (!destPerms.includes('write')) {
return { ok: false, error: 'NO_WRITE_PERMISSION' };
}
try {
if (move) {
await fs.rename(srcResolved.path, destResolved.path);
return {
ok: true,
value: destResolved.path,
};
}
await fs.cp(srcResolved.path, destResolved.path, { recursive: true });
return {
ok: true,
value: destResolved.path,
};
} catch {
return { ok: false, error: 'COPY_FAILED' };
}
}
// Create a new folder
export async function createFolder(
relPath: string,
): Promise<Result<string, FileErrorTypes>> {
const resolvedPath = resolveWithBaseDir(relPath);
if (!resolvedPath.ok) {
return { ok: false, error: resolvedPath.code };
}
if (existsSync(resolvedPath.path)) {
return { ok: false, error: 'EEXIST' };
}
try {
await fs.mkdir(resolvedPath.path);
return { ok: true, value: resolvedPath.path };
} catch (error: any) {
const code = (error.code as FileErrorTypes) || 'UNKNOWN';
return { ok: false, error: code };
}
}
export async function writeFile(
relPath: string,
file: File,
): Promise<Result<string, FileErrorTypes>> {
const resolvedPath = resolveWithBaseDir(relPath);
if (!resolvedPath.ok) {
return {
ok: false,
error: resolvedPath.code,
};
}
if (
!(await checkFilePermissions(path.dirname(resolvedPath.path))).includes(
'write',
)
) {
return {
ok: false,
error: 'EACCES',
};
}
if (existsSync(resolvedPath.path)) {
return {
ok: false,
error: 'EEXIST',
};
}
const fileBuffer = Buffer.from(await file.arrayBuffer());
await fs.writeFile(resolvedPath.path, fileBuffer);
return {
ok: true,
value: relPath,
};
}