From 8cfa25f0f31b078f03d4bb5934610adafa01c3d5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Apr 2026 22:11:41 +0800 Subject: [PATCH] feat: add HTTP file server with beautiful file browser UI --- cmd/main.go | 14 ++ config/config.go | 27 ++- httpfile/server.go | 574 +++++++++++++++++++++++++++++++++++++++++++++ static/embed.go | 14 ++ web/server.go | 8 +- 5 files changed, 630 insertions(+), 7 deletions(-) create mode 100644 httpfile/server.go diff --git a/cmd/main.go b/cmd/main.go index 175fa2d..963991a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -11,6 +11,7 @@ import ( "ftp-server/config" ftpserver "ftp-server/ftp" + httpfileserver "ftp-server/httpfile" webserver "ftp-server/web" ) @@ -30,6 +31,9 @@ func main() { fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) fmt.Printf("FTP Port: %d\n", cfg.FTP.Port) fmt.Printf("Web Admin: http://127.0.0.1:%d\n", cfg.Web.Port) + if cfg.HTTPFile.Enable { + fmt.Printf("HTTP Files: http://127.0.0.1:%d\n", cfg.HTTPFile.Port) + } fmt.Printf("Admin User: %s\n", cfg.Admin.Username) fmt.Printf("Root Dir: %s\n", cfg.FTP.RootDir) fmt.Println("======================================") @@ -53,6 +57,16 @@ func main() { } }() + // Start HTTP file server + if cfg.HTTPFile.Enable { + fileSrv := httpfileserver.NewServer(cfg) + go func() { + if err := fileSrv.Start(); err != nil { + log.Fatalf("Failed to start HTTP file server: %v", err) + } + }() + } + // Wait for termination signal sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) diff --git a/config/config.go b/config/config.go index c48ddb4..e3306b0 100644 --- a/config/config.go +++ b/config/config.go @@ -8,11 +8,12 @@ import ( // Config represents the application configuration type Config struct { - mu sync.RWMutex `json:"-"` - FTP FTPConfig `json:"ftp"` - Web WebConfig `json:"web"` - Admin AdminConfig `json:"admin"` - FTPUsers []FTPUser `json:"ftpUsers"` + mu sync.RWMutex `json:"-"` + FTP FTPConfig `json:"ftp"` + Web WebConfig `json:"web"` + HTTPFile HTTPFileConfig `json:"httpFile"` + Admin AdminConfig `json:"admin"` + FTPUsers []FTPUser `json:"ftpUsers"` } // FTPConfig holds FTP server settings @@ -30,6 +31,15 @@ type WebConfig struct { Port int `json:"port"` } +// HTTPFileConfig holds HTTP file server settings +type HTTPFileConfig struct { + Enable bool `json:"enable"` + Host string `json:"host"` + Port int `json:"port"` + RootDir string `json:"rootDir"` + Upload bool `json:"upload"` +} + // AdminConfig holds admin credentials type AdminConfig struct { Username string `json:"username"` @@ -58,6 +68,13 @@ func DefaultConfig() *Config { Host: "0.0.0.0", Port: 8080, }, + HTTPFile: HTTPFileConfig{ + Enable: true, + Host: "0.0.0.0", + Port: 9090, + RootDir: "./ftp_root", + Upload: true, + }, Admin: AdminConfig{ Username: "admin", Password: "admin123", diff --git a/httpfile/server.go b/httpfile/server.go new file mode 100644 index 0000000..655ec2f --- /dev/null +++ b/httpfile/server.go @@ -0,0 +1,574 @@ +package httpfile + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "ftp-server/config" +) + +// Server represents the HTTP file server +type Server struct { + config *config.Config +} + +// NewServer creates a new HTTP file server +func NewServer(cfg *config.Config) *Server { + return &Server{config: cfg} +} + +// Start starts the HTTP file server +func (s *Server) Start() error { + if !s.config.HTTPFile.Enable { + return nil + } + + rootDir := s.config.HTTPFile.RootDir + if err := os.MkdirAll(rootDir, 0755); err != nil { + return fmt.Errorf("failed to create root directory: %v", err) + } + + mux := http.NewServeMux() + + // Serve HTML page + mux.HandleFunc("/", s.handleFileBrowser) + + // API endpoints + mux.HandleFunc("/api/list", s.apiListDir) + if s.config.HTTPFile.Upload { + mux.HandleFunc("/api/upload", s.apiUpload) + mux.HandleFunc("/api/create-folder", s.apiCreateFolder) + } + mux.HandleFunc("/api/download", s.apiDownload) + mux.HandleFunc("/api/delete", s.apiDelete) + + addr := fmt.Sprintf("%s:%d", s.config.HTTPFile.Host, s.config.HTTPFile.Port) + log.Printf("HTTP file server listening on http://%s", addr) + + return http.ListenAndServe(addr, mux) +} + +func (s *Server) handleFileBrowser(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(fileBrowserHTML)) +} + +func (s *Server) apiListDir(w http.ResponseWriter, r *http.Request) { + dir := r.URL.Query().Get("path") + if dir == "" { + dir = "/" + } + + realPath := s.toRealPath(dir) + + entries, err := os.ReadDir(realPath) + if err != nil { + http.Error(w, `{"error":"Cannot read directory"}`, http.StatusInternalServerError) + return + } + + var files []map[string]interface{} + for _, entry := range entries { + info, _ := entry.Info() + modTime := info.ModTime().Format("2006-01-02 15:04:05") + size := info.Size() + + file := map[string]interface{}{ + "name": entry.Name(), + "modTime": modTime, + "size": formatSize(size), + "isDir": entry.IsDir(), + } + + if !entry.IsDir() { + file["ext"] = strings.ToLower(filepath.Ext(entry.Name())) + } + + files = append(files, file) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "files": files, + }) +} + +func (s *Server) apiDownload(w http.ResponseWriter, r *http.Request) { + file := r.URL.Query().Get("file") + if file == "" { + http.Error(w, "Missing file parameter", http.StatusBadRequest) + return + } + + realPath := s.toRealPath(file) + + if info, err := os.Stat(realPath); err != nil || info.IsDir() { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + http.ServeFile(w, r, realPath) + log.Printf("HTTP download: %s from %s", file, r.RemoteAddr) +} + +func (s *Server) apiUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse multipart form (max 500MB) + err := r.ParseMultipartForm(500 << 20) + if err != nil { + http.Error(w, `{"error":"Failed to parse upload"}`, http.StatusBadRequest) + return + } + + dir := r.FormValue("path") + if dir == "" { + dir = "/" + } + + files := r.MultipartForm.File["files"] + if len(files) == 0 { + http.Error(w, `{"error":"No files uploaded"}`, http.StatusBadRequest) + return + } + + realDir := s.toRealPath(dir) + + for _, fileHeader := range files { + file, err := fileHeader.Open() + if err != nil { + continue + } + + destPath := filepath.Join(realDir, fileHeader.Filename) + destFile, err := os.Create(destPath) + if err != nil { + file.Close() + continue + } + + io.Copy(destFile, file) + destFile.Close() + file.Close() + + log.Printf("HTTP upload: %s (%d bytes) from %s", fileHeader.Filename, fileHeader.Size, r.RemoteAddr) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (s *Server) apiCreateFolder(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + var req struct { + Path string `json:"path"` + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, `{"error":"Folder name required"}`, http.StatusBadRequest) + return + } + + realPath := filepath.Join(s.toRealPath(req.Path), req.Name) + if err := os.MkdirAll(realPath, 0755); err != nil { + http.Error(w, `{"error":"Failed to create folder"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (s *Server) apiDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest) + return + } + + realPath := s.toRealPath(req.Path) + if err := os.RemoveAll(realPath); err != nil { + http.Error(w, `{"error":"Delete failed"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (s *Server) toRealPath(httpPath string) string { + rootDir := s.config.HTTPFile.RootDir + cleanPath := strings.TrimPrefix(httpPath, "/") + return filepath.Join(rootDir, cleanPath) +} + +func formatSize(size int64) string { + switch { + case size < 1024: + return fmt.Sprintf("%d B", size) + case size < 1024*1024: + return fmt.Sprintf("%.1f KB", float64(size)/1024) + case size < 1024*1024*1024: + return fmt.Sprintf("%.1f MB", float64(size)/(1024*1024)) + default: + return fmt.Sprintf("%.1f GB", float64(size)/(1024*1024*1024)) + } +} + +// fileBrowserHTML is the embedded HTML page +const fileBrowserHTML = ` + + + + + HTTP 文件服务器 + + + +
+
+

