feat: file tree api add path limit
This commit is contained in:
@@ -37,6 +37,28 @@ type FileNode struct {
|
||||
Children []*FileNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// checkPath verify if the path is inside the workDir and safe to use.
|
||||
// It returns the full absolute path and a boolean indicating if it's safe.
|
||||
func (fc *FileController) checkPath(path string, allowRoot bool) (string, bool) {
|
||||
fullPath := filepath.Join(fc.workDir, filepath.Clean(path))
|
||||
rel, err := filepath.Rel(fc.workDir, fullPath)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Basic traversal check
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Root directory check
|
||||
if !allowRoot && rel == "." {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return fullPath, true
|
||||
}
|
||||
|
||||
func (fc *FileController) GetFileTree(c *gin.Context) {
|
||||
root := &FileNode{
|
||||
Name: filepath.Base(fc.workDir),
|
||||
@@ -104,8 +126,8 @@ func (fc *FileController) GetFileContent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(fc.workDir, filepath.Clean(filePath))
|
||||
if !strings.HasPrefix(fullPath, fc.workDir) {
|
||||
fullPath, safe := fc.checkPath(filePath, false)
|
||||
if !safe {
|
||||
utils.Forbidden(c, "访问被拒绝")
|
||||
return
|
||||
}
|
||||
@@ -133,8 +155,8 @@ func (fc *FileController) SaveFileContent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(fc.workDir, filepath.Clean(req.Path))
|
||||
if !strings.HasPrefix(fullPath, fc.workDir) {
|
||||
fullPath, safe := fc.checkPath(req.Path, false)
|
||||
if !safe {
|
||||
utils.Forbidden(c, "访问被拒绝")
|
||||
return
|
||||
}
|
||||
@@ -160,8 +182,8 @@ func (fc *FileController) CreateFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(fc.workDir, filepath.Clean(req.Path))
|
||||
if !strings.HasPrefix(fullPath, fc.workDir) {
|
||||
fullPath, safe := fc.checkPath(req.Path, false)
|
||||
if !safe {
|
||||
utils.Forbidden(c, "访问被拒绝")
|
||||
return
|
||||
}
|
||||
@@ -192,8 +214,8 @@ func (fc *FileController) DeleteFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(fc.workDir, filepath.Clean(req.Path))
|
||||
if !strings.HasPrefix(fullPath, fc.workDir) {
|
||||
fullPath, safe := fc.checkPath(req.Path, false)
|
||||
if !safe {
|
||||
utils.Forbidden(c, "访问被拒绝")
|
||||
return
|
||||
}
|
||||
@@ -206,6 +228,47 @@ func (fc *FileController) DeleteFile(c *gin.Context) {
|
||||
utils.SuccessMsg(c, "删除成功")
|
||||
}
|
||||
|
||||
func (fc *FileController) MoveFile(c *gin.Context) {
|
||||
var req struct {
|
||||
OldPath string `json:"oldPath" binding:"required"`
|
||||
NewPath string `json:"newPath" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
oldFull, oldSafe := fc.checkPath(req.OldPath, false)
|
||||
newFull, newSafe := fc.checkPath(req.NewPath, false)
|
||||
|
||||
if !oldSafe || !newSafe {
|
||||
utils.Forbidden(c, "访问被拒绝")
|
||||
return
|
||||
}
|
||||
|
||||
if oldFull == newFull {
|
||||
utils.Success(c, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查目标是否存在
|
||||
if _, err := os.Stat(newFull); err == nil {
|
||||
utils.BadRequest(c, "目标已存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 确保目标目录存在
|
||||
os.MkdirAll(filepath.Dir(newFull), 0755)
|
||||
|
||||
if err := os.Rename(oldFull, newFull); err != nil {
|
||||
utils.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, nil)
|
||||
}
|
||||
|
||||
func (fc *FileController) RenameFile(c *gin.Context) {
|
||||
var req struct {
|
||||
OldPath string `json:"oldPath" binding:"required"`
|
||||
@@ -217,23 +280,37 @@ func (fc *FileController) RenameFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
oldFull := filepath.Join(fc.workDir, filepath.Clean(req.OldPath))
|
||||
newFull := filepath.Join(fc.workDir, filepath.Clean(req.NewPath))
|
||||
// 校验:重命名禁止跨目录
|
||||
if filepath.Dir(filepath.Clean(req.OldPath)) != filepath.Dir(filepath.Clean(req.NewPath)) {
|
||||
utils.BadRequest(c, "禁止跨目录重命名")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(oldFull, fc.workDir) || !strings.HasPrefix(newFull, fc.workDir) {
|
||||
oldFull, oldSafe := fc.checkPath(req.OldPath, false)
|
||||
newFull, newSafe := fc.checkPath(req.NewPath, false)
|
||||
|
||||
if !oldSafe || !newSafe {
|
||||
utils.Forbidden(c, "访问被拒绝")
|
||||
return
|
||||
}
|
||||
|
||||
// 确保目标目录存在
|
||||
os.MkdirAll(filepath.Dir(newFull), 0755)
|
||||
if oldFull == newFull {
|
||||
utils.Success(c, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查目标是否存在
|
||||
if _, err := os.Stat(newFull); err == nil {
|
||||
utils.BadRequest(c, "文件已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Rename(oldFull, newFull); err != nil {
|
||||
utils.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
utils.SuccessMsg(c, "移动成功")
|
||||
utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// UploadArchive handles archive file upload and extraction
|
||||
@@ -254,14 +331,11 @@ func (fc *FileController) UploadArchive(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 确定解压目标目录
|
||||
extractDir := fc.workDir
|
||||
if targetDir != "" {
|
||||
extractDir = filepath.Join(fc.workDir, filepath.Clean(targetDir))
|
||||
if !strings.HasPrefix(extractDir, fc.workDir) {
|
||||
extractDir, safe := fc.checkPath(targetDir, true)
|
||||
if !safe {
|
||||
utils.Forbidden(c, "访问被拒绝")
|
||||
return
|
||||
}
|
||||
}
|
||||
os.MkdirAll(extractDir, 0755)
|
||||
|
||||
// 保存临时文件
|
||||
@@ -296,14 +370,11 @@ func (fc *FileController) UploadFiles(c *gin.Context) {
|
||||
targetDir := c.PostForm("path")
|
||||
|
||||
// 确定目标目录
|
||||
destDir := fc.workDir
|
||||
if targetDir != "" {
|
||||
destDir = filepath.Join(fc.workDir, filepath.Clean(targetDir))
|
||||
if !strings.HasPrefix(destDir, fc.workDir) {
|
||||
destDir, safe := fc.checkPath(targetDir, true)
|
||||
if !safe {
|
||||
utils.Forbidden(c, "访问被拒绝")
|
||||
return
|
||||
}
|
||||
}
|
||||
os.MkdirAll(destDir, 0755)
|
||||
|
||||
form, err := c.MultipartForm()
|
||||
@@ -328,10 +399,8 @@ func (fc *FileController) UploadFiles(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 构建完整路径
|
||||
fullPath := filepath.Join(destDir, filepath.Clean(relPath))
|
||||
|
||||
// 安全检查
|
||||
if !strings.HasPrefix(fullPath, fc.workDir) {
|
||||
fullPath, safe := fc.checkPath(filepath.Join(targetDir, relPath), false)
|
||||
if !safe {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -355,8 +424,8 @@ func (fc *FileController) DownloadFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(fc.workDir, filepath.Clean(filePath))
|
||||
if !strings.HasPrefix(fullPath, fc.workDir) {
|
||||
fullPath, safe := fc.checkPath(filePath, false)
|
||||
if !safe {
|
||||
utils.Forbidden(c, "访问被拒绝")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ func Setup(c *Controllers) *gin.Engine {
|
||||
files.POST("/create", c.File.CreateFile)
|
||||
files.POST("/delete", c.File.DeleteFile)
|
||||
files.POST("/rename", c.File.RenameFile)
|
||||
files.POST("/move", c.File.MoveFile)
|
||||
files.POST("/upload", c.File.UploadArchive)
|
||||
files.POST("/uploadfiles", c.File.UploadFiles)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ func ExtractZip(src, dest string) error {
|
||||
for _, f := range r.File {
|
||||
fpath := filepath.Join(dest, f.Name)
|
||||
|
||||
// 安全检查:防止路径遍历
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
// 安全检查:防止路径遍历 (ZipSlip)
|
||||
rel, err := filepath.Rel(dest, fpath)
|
||||
if err != nil || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -95,7 +96,8 @@ func extractTarReader(tr *tar.Reader, dest string) error {
|
||||
fpath := filepath.Join(dest, header.Name)
|
||||
|
||||
// 安全检查:防止路径遍历
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
rel, err := filepath.Rel(dest, fpath)
|
||||
if err != nil || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ export const api = {
|
||||
create: (path: string, isDir: boolean) => request('/files/create', { method: 'POST', body: JSON.stringify({ path, isDir }) }),
|
||||
delete: (path: string) => request('/files/delete', { method: 'POST', body: JSON.stringify({ path }) }),
|
||||
rename: (oldPath: string, newPath: string) => request('/files/rename', { method: 'POST', body: JSON.stringify({ oldPath, newPath }) }),
|
||||
move: (oldPath: string, newPath: string) => request('/files/move', { method: 'POST', body: JSON.stringify({ oldPath, newPath }) }),
|
||||
uploadArchive: async (file: File, targetPath?: string) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
@@ -199,6 +199,10 @@ async function renameItem() {
|
||||
toast.error('请输入名称')
|
||||
return
|
||||
}
|
||||
if (newName.value.includes('/')) {
|
||||
toast.error('名称不能包含路径分隔符 /')
|
||||
return
|
||||
}
|
||||
const parts = renamePath.value.split('/')
|
||||
parts[parts.length - 1] = newName.value
|
||||
const newPath = parts.join('/')
|
||||
@@ -209,7 +213,7 @@ async function renameItem() {
|
||||
}
|
||||
|
||||
try {
|
||||
await handleMove(renamePath.value, newPath, '重命名成功')
|
||||
await handleMove(renamePath.value, newPath, '重命名成功', true)
|
||||
showRenameDialog.value = false
|
||||
} catch {
|
||||
// Error handled in handleMove
|
||||
@@ -268,9 +272,13 @@ async function handleDownload(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMove(oldPath: string, newPath: string, successMsg = '移动成功') {
|
||||
async function handleMove(oldPath: string, newPath: string, successMsg = '移动成功', isRename = false) {
|
||||
try {
|
||||
if (isRename) {
|
||||
await api.files.rename(oldPath, newPath)
|
||||
} else {
|
||||
await api.files.move(oldPath, newPath)
|
||||
}
|
||||
toast.success(successMsg)
|
||||
if (selectedFile.value === oldPath) {
|
||||
selectedFile.value = newPath
|
||||
@@ -281,8 +289,8 @@ async function handleMove(oldPath: string, newPath: string, successMsg = '移动
|
||||
router.replace({ name: 'editor', query: { file: newPath } })
|
||||
}
|
||||
await loadTree()
|
||||
} catch {
|
||||
toast.error('移动失败')
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || '移动失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +570,7 @@ onUnmounted(() => {
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">新名称</Label>
|
||||
<Input v-model="newName" class="h-8 text-xs" placeholder="new_name.sh" @keyup.enter="renameItem" />
|
||||
<p class="text-[10px] text-muted-foreground">注:仅支持修改名称,不可包含路径分隔符 /</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
Reference in New Issue
Block a user