Files
auto-index-go/main.go
admin 6f3cc6b725 Add resumable upload feature with progress display
- Implement chunked file upload with 1MB chunk size
- Add progress bar with percentage and chunk counter (e.g., 2/5)
- Support resuming interrupted uploads
- Improve UI with better progress visualization
- Add dropzone.js integration for drag-and-drop uploads
- Fix progress bar jumping issue in resumable uploads
- Add file type icons and size display
- Enhance error handling and user feedback
2026-01-23 15:29:26 +08:00

810 lines
18 KiB
Go

package main
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
"github.com/gin-gonic/gin"
)
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModTime time.Time `json:"modTime"`
CanPreview bool `json:"canPreview"`
CanDownload bool `json:"canDownload"`
}
type UploadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
type MoveRequest struct {
SrcPath string `json:"srcPath"`
DestPath string `json:"destPath"`
}
type RenameRequest struct {
OldPath string `json:"oldPath"`
NewName string `json:"newName"`
}
type CreateDirRequest struct {
Path string `json:"path"`
Name string `json:"name"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
type WatchEvent struct {
Type string `json:"type"`
Path string `json:"path"`
Name string `json:"name"`
}
var (
rootDir string
watcher *fsnotify.Watcher
watchChan chan WatchEvent
)
func init() {
watchChan = make(chan WatchEvent, 100)
}
func getFileType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp":
return "image"
case ".txt", ".md", ".json", ".xml", ".html", ".css", ".js", ".go", ".py", ".java", ".c", ".cpp", ".h", ".sh":
return "text"
case ".pdf":
return "pdf"
default:
return "other"
}
}
func formatFileSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
}
func listFiles(c *gin.Context) {
path := c.Query("path")
if path == "" {
path = "."
}
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
file, err := os.Open(fullPath)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
defer file.Close()
entries, err := file.Readdir(-1)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
var files []FileInfo
for _, entry := range entries {
name := entry.Name()
if name == "." || name == ".." || name == "static" {
continue
}
entryPath := filepath.Join(path, name)
fileType := getFileType(name)
canPreview := false
if !entry.IsDir() {
canPreview = fileType == "image" || fileType == "text" || fileType == "pdf"
}
files = append(files, FileInfo{
Name: name,
Path: entryPath,
IsDir: entry.IsDir(),
Size: entry.Size(),
ModTime: entry.ModTime(),
CanPreview: canPreview,
CanDownload: true,
})
}
c.JSON(http.StatusOK, gin.H{
"files": files,
"path": path,
})
}
func previewFile(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "缺少path参数"})
return
}
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
file, err := os.Open(fullPath)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
fileType := getFileType(info.Name())
if fileType == "image" {
data, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
contentType := http.DetectContentType(data)
c.Data(http.StatusOK, contentType, data)
} else if fileType == "text" {
data, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.Data(http.StatusOK, "text/plain; charset=utf-8", data)
} else if fileType == "pdf" {
data, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.Data(http.StatusOK, "application/pdf", data)
} else {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "不支持预览此类型文件"})
}
}
func downloadFile(c *gin.Context) {
pathsParam := c.Query("paths")
if pathsParam == "" {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "缺少path参数"})
return
}
downloadSingle(c, path)
return
}
var paths []string
if err := json.Unmarshal([]byte(pathsParam), &paths); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的paths参数"})
return
}
if len(paths) == 1 {
downloadSingle(c, paths[0])
return
}
downloadMultiple(c, paths)
}
func downloadSingle(c *gin.Context, path string) {
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
file, err := os.Open(fullPath)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
if info.IsDir() {
downloadDirectory(c, path)
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", info.Name()))
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Length", fmt.Sprintf("%d", info.Size()))
io.Copy(c.Writer, file)
}
func downloadDirectory(c *gin.Context, path string) {
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
dirName := filepath.Base(path)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip", dirName))
c.Header("Content-Type", "application/zip")
c.Header("Content-Transfer-Encoding", "binary")
zw := zip.NewWriter(c.Writer)
defer zw.Close()
filepath.Walk(fullPath, func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, _ := filepath.Rel(rootDir, filePath)
if relPath == "." {
return nil
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = relPath
header.Method = zip.Deflate
if info.IsDir() {
header.Name += "/"
} else {
writer, err := zw.CreateHeader(header)
if err != nil {
return err
}
if !info.IsDir() {
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
io.Copy(writer, file)
}
}
return nil
})
}
func downloadMultiple(c *gin.Context, paths []string) {
c.Header("Content-Disposition", "attachment; filename=files.zip")
c.Header("Content-Type", "application/zip")
c.Header("Content-Transfer-Encoding", "binary")
zw := zip.NewWriter(c.Writer)
defer zw.Close()
for _, path := range paths {
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
continue
}
info, err := os.Stat(fullPath)
if err != nil {
continue
}
if info.IsDir() {
filepath.Walk(fullPath, func(filePath string, fileInfo os.FileInfo, err error) error {
if err != nil {
return nil
}
relPath, _ := filepath.Rel(rootDir, filePath)
header, err := zip.FileInfoHeader(fileInfo)
if err != nil {
return nil
}
header.Name = relPath
header.Method = zip.Deflate
if fileInfo.IsDir() {
header.Name += "/"
} else {
writer, err := zw.CreateHeader(header)
if err != nil {
return nil
}
file, err := os.Open(filePath)
if err != nil {
return nil
}
defer file.Close()
io.Copy(writer, file)
}
return nil
})
} else {
file, err := os.Open(fullPath)
if err != nil {
continue
}
defer file.Close()
header, err := zip.FileInfoHeader(info)
if err != nil {
continue
}
header.Name = filepath.Base(path)
header.Method = zip.Deflate
writer, err := zw.CreateHeader(header)
if err != nil {
continue
}
io.Copy(writer, file)
}
}
}
func uploadFile(c *gin.Context) {
path := c.DefaultQuery("path", "")
if path == "" {
path = "."
}
dzuuid := c.PostForm("dzuuid")
chunkIndex := c.PostForm("dzchunkindex")
totalChunkCount := c.PostForm("dztotalchunkcount")
fileName := c.PostForm("dzanonymousfilename")
isChunked := dzuuid != "" && chunkIndex != ""
tmpDir := filepath.Join(rootDir, ".tmp", "chunks")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
c.JSON(http.StatusBadRequest, UploadResponse{
Success: false,
Message: err.Error(),
})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, UploadResponse{
Success: false,
Message: err.Error(),
})
return
}
defer file.Close()
targetFileName := header.Filename
if fileName != "" {
targetFileName = fileName
}
fullPath := filepath.Join(rootDir, path, targetFileName)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, UploadResponse{
Success: false,
Message: "无效的路径",
})
return
}
if isChunked {
idx := 0
fmt.Sscanf(chunkIndex, "%d", &idx)
total := 0
if totalChunkCount != "" {
fmt.Sscanf(totalChunkCount, "%d", &total)
}
chunkDir := filepath.Join(tmpDir, dzuuid)
if err := os.MkdirAll(chunkDir, 0755); err != nil {
c.JSON(http.StatusBadRequest, UploadResponse{
Success: false,
Message: err.Error(),
})
return
}
chunkPath := filepath.Join(chunkDir, fmt.Sprintf("chunk_%d", idx))
outFile, err := os.Create(chunkPath)
if err != nil {
c.JSON(http.StatusBadRequest, UploadResponse{
Success: false,
Message: err.Error(),
})
return
}
defer outFile.Close()
io.Copy(outFile, file)
if idx == total-1 && total > 0 {
finalFile, err := os.Create(fullPath)
if err != nil {
os.RemoveAll(chunkDir)
c.JSON(http.StatusBadRequest, UploadResponse{
Success: false,
Message: err.Error(),
})
return
}
defer finalFile.Close()
for i := 0; i < total; i++ {
chunkPath := filepath.Join(chunkDir, fmt.Sprintf("chunk_%d", i))
chunkFile, err := os.Open(chunkPath)
if err != nil {
finalFile.Close()
os.RemoveAll(chunkDir)
c.JSON(http.StatusBadRequest, UploadResponse{
Success: false,
Message: err.Error(),
})
return
}
io.Copy(finalFile, chunkFile)
chunkFile.Close()
os.Remove(chunkPath)
}
os.RemoveAll(chunkDir)
watchChan <- WatchEvent{Type: "create", Path: fullPath, Name: targetFileName}
c.JSON(http.StatusOK, UploadResponse{
Success: true,
Message: "上传成功",
})
} else {
c.JSON(http.StatusOK, UploadResponse{
Success: true,
Message: "分块上传成功",
})
}
} else {
outFile, err := os.Create(fullPath)
if err != nil {
c.JSON(http.StatusBadRequest, UploadResponse{
Success: false,
Message: err.Error(),
})
return
}
defer outFile.Close()
io.Copy(outFile, file)
watchChan <- WatchEvent{Type: "create", Path: fullPath, Name: targetFileName}
c.JSON(http.StatusOK, UploadResponse{
Success: true,
Message: "上传成功",
})
}
}
func deleteFiles(c *gin.Context) {
var paths []string
if err := c.ShouldBindJSON(&paths); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"})
return
}
for _, path := range paths {
fullPath := filepath.Join(rootDir, path)
if !strings.HasPrefix(fullPath, rootDir) {
continue
}
info, err := os.Stat(fullPath)
if err != nil {
continue
}
if info.IsDir() {
os.RemoveAll(fullPath)
} else {
os.Remove(fullPath)
}
watchChan <- WatchEvent{Type: "delete", Path: fullPath, Name: filepath.Base(path)}
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func moveFile(c *gin.Context) {
var req MoveRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"})
return
}
srcPath := filepath.Join(rootDir, req.SrcPath)
destPath := filepath.Join(rootDir, req.DestPath)
if !strings.HasPrefix(srcPath, rootDir) || !strings.HasPrefix(destPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
if err := os.Rename(srcPath, destPath); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
watchChan <- WatchEvent{Type: "move", Path: destPath, Name: filepath.Base(destPath)}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func renameFile(c *gin.Context) {
var req RenameRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"})
return
}
srcPath := filepath.Join(rootDir, req.OldPath)
newPath := filepath.Join(rootDir, filepath.Dir(req.OldPath), req.NewName)
if !strings.HasPrefix(srcPath, rootDir) || !strings.HasPrefix(newPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
if err := os.Rename(srcPath, newPath); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
watchChan <- WatchEvent{Type: "rename", Path: newPath, Name: req.NewName}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func createDir(c *gin.Context) {
var req CreateDirRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的请求体"})
return
}
fullPath := filepath.Join(rootDir, req.Path, req.Name)
if !strings.HasPrefix(fullPath, rootDir) {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "无效的路径"})
return
}
if err := os.MkdirAll(fullPath, 0755); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
watchChan <- WatchEvent{Type: "create", Path: fullPath, Name: req.Name}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func watchFiles(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
c.Writer.Flush()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case event := <-watchChan:
data, _ := json.Marshal(event)
c.SSEvent("message", string(data))
c.Writer.Flush()
case <-ticker.C:
c.SSEvent("ping", time.Now().Format("2006-01-02 15:04:05"))
c.Writer.Flush()
case <-c.Request.Context().Done():
return
}
}
}
func startWatcher() {
var err error
watcher, err = fsnotify.NewWatcher()
if err != nil {
fmt.Printf("警告: 无法创建文件监控器: %v\n", err)
return
}
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Create == fsnotify.Create ||
event.Op&fsnotify.Write == fsnotify.Write ||
event.Op&fsnotify.Remove == fsnotify.Remove ||
event.Op&fsnotify.Rename == fsnotify.Rename ||
event.Op&fsnotify.Chmod == fsnotify.Chmod {
relPath, _ := filepath.Rel(rootDir, event.Name)
if relPath == "." {
continue
}
eventType := "modify"
if event.Op&fsnotify.Create == fsnotify.Create {
eventType = "create"
} else if event.Op&fsnotify.Remove == fsnotify.Remove {
eventType = "delete"
} else if event.Op&fsnotify.Rename == fsnotify.Rename {
eventType = "rename"
}
select {
case watchChan <- WatchEvent{Type: eventType, Path: event.Name, Name: filepath.Base(event.Name)}:
default:
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
fmt.Printf("监控错误: %v\n", err)
}
}
}()
watcher.Add(rootDir)
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err == nil && info.IsDir() {
watcher.Add(path)
}
return nil
})
}
func main() {
port := "8080"
dir := "./files"
for i := 1; i < len(os.Args); i++ {
switch os.Args[i] {
case "-port":
if i+1 < len(os.Args) {
port = os.Args[i+1]
i++
}
case "-root":
if i+1 < len(os.Args) {
dir = os.Args[i+1]
i++
}
}
}
if dir == "" {
dir = "."
}
if !filepath.IsAbs(dir) {
cwd, _ := os.Getwd()
dir = filepath.Join(cwd, dir)
}
rootDir = dir
if err := os.MkdirAll(rootDir, 0755); err != nil {
fmt.Printf("错误: 无法创建根目录: %v\n", err)
os.Exit(1)
}
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(gin.Recovery())
router.Use(corsMiddleware())
router.GET("/", func(c *gin.Context) {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "1; mode=block")
html, err := os.ReadFile("/var/www/static/index.html")
if err != nil {
c.String(http.StatusInternalServerError, "无法加载页面")
return
}
c.String(http.StatusOK, "%s", html)
})
router.Static("/static", "/var/www/static")
api := router.Group("/api")
{
api.GET("/files", listFiles)
api.GET("/preview", previewFile)
api.GET("/download", downloadFile)
api.POST("/upload", uploadFile)
api.DELETE("/files", deleteFiles)
api.PUT("/move", moveFile)
api.PUT("/rename", renameFile)
api.POST("/dir", createDir)
api.GET("/watch", watchFiles)
}
go startWatcher()
addr := fmt.Sprintf(":%s", port)
fmt.Printf("文件管理器已启动: http://localhost:%s\n", port)
fmt.Printf("根目录: %s\n", rootDir)
if err := router.Run(addr); err != nil {
fmt.Printf("错误: %v\n", err)
}
}
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusOK)
return
}
c.Next()
}
}