📁 HTTP 文件服务器

+

在线浏览、下载、上传文件

+
+ + + +
+ + + + +
+ +
+ + + + + + +
名称大小修改时间操作
+ +
+
+ + + + + + + + + + +` diff --git a/static/embed.go b/static/embed.go index ec10c6d..c7ea532 100644 --- a/static/embed.go +++ b/static/embed.go @@ -136,6 +136,10 @@ var IndexHTML = `
Web 端口
-
+
+
HTTP 文件服务
+
-
+
FTP 用户数
-
@@ -146,6 +150,7 @@ var IndexHTML = ` +
FTP 根目录-
被动模式端口范围-
HTTP 文件服务地址-
管理面板地址-
@@ -333,6 +338,15 @@ function loadStatus() { document.getElementById('statWebPort').textContent = data.webPort; document.getElementById('statUserCount').textContent = data.userCount; document.getElementById('infoRootDir').textContent = data.rootDir; + if (data.httpFilePort) { + document.getElementById('statHttpFilePort').textContent = data.httpFilePort; + document.getElementById('statHttpFilePort').style.color = '#28a745'; + document.getElementById('infoHttpFileAddr').textContent = 'http://' + location.hostname + ':' + data.httpFilePort; + } else { + document.getElementById('statHttpFilePort').textContent = '未启用'; + document.getElementById('statHttpFilePort').style.color = '#999'; + document.getElementById('infoHttpFileAddr').textContent = '未启用'; + } }); } diff --git a/web/server.go b/web/server.go index dbdc30a..dfcdd05 100644 --- a/web/server.go +++ b/web/server.go @@ -136,13 +136,17 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + data := map[string]interface{}{ "status": "running", "ftpPort": s.config.FTP.Port, "webPort": s.config.Web.Port, "rootDir": s.config.FTP.RootDir, "userCount": len(s.config.GetFTPUsers()), - }) + } + if s.config.HTTPFile.Enable { + data["httpFilePort"] = s.config.HTTPFile.Port + } + json.NewEncoder(w).Encode(data) } func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {