mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(server): add safe directory creation to path service
Introduces PathService.createDirectory with comprehensive path validation to prevent directory traversal and symlink attacks. It validates parent paths against a blocked list, ensures the parent is a real directory, and blocks unsafe characters in folder names. Filesystem errors like EEXIST and EACCES are mapped to standard HTTP exceptions to provide clear feedback to the client. Closes #332
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import { Folder, FolderOpen, ChevronRight, ChevronUp, Search, X, Check, Loader2, HardDrive } from 'lucide-vue-next'
|
import { Folder, FolderOpen, FolderPlus, ChevronRight, ChevronUp, Search, X, Check, Loader2, HardDrive } from 'lucide-vue-next'
|
||||||
|
import type { CreateFolderResult } from '@bookorbit/types'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
@@ -74,6 +75,69 @@ watch(currentPath, (p) => loadEntries(p), { immediate: true })
|
|||||||
function selectCurrent() {
|
function selectCurrent() {
|
||||||
emit('select', currentPath.value)
|
emit('select', currentPath.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const creatingFolder = ref(false)
|
||||||
|
const newFolderName = ref('')
|
||||||
|
const createError = ref<string | null>(null)
|
||||||
|
const createLoading = ref(false)
|
||||||
|
const newFolderInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
function toggleNewFolder() {
|
||||||
|
creatingFolder.value = !creatingFolder.value
|
||||||
|
newFolderName.value = ''
|
||||||
|
createError.value = null
|
||||||
|
if (creatingFolder.value) {
|
||||||
|
nextTick(() => newFolderInput.value?.focus())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelNewFolder() {
|
||||||
|
creatingFolder.value = false
|
||||||
|
newFolderName.value = ''
|
||||||
|
createError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNewFolder() {
|
||||||
|
const name = newFolderName.value.trim()
|
||||||
|
if (!name || createLoading.value) return
|
||||||
|
if (name.includes('/') || name.includes('\\') || name === '.' || name === '..' || name.startsWith('.')) {
|
||||||
|
createError.value = 'Enter a single folder name without slashes or leading dots'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createLoading.value = true
|
||||||
|
createError.value = null
|
||||||
|
try {
|
||||||
|
const res = await api('/api/v1/path', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ parentPath: currentPath.value, name }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const created: CreateFolderResult = await res.json()
|
||||||
|
creatingFolder.value = false
|
||||||
|
newFolderName.value = ''
|
||||||
|
navigate(created.path)
|
||||||
|
} else {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
const message = Array.isArray(body?.message) ? body.message[0] : body?.message
|
||||||
|
createError.value = message ?? 'Could not create folder'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
createError.value = 'Could not connect'
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNewFolderKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
submitNewFolder()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelNewFolder()
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -91,12 +155,21 @@ function selectCurrent() {
|
|||||||
<HardDrive :size="15" class="text-primary" />
|
<HardDrive :size="15" class="text-primary" />
|
||||||
<span class="text-sm font-semibold text-foreground">Browse folders</span>
|
<span class="text-sm font-semibold text-foreground">Browse folders</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-1">
|
||||||
class="flex items-center justify-center w-7 h-7 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
<button
|
||||||
@click="emit('close')"
|
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border border-border text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
>
|
@click="toggleNewFolder"
|
||||||
<X :size="14" />
|
>
|
||||||
</button>
|
<FolderPlus :size="13" />
|
||||||
|
New folder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-center w-7 h-7 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<X :size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
@@ -139,6 +212,36 @@ function selectCurrent() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- New folder -->
|
||||||
|
<div v-if="creatingFolder" class="px-3 py-2 border-b border-border bg-muted/20 shrink-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<FolderPlus :size="13" class="text-primary shrink-0" />
|
||||||
|
<input
|
||||||
|
ref="newFolderInput"
|
||||||
|
v-model="newFolderName"
|
||||||
|
type="text"
|
||||||
|
placeholder="New folder name"
|
||||||
|
class="flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground/70 focus:outline-none"
|
||||||
|
@keydown="onNewFolderKeydown"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90 transition-opacity shrink-0 disabled:opacity-50"
|
||||||
|
:disabled="!newFolderName.trim() || createLoading"
|
||||||
|
@click="submitNewFolder"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="createLoading" :size="12" class="animate-spin" />
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2.5 py-1 rounded-md border border-border text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
|
||||||
|
@click="cancelNewFolder"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="createError" class="mt-1.5 pl-5 text-xs text-destructive">{{ createError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Directory list -->
|
<!-- Directory list -->
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<div v-if="loading" class="flex items-center justify-center h-full">
|
<div v-if="loading" class="flex items-center justify-center h-full">
|
||||||
|
|||||||
@@ -43,3 +43,4 @@ export * from "./achievement";
|
|||||||
export * from "./reading-session";
|
export * from "./reading-session";
|
||||||
export * from "./annotation";
|
export * from "./annotation";
|
||||||
export * from "./hardcover";
|
export * from "./hardcover";
|
||||||
|
export * from "./path";
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface DirectoryEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFolderRequest {
|
||||||
|
parentPath: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateFolderResult = DirectoryEntry;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateFolderDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(4096)
|
||||||
|
parentPath: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { PathController } from './path.controller';
|
|||||||
describe('PathController', () => {
|
describe('PathController', () => {
|
||||||
const pathService = {
|
const pathService = {
|
||||||
listDirectories: vi.fn(),
|
listDirectories: vi.fn(),
|
||||||
|
createDirectory: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const controller = new PathController(pathService as never);
|
const controller = new PathController(pathService as never);
|
||||||
@@ -22,4 +23,10 @@ describe('PathController', () => {
|
|||||||
|
|
||||||
expect(pathService.listDirectories).toHaveBeenCalledWith('/books');
|
expect(pathService.listDirectories).toHaveBeenCalledWith('/books');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forwards folder creation to the service', async () => {
|
||||||
|
await controller.createFolder({ parentPath: '/books', name: 'scifi' });
|
||||||
|
|
||||||
|
expect(pathService.createDirectory).toHaveBeenCalledWith('/books', 'scifi');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Permission } from '@bookorbit/types';
|
import { Permission } from '@bookorbit/types';
|
||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
|
||||||
|
|
||||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||||
import { PathService } from './path.service';
|
import { PathService } from './path.service';
|
||||||
|
|
||||||
@Controller('path')
|
@Controller('path')
|
||||||
@@ -13,4 +14,10 @@ export class PathController {
|
|||||||
listDirectories(@Query('path') path: string) {
|
listDirectories(@Query('path') path: string) {
|
||||||
return this.pathService.listDirectories(path || '/');
|
return this.pathService.listDirectories(path || '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission(Permission.ManageLibraries)
|
||||||
|
createFolder(@Body() dto: CreateFolderDto) {
|
||||||
|
return this.pathService.createDirectory(dto.parentPath, dto.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
vi.mock('fs/promises', () => ({
|
vi.mock('fs/promises', () => ({
|
||||||
readdir: vi.fn(),
|
readdir: vi.fn(),
|
||||||
lstat: vi.fn(),
|
lstat: vi.fn(),
|
||||||
|
mkdir: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { readdir, lstat } from 'fs/promises';
|
import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { readdir, lstat, mkdir } from 'fs/promises';
|
||||||
|
|
||||||
import { PathService } from './path.service';
|
import { PathService } from './path.service';
|
||||||
|
|
||||||
const readdirMock = vi.mocked(readdir);
|
const readdirMock = vi.mocked(readdir);
|
||||||
const lstatMock = vi.mocked(lstat);
|
const lstatMock = vi.mocked(lstat);
|
||||||
|
const mkdirMock = vi.mocked(mkdir);
|
||||||
|
|
||||||
|
function dirStat() {
|
||||||
|
return { isSymbolicLink: () => false, isDirectory: () => true } as never;
|
||||||
|
}
|
||||||
|
|
||||||
function entry(name: string, options: { directory?: boolean; symbolicLink?: boolean } = {}) {
|
function entry(name: string, options: { directory?: boolean; symbolicLink?: boolean } = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -92,4 +99,82 @@ describe('PathService', () => {
|
|||||||
|
|
||||||
await expect(service.listDirectories('/does/not/exist')).resolves.toEqual([]);
|
await expect(service.listDirectories('/does/not/exist')).resolves.toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createDirectory', () => {
|
||||||
|
it('creates a folder under an accessible parent and returns its name and path', async () => {
|
||||||
|
lstatMock.mockResolvedValue(dirStat());
|
||||||
|
mkdirMock.mockResolvedValue(undefined as never);
|
||||||
|
|
||||||
|
await expect(service.createDirectory('/tmp/books', 'scifi')).resolves.toEqual({
|
||||||
|
name: 'scifi',
|
||||||
|
path: '/tmp/books/scifi',
|
||||||
|
});
|
||||||
|
expect(mkdirMock).toHaveBeenCalledWith('/tmp/books/scifi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims the folder name before creating', async () => {
|
||||||
|
lstatMock.mockResolvedValue(dirStat());
|
||||||
|
mkdirMock.mockResolvedValue(undefined as never);
|
||||||
|
|
||||||
|
await expect(service.createDirectory('/tmp/books', ' scifi ')).resolves.toEqual({
|
||||||
|
name: 'scifi',
|
||||||
|
path: '/tmp/books/scifi',
|
||||||
|
});
|
||||||
|
expect(mkdirMock).toHaveBeenCalledWith('/tmp/books/scifi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects blocked parent paths without touching the filesystem', async () => {
|
||||||
|
await expect(service.createDirectory('/etc', 'evil')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(lstatMock).not.toHaveBeenCalled();
|
||||||
|
expect(mkdirMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a symlinked parent', async () => {
|
||||||
|
lstatMock.mockResolvedValue({ isSymbolicLink: () => true, isDirectory: () => false } as never);
|
||||||
|
|
||||||
|
await expect(service.createDirectory('/tmp/link', 'scifi')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(mkdirMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a parent that is not a directory', async () => {
|
||||||
|
lstatMock.mockResolvedValue({ isSymbolicLink: () => false, isDirectory: () => false } as never);
|
||||||
|
|
||||||
|
await expect(service.createDirectory('/tmp/file.txt', 'scifi')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(mkdirMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['', ' ', '..', '.', '.hidden', 'a/b', 'a\\b'])('rejects unsafe folder name %j before any filesystem access', async (name) => {
|
||||||
|
await expect(service.createDirectory('/tmp/books', name)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(lstatMock).not.toHaveBeenCalled();
|
||||||
|
expect(mkdirMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps EEXIST to a conflict', async () => {
|
||||||
|
lstatMock.mockResolvedValue(dirStat());
|
||||||
|
mkdirMock.mockRejectedValue(Object.assign(new Error('exists'), { code: 'EEXIST' }));
|
||||||
|
|
||||||
|
await expect(service.createDirectory('/tmp/books', 'scifi')).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps EACCES to forbidden', async () => {
|
||||||
|
lstatMock.mockResolvedValue(dirStat());
|
||||||
|
mkdirMock.mockRejectedValue(Object.assign(new Error('denied'), { code: 'EACCES' }));
|
||||||
|
|
||||||
|
await expect(service.createDirectory('/tmp/books', 'scifi')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps EROFS to a bad request', async () => {
|
||||||
|
lstatMock.mockResolvedValue(dirStat());
|
||||||
|
mkdirMock.mockRejectedValue(Object.assign(new Error('read-only'), { code: 'EROFS' }));
|
||||||
|
|
||||||
|
await expect(service.createDirectory('/tmp/books', 'scifi')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a missing parent (ENOENT) to a bad request', async () => {
|
||||||
|
lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
|
||||||
|
|
||||||
|
await expect(service.createDirectory('/tmp/gone', 'scifi')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(mkdirMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import type { CreateFolderResult, DirectoryEntry } from '@bookorbit/types';
|
||||||
import { lstat, readdir } from 'fs/promises';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { lstat, mkdir, readdir } from 'fs/promises';
|
||||||
import { join, resolve, sep } from 'path';
|
import { join, resolve, sep } from 'path';
|
||||||
|
|
||||||
|
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||||
|
|
||||||
const BLOCKED = ['/proc', '/sys', '/dev', '/run', '/var/run', '/etc', '/root'];
|
const BLOCKED = ['/proc', '/sys', '/dev', '/run', '/var/run', '/etc', '/root'];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PathService {
|
export class PathService {
|
||||||
async listDirectories(rawPath: string): Promise<{ name: string; path: string }[]> {
|
private readonly logger = new Logger(PathService.name);
|
||||||
|
|
||||||
|
async listDirectories(rawPath: string): Promise<DirectoryEntry[]> {
|
||||||
const resolved = resolve(rawPath || '/');
|
const resolved = resolve(rawPath || '/');
|
||||||
if (BLOCKED.some((b) => resolved === b || resolved.startsWith(b + sep))) {
|
if (this.isBlocked(resolved)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -16,7 +29,7 @@ export class PathService {
|
|||||||
if (rootStat.isSymbolicLink()) return [];
|
if (rootStat.isSymbolicLink()) return [];
|
||||||
|
|
||||||
const entries = await readdir(resolved, { withFileTypes: true }); // codeql[js/path-injection]
|
const entries = await readdir(resolved, { withFileTypes: true }); // codeql[js/path-injection]
|
||||||
const dirs: { name: string; path: string }[] = [];
|
const dirs: DirectoryEntry[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
if (entry.name.startsWith('.')) continue;
|
if (entry.name.startsWith('.')) continue;
|
||||||
@@ -33,4 +46,83 @@ export class PathService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createDirectory(rawParent: string, rawName: string): Promise<CreateFolderResult> {
|
||||||
|
const name = this.assertSafeName(rawName);
|
||||||
|
const parent = resolve(rawParent || '/');
|
||||||
|
if (this.isBlocked(parent)) {
|
||||||
|
throw new ForbiddenException('Cannot create a folder in this location');
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
this.logger.log(`[path.create_folder] [start] parentPath="${sanitizeLogValue(parent)}" name="${sanitizeLogValue(name)}" - create folder started`);
|
||||||
|
try {
|
||||||
|
const parentStat = await lstat(parent); // codeql[js/path-injection]
|
||||||
|
if (parentStat.isSymbolicLink()) throw new ForbiddenException('Cannot create a folder under a symbolic link');
|
||||||
|
if (!parentStat.isDirectory()) throw new BadRequestException('Parent path is not a directory');
|
||||||
|
|
||||||
|
const target = join(parent, name);
|
||||||
|
const prefix = parent.endsWith(sep) ? parent : parent + sep;
|
||||||
|
if (!resolve(target).startsWith(prefix)) {
|
||||||
|
throw new BadRequestException('Invalid folder name');
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(target); // codeql[js/path-injection]
|
||||||
|
this.logger.log(`[path.create_folder] [end] path="${sanitizeLogValue(target)}" durationMs=${Date.now() - startedAt} - create folder completed`);
|
||||||
|
return { name, path: target };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[path.create_folder] [fail] parentPath="${sanitizeLogValue(parent)}" name="${sanitizeLogValue(name)}" durationMs=${Date.now() - startedAt} errorClass=${getErrorClass(error)} error="${sanitizeLogValue(getErrorMessage(error))}" - create folder failed`,
|
||||||
|
);
|
||||||
|
throw this.mapCreateError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isBlocked(resolved: string): boolean {
|
||||||
|
return BLOCKED.some((b) => resolved === b || resolved.startsWith(b + sep));
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSafeName(rawName: string): string {
|
||||||
|
const name = (rawName ?? '').trim();
|
||||||
|
if (!name) throw new BadRequestException('Folder name is required');
|
||||||
|
if (name.includes('/') || name.includes('\\') || name.includes(sep)) {
|
||||||
|
throw new BadRequestException('Folder name cannot contain path separators');
|
||||||
|
}
|
||||||
|
if (name === '.' || name === '..' || name.startsWith('.')) {
|
||||||
|
throw new BadRequestException('Folder name cannot start with a dot');
|
||||||
|
}
|
||||||
|
if (name.includes('\0')) throw new BadRequestException('Folder name contains invalid characters');
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapCreateError(error: unknown): HttpException {
|
||||||
|
if (error instanceof HttpException) return error;
|
||||||
|
switch (getErrorCode(error)) {
|
||||||
|
case 'EEXIST':
|
||||||
|
return new ConflictException('A folder with that name already exists');
|
||||||
|
case 'EACCES':
|
||||||
|
case 'EPERM':
|
||||||
|
return new ForbiddenException('Permission denied');
|
||||||
|
case 'EROFS':
|
||||||
|
return new BadRequestException('The filesystem is read-only');
|
||||||
|
case 'ENOENT':
|
||||||
|
return new BadRequestException('Parent folder no longer exists');
|
||||||
|
default:
|
||||||
|
return new InternalServerErrorException('Could not create the folder');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorCode(error: unknown): string | undefined {
|
||||||
|
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
|
||||||
|
const code = (error as { code?: unknown }).code;
|
||||||
|
return typeof code === 'string' ? code : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorClass(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.constructor.name : 'Error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user