2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 05:31:44 +08:00
Files
panel/internal/service/file.go
2026-01-14 23:22:24 +08:00

796 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build !windows
package service
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
stdio "io"
"mime/multipart"
"net/http"
stdos "os"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"time"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/utils/file"
"github.com/spf13/cast"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/http/request"
"github.com/acepanel/panel/pkg/chattr"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/os"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/tools"
)
type FileService struct {
t *gotext.Locale
taskRepo biz.TaskRepo
}
func NewFileService(t *gotext.Locale, task biz.TaskRepo) *FileService {
return &FileService{
t: t,
taskRepo: task,
}
}
func (s *FileService) Create(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileCreate](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if !req.Dir {
if _, err = shell.Execf("touch %s", req.Path); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
} else {
if err = stdos.MkdirAll(req.Path, 0755); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
s.setPermission(req.Path, 0755, "www", "www")
Success(w, nil)
}
func (s *FileService) Content(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePath](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
fileInfo, err := stdos.Stat(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if fileInfo.IsDir() {
Error(w, http.StatusInternalServerError, s.t.Get("target is a directory"))
return
}
if fileInfo.Size() > 10*1024*1024 {
Error(w, http.StatusInternalServerError, s.t.Get("file is too large, please download it to view"))
return
}
content, err := stdos.ReadFile(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
mime, err := file.MimeType(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, chix.M{
"mime": mime,
"content": base64.StdEncoding.EncodeToString(content),
})
}
func (s *FileService) Save(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileSave](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
fileInfo, err := stdos.Stat(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(req.Path, req.Content, fileInfo.Mode()); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
func (s *FileService) Delete(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePath](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
banned := []string{"/", app.Root, filepath.Join(app.Root, "server"), filepath.Join(app.Root, "panel")}
if slices.Contains(banned, req.Path) {
Error(w, http.StatusForbidden, s.t.Get("please don't do this"))
return
}
if err = io.Remove(req.Path); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
func (s *FileService) Upload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(2 << 30); err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
path := r.FormValue("path")
_, handler, err := r.FormFile("file")
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("upload file error: %v", err))
return
}
if io.Exists(path) {
Error(w, http.StatusForbidden, s.t.Get("target path %s already exists", path))
return
}
if !io.Exists(filepath.Dir(path)) {
if err = stdos.MkdirAll(filepath.Dir(path), 0755); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("create directory error: %v", err))
return
}
}
src, _ := handler.Open()
out, err := stdos.OpenFile(path, stdos.O_CREATE|stdos.O_RDWR|stdos.O_TRUNC, 0644)
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("open file error: %v", err))
return
}
if _, err = stdio.Copy(out, src); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("write file error: %v", err))
return
}
_ = src.Close()
s.setPermission(path, 0755, "www", "www")
Success(w, nil)
}
func (s *FileService) Exist(w http.ResponseWriter, r *http.Request) {
binder := chix.NewBind(r)
defer binder.Release()
var paths []string
if err := binder.Body(&paths); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
var results []bool
for item := range slices.Values(paths) {
results = append(results, io.Exists(item))
}
Success(w, results)
}
func (s *FileService) Move(w http.ResponseWriter, r *http.Request) {
binder := chix.NewBind(r)
defer binder.Release()
var req []request.FileControl
if err := binder.Body(&req); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
for item := range slices.Values(req) {
if io.Exists(item.Target) && !item.Force {
continue
}
if io.IsDir(item.Source) && strings.HasPrefix(item.Target, item.Source+"/") {
Error(w, http.StatusForbidden, s.t.Get("please don't do this"))
return
}
if err := io.Mv(item.Source, item.Target); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
Success(w, nil)
}
func (s *FileService) Copy(w http.ResponseWriter, r *http.Request) {
binder := chix.NewBind(r)
defer binder.Release()
var req []request.FileControl
if err := binder.Body(&req); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
for item := range slices.Values(req) {
if io.Exists(item.Target) && !item.Force {
continue
}
if io.IsDir(item.Source) && strings.HasPrefix(item.Target, item.Source+"/") {
Error(w, http.StatusForbidden, s.t.Get("please don't do this"))
return
}
if err := io.Cp(item.Source, item.Target); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
Success(w, nil)
}
func (s *FileService) Download(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePath](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
info, err := stdos.Stat(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if info.IsDir() {
Error(w, http.StatusInternalServerError, s.t.Get("can't download a directory"))
return
}
render := chix.NewRender(w, r)
defer render.Release()
render.Download(req.Path, info.Name())
}
func (s *FileService) RemoteDownload(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileRemoteDownload](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
timestamp := time.Now().Format("20060102150405")
task := new(biz.Task)
task.Name = s.t.Get("Download remote file %v", filepath.Base(req.Path))
task.Status = biz.TaskStatusWaiting
task.Shell = fmt.Sprintf(`wget -o /tmp/remote-download-%s.log -O '%s' '%s' && chmod 0755 '%s' && chown www:www '%s'`, timestamp, req.Path, req.URL, req.Path, req.Path)
task.Log = fmt.Sprintf("/tmp/remote-download-%s.log", timestamp)
if err = s.taskRepo.Push(task); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
func (s *FileService) Info(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePath](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
info, err := stdos.Stat(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
Error(w, http.StatusInternalServerError, s.t.Get("failed to get file system info"))
return
}
// 检查是否有 immutable 属性
immutable := false
if f, err := stdos.OpenFile(req.Path, stdos.O_RDONLY, 0); err == nil {
immutable, _ = chattr.IsAttr(f, chattr.FS_IMMUTABLE_FL)
_ = f.Close()
}
Success(w, chix.M{
"name": info.Name(),
"full": req.Path,
"size": tools.FormatBytes(float64(info.Size())),
"mode_str": info.Mode().String(),
"mode": fmt.Sprintf("%04o", info.Mode().Perm()),
"owner": os.GetUser(stat.Uid),
"group": os.GetGroup(stat.Gid),
"uid": stat.Uid,
"gid": stat.Gid,
"hidden": io.IsHidden(info.Name()),
"symlink": io.IsSymlink(info.Mode()),
"link": io.GetSymlink(req.Path),
"dir": info.IsDir(),
"modify": info.ModTime().Format(time.DateTime),
"immutable": immutable,
})
}
// Size 计算大小
func (s *FileService) Size(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePath](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
info, err := stdos.Stat(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if !info.IsDir() {
// 如果不是目录,直接返回文件大小
Success(w, chix.M{
"size": tools.FormatBytes(float64(info.Size())),
})
return
}
// 计算目录大小
output, err := shell.Execf("du -sb '%s' | awk '{print $1}'", req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, tools.FormatBytes(cast.ToFloat64(output)))
}
func (s *FileService) Permission(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePermission](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 解析成8进制
mode, err := strconv.ParseUint(req.Mode, 8, 64)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Chmod(req.Path, stdos.FileMode(mode)); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Chown(req.Path, req.Owner, req.Group); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
func (s *FileService) Compress(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileCompress](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Compress(req.Dir, req.Paths, req.File); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
s.setPermission(req.File, 0755, "www", "www")
Success(w, nil)
}
func (s *FileService) UnCompress(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileUnCompress](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.UnCompress(req.File, req.Path); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
list, err := io.ListCompress(req.File)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
for item := range slices.Values(list) {
s.setPermission(filepath.Join(req.Path, item), 0755, "www", "www")
}
Success(w, nil)
}
func (s *FileService) List(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileList](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
var list []stdos.DirEntry
if req.Keyword != "" {
list, err = io.SearchX(req.Path, req.Keyword, req.Sub)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
} else {
list, err = stdos.ReadDir(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
// 前缀 - 表示降序
sortKey := req.Sort
sortDesc := false
if strings.HasPrefix(sortKey, "-") {
sortDesc = true
sortKey = strings.TrimPrefix(sortKey, "-")
}
// 获取文件信息用于排序
type entryWithInfo struct {
entry stdos.DirEntry
info stdos.FileInfo
}
entriesWithInfo := make([]entryWithInfo, 0, len(list))
for _, entry := range list {
info, err := entry.Info()
if err != nil {
continue
}
entriesWithInfo = append(entriesWithInfo, entryWithInfo{entry: entry, info: info})
}
// 排序
slices.SortFunc(entriesWithInfo, func(a, b entryWithInfo) int {
// 文件夹始终排在前面(除非按特定字段排序)
if sortKey == "" {
if a.info.IsDir() && !b.info.IsDir() {
return -1
}
if !a.info.IsDir() && b.info.IsDir() {
return 1
}
}
var cmp int
switch sortKey {
case "size":
// 按大小排序
if a.info.Size() < b.info.Size() {
cmp = -1
} else if a.info.Size() > b.info.Size() {
cmp = 1
} else {
cmp = 0
}
case "modify":
// 按修改时间排序
if a.info.ModTime().Before(b.info.ModTime()) {
cmp = -1
} else if a.info.ModTime().After(b.info.ModTime()) {
cmp = 1
} else {
cmp = 0
}
case "name":
// 按名称排序
cmp = strings.Compare(strings.ToLower(a.info.Name()), strings.ToLower(b.info.Name()))
default:
// 默认按名称排序
cmp = strings.Compare(strings.ToLower(a.info.Name()), strings.ToLower(b.info.Name()))
}
if sortDesc {
cmp = -cmp
}
return cmp
})
// 转换回 DirEntry 列表
sortedList := make([]stdos.DirEntry, len(entriesWithInfo))
for i, e := range entriesWithInfo {
sortedList[i] = e.entry
}
paged, total := Paginate(r, s.formatDir(req.Path, sortedList))
Success(w, chix.M{
"total": total,
"items": paged,
})
}
// ChunkUploadStart 开始分块上传
func (s *FileService) ChunkUploadStart(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ChunkUploadStart](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
targetPath := filepath.Join(req.Path, req.FileName)
if io.Exists(targetPath) {
Error(w, http.StatusForbidden, s.t.Get("target path %s already exists", targetPath))
return
}
// 确保目标目录存在
if !io.Exists(req.Path) {
if err = stdos.MkdirAll(req.Path, 0755); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("create directory error: %v", err))
return
}
}
// 扫描目录中已存在的分块文件
prefix := s.getChunkTempFilePrefix(req.FileName, req.FileHash)
entries, err := stdos.ReadDir(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("read directory error: %v", err))
return
}
uploadedChunks := make([]int, 0)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasPrefix(name, prefix) {
// 提取分块索引
indexStr := strings.TrimPrefix(name, prefix)
if index, err := strconv.Atoi(indexStr); err == nil && index >= 0 && index < req.ChunkCount {
uploadedChunks = append(uploadedChunks, index)
}
}
}
Success(w, chix.M{
"uploaded_chunks": uploadedChunks,
})
}
// ChunkUploadChunk 上传单个分块
func (s *FileService) ChunkUploadChunk(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(100 << 20); err != nil { // 100MB
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
path := r.FormValue("path")
fileName := r.FormValue("file_name")
fileHash := r.FormValue("file_hash")
chunkIndex, _ := strconv.Atoi(r.FormValue("chunk_index"))
chunkHash := r.FormValue("chunk_hash")
if path == "" || fileName == "" || fileHash == "" {
Error(w, http.StatusBadRequest, s.t.Get("path, file_name and file_hash are required"))
return
}
// 获取上传的文件
_, handler, err := r.FormFile("file")
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("get upload file error: %v", err))
return
}
src, err := handler.Open()
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("open upload file error: %v", err))
return
}
defer func(src multipart.File) { _ = src.Close() }(src)
// 读取分块内容
chunkData, err := stdio.ReadAll(src)
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("read chunk data error: %v", err))
return
}
// 校验分块 hash
if chunkHash != "" {
hash := sha256.Sum256(chunkData)
actualHash := hex.EncodeToString(hash[:])
if actualHash != chunkHash {
Error(w, http.StatusBadRequest, s.t.Get("chunk hash mismatch"))
return
}
}
// 保存分块到目标目录
// 格式: .{filename}.{hash前16位}.chunk.{index}
prefix := s.getChunkTempFilePrefix(fileName, fileHash)
chunkPath := filepath.Join(path, fmt.Sprintf("%s%d", prefix, chunkIndex))
if err = stdos.WriteFile(chunkPath, chunkData, 0644); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("save chunk error: %v", err))
return
}
Success(w, chix.M{
"chunk_index": chunkIndex,
})
}
// ChunkUploadFinish 完成分块上传
func (s *FileService) ChunkUploadFinish(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ChunkUploadFinish](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
targetPath := filepath.Join(req.Path, req.FileName)
// 检查目标文件是否已存在
if io.Exists(targetPath) {
Error(w, http.StatusForbidden, s.t.Get("target path %s already exists", targetPath))
return
}
// 创建目标文件
outFile, err := stdos.OpenFile(targetPath, stdos.O_CREATE|stdos.O_WRONLY|stdos.O_TRUNC, 0644)
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("create target file error: %v", err))
return
}
defer func(outFile *stdos.File) { _ = outFile.Close() }(outFile)
// 按顺序合并分块
prefix := s.getChunkTempFilePrefix(req.FileName, req.FileHash)
var chunkPaths []string
for i := 0; i < req.ChunkCount; i++ {
chunkPath := filepath.Join(req.Path, fmt.Sprintf("%s%d", prefix, i))
chunkPaths = append(chunkPaths, chunkPath)
chunkData, err := stdos.ReadFile(chunkPath)
if err != nil {
// 删除已创建的目标文件
_ = outFile.Close()
_ = stdos.Remove(targetPath)
Error(w, http.StatusInternalServerError, s.t.Get("read chunk %d error: %v", i, err))
return
}
if _, err = outFile.Write(chunkData); err != nil {
_ = outFile.Close()
_ = stdos.Remove(targetPath)
Error(w, http.StatusInternalServerError, s.t.Get("write chunk %d error: %v", i, err))
return
}
}
// 设置权限
s.setPermission(targetPath, 0755, "www", "www")
// 清理临时分块文件
for _, chunkPath := range chunkPaths {
_ = stdos.Remove(chunkPath)
}
Success(w, chix.M{
"path": targetPath,
})
}
// formatDir 格式化目录信息
func (s *FileService) formatDir(base string, entries []stdos.DirEntry) []any {
var paths []any
for item := range slices.Values(entries) {
info, err := item.Info()
if err != nil {
continue // 直接跳过,不返回错误,不然很烦人的
}
if de, ok := item.(*io.SearchEntry); ok {
base = filepath.Dir(de.Path())
}
stat := info.Sys().(*syscall.Stat_t)
// 对于目录size 返回空字符串,需要用户手动计算
size := ""
if !info.IsDir() {
size = tools.FormatBytes(float64(info.Size()))
}
// 检查是否有 immutable 属性
fullPath := filepath.Join(base, info.Name())
immutable := false
if f, err := stdos.OpenFile(fullPath, stdos.O_RDONLY, 0); err == nil {
immutable, _ = chattr.IsAttr(f, chattr.FS_IMMUTABLE_FL)
_ = f.Close()
}
paths = append(paths, map[string]any{
"name": info.Name(),
"full": fullPath,
"size": size,
"mode_str": info.Mode().String(),
"mode": fmt.Sprintf("%04o", info.Mode().Perm()),
"owner": os.GetUser(stat.Uid),
"group": os.GetGroup(stat.Gid),
"uid": stat.Uid,
"gid": stat.Gid,
"hidden": io.IsHidden(info.Name()),
"symlink": io.IsSymlink(info.Mode()),
"link": io.GetSymlink(fullPath),
"dir": info.IsDir(),
"modify": info.ModTime().Format(time.DateTime),
"immutable": immutable,
})
}
return paths
}
// setPermission 设置权限
func (s *FileService) setPermission(path string, mode stdos.FileMode, owner, group string) {
_ = io.Chmod(path, mode)
_ = io.Chown(path, owner, group)
}
// getChunkTempFilePrefix 获取分块临时文件前缀
// 格式: .{filename}.{hash前16位}.chunk.
func (s *FileService) getChunkTempFilePrefix(fileName, fileHash string) string {
hashPrefix := fileHash
if len(hashPrefix) > 16 {
hashPrefix = hashPrefix[:16]
}
return fmt.Sprintf(".%s.%s.chunk.", fileName, hashPrefix)
}