From 1d36000b801734b0215075db57c9791c35e02cf5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 6 May 2026 17:32:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96FTP=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E9=A1=B9=E7=9B=AE=20-=20=E6=94=AF=E6=8C=81We?= =?UTF-8?q?b=E7=AE=A1=E7=90=86=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 27 ++ config/config.go | 122 +++++++++ database/db.go | 330 ++++++++++++++++++++++++ ftp/server.go | 185 ++++++++++++++ go.mod | 23 ++ go.sum | 69 +++++ main.go | 74 ++++++ static/css/style.css | 498 ++++++++++++++++++++++++++++++++++++ static/index.html | 341 +++++++++++++++++++++++++ static/js/app.js | 460 ++++++++++++++++++++++++++++++++++ web/server.go | 585 +++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 2714 insertions(+) create mode 100644 .gitignore create mode 100644 config/config.go create mode 100644 database/db.go create mode 100644 ftp/server.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 static/css/style.css create mode 100644 static/index.html create mode 100644 static/js/app.js create mode 100644 web/server.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b44a9d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# 编译产物 +ftp-server.exe +*.exe + +# 数据目录 +data/ + +# 配置文件(包含密码等敏感信息) +config.json + +# FTP根目录 +ftp_root/ + +# Go 相关 +vendor/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +Thumbs.db +Desktop.ini +.DS_Store diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..84cb322 --- /dev/null +++ b/config/config.go @@ -0,0 +1,122 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" +) + +// Config 全局配置结构 +type Config struct { + mu sync.RWMutex + FTP FTPConfig `json:"ftp"` + Web WebConfig `json:"web"` + Admin AdminConfig `json:"admin"` + Database DBConfig `json:"database"` +} + +type FTPConfig struct { + Host string `json:"host"` + Port int `json:"port"` + PassivePortMin int `json:"passive_port_min"` + PassivePortMax int `json:"passive_port_max"` + RootDir string `json:"root_dir"` + EnableAnonymous bool `json:"enable_anonymous"` + MaxConnections int `json:"max_connections"` + IdleTimeout int `json:"idle_timeout"` // 秒 +} + +type WebConfig struct { + Host string `json:"host"` + Port int `json:"port"` +} + +type AdminConfig struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type DBConfig struct { + Path string `json:"path"` +} + +// DefaultConfig 返回默认配置 +func DefaultConfig() *Config { + return &Config{ + FTP: FTPConfig{ + Host: "0.0.0.0", + Port: 2121, + PassivePortMin: 50000, + PassivePortMax: 50100, + RootDir: "./ftp_root", + EnableAnonymous: false, + MaxConnections: 50, + IdleTimeout: 300, + }, + Web: WebConfig{ + Host: "0.0.0.0", + Port: 8080, + }, + Admin: AdminConfig{ + Username: "admin", + Password: "admin123", + }, + Database: DBConfig{ + Path: "./data/ftp.db", + }, + } +} + +// Load 从文件加载配置 +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + cfg := DefaultConfig() + if err := cfg.Save(path); err != nil { + return nil, err + } + return cfg, nil + } + return nil, err + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +// Save 保存配置到文件 +func (c *Config) Save(path string) error { + c.mu.RLock() + defer c.mu.RUnlock() + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +// Get 安全获取配置副本 +func (c *Config) Get() Config { + c.mu.RLock() + defer c.mu.RUnlock() + return *c +} + +// Update 安全更新配置 +func (c *Config) Update(fn func(cfg *Config)) { + c.mu.Lock() + defer c.mu.Unlock() + fn(c) +} diff --git a/database/db.go b/database/db.go new file mode 100644 index 0000000..d04e26c --- /dev/null +++ b/database/db.go @@ -0,0 +1,330 @@ +package database + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +// DB 数据库实例 +type DB struct { + *sql.DB +} + +// FTPUser FTP用户 +type FTPUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + HomeDir string `json:"home_dir"` + Permissions string `json:"permissions"` // "read", "write", "read,write", "admin" + QuotaSize int64 `json:"quota_size"` // 字节, 0 = 无限制 + QuotaFiles int `json:"quota_files"` // 文件数, 0 = 无限制 + UploadRate int `json:"upload_rate"` // KB/s, 0 = 无限制 + DownloadRate int `json:"download_rate"` // KB/s, 0 = 无限制 + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// FTPLog FTP操作日志 +type FTPLog struct { + ID int64 `json:"id"` + Username string `json:"username"` + IP string `json:"ip"` + Action string `json:"action"` + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} + +// OnlineUser 在线用户 +type OnlineUser struct { + ID int `json:"id"` + Username string `json:"username"` + IP string `json:"ip"` + LoginTime time.Time `json:"login_time"` + LastActivity time.Time `json:"last_activity"` + CurrentDir string `json:"current_dir"` +} + +// Open 打开数据库 +func Open(dbPath string) (*DB, error) { + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("创建数据库目录失败: %w", err) + } + + db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return nil, fmt.Errorf("打开数据库失败: %w", err) + } + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("连接数据库失败: %w", err) + } + + d := &DB{db} + if err := d.initTables(); err != nil { + return nil, err + } + + return d, nil +} + +// initTables 初始化数据表 +func (db *DB) initTables() error { + schema := ` + CREATE TABLE IF NOT EXISTS ftp_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + home_dir TEXT NOT NULL, + permissions TEXT DEFAULT 'read', + quota_size INTEGER DEFAULT 0, + quota_files INTEGER DEFAULT 0, + upload_rate INTEGER DEFAULT 0, + download_rate INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS ftp_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT DEFAULT '', + ip TEXT DEFAULT '', + action TEXT NOT NULL, + file_path TEXT DEFAULT '', + file_size INTEGER DEFAULT 0, + status TEXT DEFAULT 'success', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_logs_username ON ftp_logs(username); + CREATE INDEX IF NOT EXISTS idx_logs_created ON ftp_logs(created_at); + CREATE INDEX IF NOT EXISTS idx_logs_action ON ftp_logs(action); + + CREATE TABLE IF NOT EXISTS system_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + total_uploads INTEGER DEFAULT 0, + total_downloads INTEGER DEFAULT 0, + total_upload_bytes INTEGER DEFAULT 0, + total_download_bytes INTEGER DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + ` + + _, err := db.Exec(schema) + return err +} + +// --- FTP用户 CRUD --- + +// CreateUser 创建FTP用户 +func (db *DB) CreateUser(user *FTPUser) error { + now := time.Now().Format("2006-01-02 15:04:05") + result, err := db.Exec(` + INSERT INTO ftp_users (username, password, home_dir, permissions, quota_size, quota_files, + upload_rate, download_rate, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + user.Username, user.Password, user.HomeDir, user.Permissions, + user.QuotaSize, user.QuotaFiles, user.UploadRate, user.DownloadRate, + user.Enabled, now, now) + if err != nil { + return fmt.Errorf("创建用户失败: %w", err) + } + user.ID, _ = result.LastInsertId() + user.CreatedAt = now + user.UpdatedAt = now + return nil +} + +// GetUser 获取用户 +func (db *DB) GetUser(username string) (*FTPUser, error) { + user := &FTPUser{} + var enabled int + err := db.QueryRow(` + SELECT id, username, password, home_dir, permissions, quota_size, quota_files, + upload_rate, download_rate, enabled, created_at, updated_at + FROM ftp_users WHERE username = ?`, username, + ).Scan(&user.ID, &user.Username, &user.Password, &user.HomeDir, &user.Permissions, + &user.QuotaSize, &user.QuotaFiles, &user.UploadRate, &user.DownloadRate, + &enabled, &user.CreatedAt, &user.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + user.Enabled = enabled == 1 + return user, nil +} + +// ListUsers 列出所有用户 +func (db *DB) ListUsers() ([]FTPUser, error) { + rows, err := db.Query(` + SELECT id, username, password, home_dir, permissions, quota_size, quota_files, + upload_rate, download_rate, enabled, created_at, updated_at + FROM ftp_users ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []FTPUser + for rows.Next() { + var user FTPUser + var enabled int + err := rows.Scan(&user.ID, &user.Username, &user.Password, &user.HomeDir, &user.Permissions, + &user.QuotaSize, &user.QuotaFiles, &user.UploadRate, &user.DownloadRate, + &enabled, &user.CreatedAt, &user.UpdatedAt) + if err != nil { + return nil, err + } + user.Enabled = enabled == 1 + users = append(users, user) + } + return users, nil +} + +// UpdateUser 更新用户 +func (db *DB) UpdateUser(user *FTPUser) error { + now := time.Now().Format("2006-01-02 15:04:05") + _, err := db.Exec(` + UPDATE ftp_users SET home_dir=?, permissions=?, quota_size=?, quota_files=?, + upload_rate=?, download_rate=?, enabled=?, updated_at=? + WHERE username=?`, + user.HomeDir, user.Permissions, user.QuotaSize, user.QuotaFiles, + user.UploadRate, user.DownloadRate, user.Enabled, now, user.Username) + return err +} + +// UpdateUserPassword 更新用户密码 +func (db *DB) UpdateUserPassword(username, password string) error { + now := time.Now().Format("2006-01-02 15:04:05") + _, err := db.Exec(`UPDATE ftp_users SET password=?, updated_at=? WHERE username=?`, + password, now, username) + return err +} + +// DeleteUser 删除用户 +func (db *DB) DeleteUser(username string) error { + _, err := db.Exec(`DELETE FROM ftp_users WHERE username=?`, username) + return err +} + +// --- 日志 --- + +// AddLog 添加操作日志 +func (db *DB) AddLog(log *FTPLog) error { + now := time.Now().Format("2006-01-02 15:04:05") + result, err := db.Exec(` + INSERT INTO ftp_logs (username, ip, action, file_path, file_size, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + log.Username, log.IP, log.Action, log.FilePath, log.FileSize, log.Status, now) + if err != nil { + return err + } + log.ID, _ = result.LastInsertId() + log.CreatedAt = now + return nil +} + +// QueryLogs 查询日志 +func (db *DB) QueryLogs(username, action string, page, pageSize int) ([]FTPLog, int, error) { + where := "WHERE 1=1" + args := []interface{}{} + + if username != "" { + where += " AND username LIKE ?" + args = append(args, "%"+username+"%") + } + if action != "" { + where += " AND action = ?" + args = append(args, action) + } + + // 获取总数 + var total int + countSQL := fmt.Sprintf("SELECT COUNT(*) FROM ftp_logs %s", where) + err := db.QueryRow(countSQL, args...).Scan(&total) + if err != nil { + return nil, 0, err + } + + // 分页查询 + offset := (page - 1) * pageSize + querySQL := fmt.Sprintf(` + SELECT id, username, ip, action, file_path, file_size, status, created_at + FROM ftp_logs %s ORDER BY id DESC LIMIT ? OFFSET ?`, where) + queryArgs := append(args, pageSize, offset) + + rows, err := db.Query(querySQL, queryArgs...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var logs []FTPLog + for rows.Next() { + var log FTPLog + err := rows.Scan(&log.ID, &log.Username, &log.IP, &log.Action, &log.FilePath, + &log.FileSize, &log.Status, &log.CreatedAt) + if err != nil { + return nil, 0, err + } + logs = append(logs, log) + } + return logs, total, nil +} + +// GetLogStats 获取日志统计 +func (db *DB) GetLogStats() (map[string]interface{}, error) { + stats := make(map[string]interface{}) + + var totalUsers int + db.QueryRow("SELECT COUNT(*) FROM ftp_users").Scan(&totalUsers) + stats["total_users"] = totalUsers + + var enabledUsers int + db.QueryRow("SELECT COUNT(*) FROM ftp_users WHERE enabled=1").Scan(&enabledUsers) + stats["enabled_users"] = enabledUsers + + var todayLogins int + db.QueryRow("SELECT COUNT(DISTINCT username) FROM ftp_logs WHERE action='login' AND created_at >= date('now')").Scan(&todayLogins) + stats["today_logins"] = todayLogins + + var todayUploads int + db.QueryRow("SELECT COUNT(*) FROM ftp_logs WHERE action='upload' AND created_at >= date('now')").Scan(&todayUploads) + stats["today_uploads"] = todayUploads + + var todayDownloads int + db.QueryRow("SELECT COUNT(*) FROM ftp_logs WHERE action='download' AND created_at >= date('now')").Scan(&todayDownloads) + stats["today_downloads"] = todayDownloads + + var totalUploadBytes int64 + db.QueryRow("SELECT COALESCE(SUM(file_size),0) FROM ftp_logs WHERE action='upload'").Scan(&totalUploadBytes) + stats["total_upload_bytes"] = totalUploadBytes + + var totalDownloadBytes int64 + db.QueryRow("SELECT COALESCE(SUM(file_size),0) FROM ftp_logs WHERE action='download'").Scan(&totalDownloadBytes) + stats["total_download_bytes"] = totalDownloadBytes + + return stats, nil +} + +// CleanOldLogs 清理旧日志(保留最近N天) +func (db *DB) CleanOldLogs(days int) (int64, error) { + result, err := db.Exec(`DELETE FROM ftp_logs WHERE created_at < datetime('now', ?||' days')`, + fmt.Sprintf("-%d", days)) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/ftp/server.go b/ftp/server.go new file mode 100644 index 0000000..45fb447 --- /dev/null +++ b/ftp/server.go @@ -0,0 +1,185 @@ +package ftp + +import ( + "crypto/tls" + "fmt" + "log" + "os" + "sync" + "time" + + "ftp-server/config" + "ftp-server/database" + + ftpserver "github.com/fclairamb/ftpserverlib" + "github.com/spf13/afero" +) + +// Server FTP服务器 +type Server struct { + config *config.Config + db *database.DB + ftpServer *ftpserver.FtpServer + onlineMu sync.RWMutex + onlineUsers map[string]*database.OnlineUser +} + +// NewServer 创建FTP服务器 +func NewServer(cfg *config.Config, db *database.DB) *Server { + return &Server{ + config: cfg, + db: db, + onlineUsers: make(map[string]*database.OnlineUser), + } +} + +// Start 启动FTP服务器 +func (s *Server) Start() error { + ftpCfg := s.config.Get().FTP + + // 确保FTP根目录存在 + if err := os.MkdirAll(ftpCfg.RootDir, 0755); err != nil { + return fmt.Errorf("创建FTP根目录失败: %w", err) + } + + server := ftpserver.NewFtpServer(s) + s.ftpServer = server + + go func() { + if err := server.ListenAndServe(); err != nil { + log.Printf("FTP服务器错误: %v", err) + } + }() + + log.Printf("FTP服务器已启动: %s:%d", ftpCfg.Host, ftpCfg.Port) + return nil +} + +// Stop 停止FTP服务器 +func (s *Server) Stop() { + if s.ftpServer != nil { + s.ftpServer.Stop() + log.Println("FTP服务器已停止") + } +} + +// GetOnlineUsers 获取在线用户列表 +func (s *Server) GetOnlineUsers() []database.OnlineUser { + s.onlineMu.RLock() + defer s.onlineMu.RUnlock() + + result := make([]database.OnlineUser, 0, len(s.onlineUsers)) + for _, u := range s.onlineUsers { + result = append(result, *u) + } + return result +} + +// --- 实现 ftpserverlib.MainDriver 接口 --- + +// GetSettings 返回FTP服务器设置 +func (s *Server) GetSettings() (*ftpserver.Settings, error) { + ftpCfg := s.config.Get().FTP + return &ftpserver.Settings{ + ListenAddr: fmt.Sprintf("%s:%d", ftpCfg.Host, ftpCfg.Port), + PassiveTransferPortRange: ftpserver.PortRange{ + Start: ftpCfg.PassivePortMin, + End: ftpCfg.PassivePortMax, + }, + ConnectionTimeout: int(time.Duration(ftpCfg.IdleTimeout) * time.Second), + }, nil +} + +// ClientConnected 客户端连接 +func (s *Server) ClientConnected(cc ftpserver.ClientContext) (string, error) { + return "220 Welcome to FTP Server\r\n", nil +} + +// ClientDisconnected 客户端断开 +func (s *Server) ClientDisconnected(cc ftpserver.ClientContext) { + s.onlineMu.Lock() + defer s.onlineMu.Unlock() + + for id, u := range s.onlineUsers { + if u.IP == cc.RemoteAddr().String() { + delete(s.onlineUsers, id) + break + } + } +} + +// AuthUser 认证用户 +func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string) (ftpserver.ClientDriver, error) { + ftpCfg := s.config.Get().FTP + + // 匿名登录 + if username == "anonymous" { + if !ftpCfg.EnableAnonymous { + return nil, fmt.Errorf("匿名访问未启用") + } + if err := os.MkdirAll(ftpCfg.RootDir, 0755); err != nil { + return nil, fmt.Errorf("创建根目录失败") + } + osFs := afero.NewOsFs() + boundedFs := afero.NewBasePathFs(osFs, ftpCfg.RootDir) + return boundedFs, nil + } + + // 数据库用户认证 + user, err := s.db.GetUser(username) + if err != nil { + return nil, fmt.Errorf("认证失败") + } + if user == nil || !user.Enabled { + return nil, fmt.Errorf("用户不存在或已禁用") + } + if user.Password != password { + s.db.AddLog(&database.FTPLog{ + Username: username, + IP: cc.RemoteAddr().String(), + Action: "login_failed", + Status: "failed", + }) + return nil, fmt.Errorf("密码错误") + } + + // 记录登录日志 + s.db.AddLog(&database.FTPLog{ + Username: username, + IP: cc.RemoteAddr().String(), + Action: "login", + Status: "success", + }) + + // 记录在线用户 + s.onlineMu.Lock() + s.onlineUsers[username+"_"+cc.RemoteAddr().String()] = &database.OnlineUser{ + Username: username, + IP: cc.RemoteAddr().String(), + LoginTime: time.Now(), + LastActivity: time.Now(), + CurrentDir: user.HomeDir, + } + s.onlineMu.Unlock() + + // 确保用户目录存在(自动创建) + if err := os.MkdirAll(user.HomeDir, 0755); err != nil { + return nil, fmt.Errorf("创建用户目录失败: %v", err) + } + + // 返回 afero.Fs 作为 ClientDriver + osFs := afero.NewOsFs() + boundedFs := afero.NewBasePathFs(osFs, user.HomeDir) + + // 根据权限设置只读 + if user.Permissions == "read" { + return afero.NewReadOnlyFs(boundedFs), nil + } + + return boundedFs, nil +} + +// GetTLSConfig 获取TLS配置 +func (s *Server) GetTLSConfig() (*tls.Config, error) { + return nil, fmt.Errorf("TLS未配置") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bdee3ec --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module ftp-server + +go 1.26.2 + +require ( + github.com/fclairamb/ftpserverlib v0.30.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/spf13/afero v1.15.0 + modernc.org/sqlite v1.50.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.28.0 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1c47eaf --- /dev/null +++ b/go.sum @@ -0,0 +1,69 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fclairamb/ftpserverlib v0.30.0 h1:caB9sDn1Au//q0j2ev/icPn388qPuk4k1ajSvglDcMQ= +github.com/fclairamb/ftpserverlib v0.30.0/go.mod h1:QmogtltTOgkihyKza0GNo37Mu4AEzbJ+sH6W9Y0MBIQ= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo= +github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= +modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= +modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= +modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9accf14 --- /dev/null +++ b/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "ftp-server/config" + "ftp-server/database" + "ftp-server/ftp" + "ftp-server/web" +) + +func main() { + configPath := flag.String("config", "config.json", "配置文件路径") + flag.Parse() + + fmt.Println("========================================") + fmt.Println(" FTP Server with Web Management") + fmt.Println("========================================") + fmt.Println() + + // 加载配置 + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } + log.Println("配置加载成功") + + // 打开数据库 + dbCfg := cfg.Get().Database + db, err := database.Open(dbCfg.Path) + if err != nil { + log.Fatalf("打开数据库失败: %v", err) + } + defer db.Close() + log.Println("数据库初始化成功") + + // 启动FTP服务器 + ftpServer := ftp.NewServer(cfg, db) + if err := ftpServer.Start(); err != nil { + log.Fatalf("启动FTP服务器失败: %v", err) + } + + // 启动Web管理服务器 + webServer := web.NewServer(cfg, db, ftpServer) + + // 信号处理 + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + if err := webServer.Start(); err != nil { + log.Fatalf("启动Web服务器失败: %v", err) + } + }() + + webCfg := cfg.Get().Web + fmt.Println() + fmt.Printf(" FTP端口: %d\n", cfg.Get().FTP.Port) + fmt.Printf(" Web管理: http://localhost:%d\n", webCfg.Port) + fmt.Println() + fmt.Println(" 按 Ctrl+C 停止服务器") + fmt.Println() + + <-sigChan + fmt.Println() + log.Println("正在关闭服务器...") + ftpServer.Stop() + log.Println("服务器已停止") +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..f38fde1 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,498 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background: #f0f2f5; + color: #333; +} + +/* 登录页面 */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.login-card { + background: #fff; + border-radius: 12px; + padding: 40px; + width: 400px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + text-align: center; +} + +.login-card h1 { + font-size: 28px; + margin-bottom: 8px; + color: #1a1a2e; +} + +.login-card p { + color: #888; + margin-bottom: 30px; +} + +/* 主布局 */ +.main-app { + display: flex; + min-height: 100vh; +} + +/* 侧边栏 */ +.sidebar { + width: 220px; + background: #1a1a2e; + color: #fff; + display: flex; + flex-direction: column; + position: fixed; + height: 100vh; + z-index: 100; +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.sidebar-header h2 { + font-size: 20px; + font-weight: 600; +} + +.sidebar-menu { + list-style: none; + flex: 1; + padding: 10px 0; +} + +.sidebar-menu li { + padding: 12px 20px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 10px; + color: rgba(255, 255, 255, 0.7); +} + +.sidebar-menu li:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.sidebar-menu li.active { + background: rgba(255, 255, 255, 0.15); + color: #fff; + border-left: 3px solid #667eea; +} + +.sidebar-menu .icon { + font-size: 16px; + width: 20px; + text-align: center; +} + +.sidebar-footer { + padding: 15px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +/* 内容区 */ +.content { + margin-left: 220px; + flex: 1; + padding: 30px; +} + +.page { + display: none; +} + +.page.active { + display: block; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.page-header h2 { + margin: 0; +} + +h2 { + font-size: 22px; + margin-bottom: 20px; + color: #1a1a2e; +} + +/* 统计卡片 */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; +} + +.stat-card { + background: #fff; + border-radius: 8px; + padding: 20px; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.stat-card.wide { + grid-column: span 3; +} + +.stat-value { + font-size: 32px; + font-weight: 700; + color: #667eea; + margin-bottom: 4px; +} + +.stat-label { + font-size: 13px; + color: #888; +} + +/* 表格 */ +.data-table { + width: 100%; + background: #fff; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + border-collapse: collapse; +} + +.data-table th { + background: #f8f9fa; + padding: 12px 16px; + text-align: left; + font-weight: 600; + font-size: 13px; + color: #666; + border-bottom: 2px solid #eee; +} + +.data-table td { + padding: 10px 16px; + border-bottom: 1px solid #f0f0f0; + font-size: 14px; +} + +.data-table tr:hover td { + background: #f8f9fa; +} + +.data-table .status-enabled { + color: #52c41a; + font-weight: 600; +} + +.data-table .status-disabled { + color: #ff4d4f; + font-weight: 600; +} + +/* 按钮 */ +.btn { + padding: 8px 16px; + border: 1px solid #d9d9d9; + border-radius: 6px; + background: #fff; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.btn:hover { + border-color: #667eea; + color: #667eea; +} + +.btn-primary { + background: #667eea; + color: #fff; + border-color: #667eea; +} + +.btn-primary:hover { + background: #5a6fd6; + color: #fff; +} + +.btn-danger { + background: #ff4d4f; + color: #fff; + border-color: #ff4d4f; +} + +.btn-danger:hover { + background: #e04346; + color: #fff; +} + +.btn-sm { + padding: 4px 12px; + font-size: 13px; +} + +.btn-block { + width: 100%; + padding: 12px; + font-size: 16px; +} + +/* 表单 */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 14px; + color: #333; +} + +.form-group small { + display: block; + margin-top: 4px; + color: #999; + font-size: 12px; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 8px 12px; + border: 1px solid #d9d9d9; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); +} + +.input-sm { + padding: 6px 10px; + border: 1px solid #d9d9d9; + border-radius: 6px; + font-size: 13px; +} + +.input-sm:focus { + outline: none; + border-color: #667eea; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +} + +/* 对话框 */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: #fff; + border-radius: 12px; + width: 520px; + max-width: 90vw; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + border-bottom: 1px solid #f0f0f0; +} + +.modal-header h3 { + margin: 0; + font-size: 18px; +} + +.modal-close { + font-size: 24px; + cursor: pointer; + color: #999; + line-height: 1; +} + +.modal-close:hover { + color: #333; +} + +.modal-content form { + padding: 24px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +/* 面包屑 */ +.breadcrumb { + background: #fff; + padding: 10px 16px; + border-radius: 6px; + margin-bottom: 12px; + font-size: 14px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.breadcrumb span { + cursor: pointer; + color: #667eea; +} + +.breadcrumb span:hover { + text-decoration: underline; +} + +/* 过滤栏 */ +.filter-bar { + display: flex; + gap: 10px; + align-items: center; +} + +/* 分页 */ +.pagination { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 16px; +} + +.pagination button { + padding: 6px 14px; + border: 1px solid #d9d9d9; + border-radius: 4px; + background: #fff; + cursor: pointer; + font-size: 13px; +} + +.pagination button.active { + background: #667eea; + color: #fff; + border-color: #667eea; +} + +.pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 文件操作 */ +.file-actions { + display: flex; + gap: 10px; +} + +/* 设置 */ +.settings-section { + background: #fff; + border-radius: 8px; + padding: 24px; + margin-bottom: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.settings-section h3 { + margin-bottom: 16px; + font-size: 16px; + color: #1a1a2e; + border-bottom: 1px solid #eee; + padding-bottom: 8px; +} + +/* Toast提示 */ +.toast { + position: fixed; + top: 20px; + right: 20px; + padding: 12px 24px; + border-radius: 8px; + color: #fff; + font-size: 14px; + z-index: 2000; + display: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.toast.success { + background: #52c41a; + display: block; +} + +.toast.error { + background: #ff4d4f; + display: block; +} + +/* 操作按钮组 */ +.action-btns { + display: flex; + gap: 4px; +} + +.action-btns .btn { + padding: 4px 8px; + font-size: 12px; +} + +/* 文件图标 */ +.file-icon { + margin-right: 6px; +} + +.dir-link { + color: #667eea; + cursor: pointer; +} + +.dir-link:hover { + text-decoration: underline; +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..7e36a4e --- /dev/null +++ b/static/index.html @@ -0,0 +1,341 @@ + + + + + + FTP Server 管理 + + + + +
+ +
+ + + + + + + + + + + +
+ + + + diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..7c5a365 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,460 @@ +// 全局变量 +let token = localStorage.getItem('ftp_token') || ''; +let currentPath = ''; +let logPage = 1; + +// --- API 封装 --- +async function api(method, url, data) { + const opts = { + method, + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' + } + }; + if (data && method !== 'GET') { + opts.body = JSON.stringify(data); + } + const resp = await fetch(url, opts); + const json = await resp.json(); + if (resp.status === 401) { + token = ''; + localStorage.removeItem('ftp_token'); + showLogin(); + throw new Error('登录已过期'); + } + if (json.error) { + throw new Error(json.error); + } + return json.data; +} + +// --- 工具函数 --- +function showToast(msg, type = 'success') { + const toast = document.getElementById('toast'); + toast.textContent = msg; + toast.className = 'toast ' + type; + setTimeout(() => { toast.className = 'toast'; }, 3000); +} + +function formatBytes(bytes) { + if (!bytes || bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i]; +} + +function formatTime(t) { + if (!t) return '-'; + return t.replace('T', ' ').substring(0, 19); +} + +function showLogin() { + document.getElementById('login-page').style.display = 'flex'; + document.getElementById('main-app').style.display = 'none'; +} + +function showMain() { + document.getElementById('login-page').style.display = 'none'; + document.getElementById('main-app').style.display = 'flex'; +} + +// --- 登录 --- +document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const username = document.getElementById('login-username').value; + const password = document.getElementById('login-password').value; + try { + const data = await api('POST', '/api/login', { username, password }); + token = data.token; + localStorage.setItem('ftp_token', token); + showMain(); + loadDashboard(); + } catch (err) { + showToast(err.message, 'error'); + } +}); + +document.getElementById('logout-btn').addEventListener('click', () => { + token = ''; + localStorage.removeItem('ftp_token'); + showLogin(); +}); + +// --- 导航切换 --- +document.querySelectorAll('.sidebar-menu li').forEach(li => { + li.addEventListener('click', () => { + document.querySelectorAll('.sidebar-menu li').forEach(el => el.classList.remove('active')); + li.classList.add('active'); + const page = li.dataset.page; + document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); + document.getElementById('page-' + page).classList.add('active'); + loadPage(page); + }); +}); + +function loadPage(page) { + switch (page) { + case 'dashboard': loadDashboard(); break; + case 'users': loadUsers(); break; + case 'files': loadFiles(currentPath); break; + case 'logs': loadLogs(); break; + case 'online': loadOnline(); break; + case 'settings': loadConfig(); break; + } +} + +// --- 仪表盘 --- +async function loadDashboard() { + try { + const stats = await api('GET', '/api/dashboard'); + document.getElementById('stat-users').textContent = stats.total_users || 0; + document.getElementById('stat-enabled').textContent = stats.enabled_users || 0; + document.getElementById('stat-online').textContent = stats.online_users || 0; + document.getElementById('stat-today-logins').textContent = stats.today_logins || 0; + document.getElementById('stat-today-uploads').textContent = stats.today_uploads || 0; + document.getElementById('stat-today-downloads').textContent = stats.today_downloads || 0; + document.getElementById('stat-upload-bytes').textContent = formatBytes(stats.total_upload_bytes); + document.getElementById('stat-download-bytes').textContent = formatBytes(stats.total_download_bytes); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- 用户管理 --- +async function loadUsers() { + try { + const users = await api('GET', '/api/users'); + const tbody = document.getElementById('users-tbody'); + tbody.innerHTML = users.map(u => ` + + ${u.id} + ${u.username} + ${u.home_dir} + ${u.permissions} + ${u.quota_size > 0 ? formatBytes(u.quota_size) : '无限制'} + ${u.enabled ? '启用' : '禁用'} + ${formatTime(u.created_at)} + + + + + + + `).join(''); + } catch (err) { + showToast(err.message, 'error'); + } +} + +function showAddUser() { + document.getElementById('user-modal-title').textContent = '添加用户'; + document.getElementById('user-edit-mode').value = 'add'; + document.getElementById('user-form').reset(); + document.getElementById('user-username').disabled = false; + document.getElementById('user-password').required = true; + document.getElementById('user-enabled').checked = true; + document.getElementById('user-modal').style.display = 'flex'; +} + +async function editUser(username) { + try { + const user = await api('GET', '/api/users/' + username); + document.getElementById('user-modal-title').textContent = '编辑用户'; + document.getElementById('user-edit-mode').value = 'edit'; + document.getElementById('user-username').value = user.username; + document.getElementById('user-username').disabled = true; + document.getElementById('user-password').value = ''; + document.getElementById('user-password').required = false; + document.getElementById('user-homedir').value = user.home_dir; + document.getElementById('user-permissions').value = user.permissions; + document.getElementById('user-quota-size').value = Math.round(user.quota_size / 1024 / 1024); + document.getElementById('user-quota-files').value = user.quota_files; + document.getElementById('user-upload-rate').value = user.upload_rate; + document.getElementById('user-download-rate').value = user.download_rate; + document.getElementById('user-enabled').checked = user.enabled; + document.getElementById('user-modal').style.display = 'flex'; + } catch (err) { + showToast(err.message, 'error'); + } +} + +document.getElementById('user-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const mode = document.getElementById('user-edit-mode').value; + const username = document.getElementById('user-username').value; + const password = document.getElementById('user-password').value; + const homeDir = document.getElementById('user-homedir').value; + const quotaMB = parseInt(document.getElementById('user-quota-size').value) || 0; + + const data = { + username, + password, + home_dir: homeDir, + permissions: document.getElementById('user-permissions').value, + quota_size: quotaMB * 1024 * 1024, + quota_files: parseInt(document.getElementById('user-quota-files').value) || 0, + upload_rate: parseInt(document.getElementById('user-upload-rate').value) || 0, + download_rate: parseInt(document.getElementById('user-download-rate').value) || 0, + enabled: document.getElementById('user-enabled').checked + }; + + try { + if (mode === 'add') { + await api('POST', '/api/users', data); + showToast('用户添加成功'); + } else { + await api('PUT', '/api/users/' + username, data); + if (password) { + await api('PUT', '/api/users/' + username + '/password', { password }); + } + showToast('用户更新成功'); + } + closeUserModal(); + loadUsers(); + } catch (err) { + showToast(err.message, 'error'); + } +}); + +function closeUserModal() { + document.getElementById('user-modal').style.display = 'none'; +} + +async function deleteUser(username) { + if (!confirm('确定删除用户 "' + username + '" 吗?')) return; + try { + await api('DELETE', '/api/users/' + username); + showToast('用户已删除'); + loadUsers(); + } catch (err) { + showToast(err.message, 'error'); + } +} + +async function resetPassword(username) { + const password = prompt('请输入新密码:'); + if (!password) return; + try { + await api('PUT', '/api/users/' + username + '/password', { password }); + showToast('密码已更新'); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- 文件管理 --- +async function loadFiles(path) { + try { + const url = '/api/files?path=' + encodeURIComponent(path || ''); + const data = await api('GET', url); + currentPath = data.path; + const tbody = document.getElementById('files-tbody'); + let html = ''; + + // 返回上级 + if (currentPath) { + html += ` + [根目录] + `; + } + + data.files.forEach(f => { + if (f.is_dir) { + html += ` + 📁${f.name} + - + ${formatTime(f.mod_time)} + + + + `; + } else { + html += ` + 📄${f.name} + ${formatBytes(f.size)} + ${formatTime(f.mod_time)} + + + + `; + } + }); + + if (!data.files.length && !currentPath) { + html = '目录为空'; + } + + tbody.innerHTML = html; + + // 面包屑 + document.getElementById('file-breadcrumb').innerHTML = '/ ' + + currentPath.replace(/\\/g, '/').split('/').filter(Boolean).map((p, i, arr) => { + const subPath = arr.slice(0, i + 1).join('/'); + return '' + p + ''; + }).join(' / '); + } catch (err) { + showToast(err.message, 'error'); + } +} + +async function deleteFile(path, isDir) { + const name = path.split(/[\\/]/).pop(); + if (!confirm('确定删除 "' + name + '" 吗?' + (isDir ? '将删除文件夹内所有内容!' : ''))) return; + try { + await api('DELETE', '/api/files?path=' + encodeURIComponent(path)); + showToast('删除成功'); + loadFiles(currentPath); + } catch (err) { + showToast(err.message, 'error'); + } +} + +function uploadFile() { + document.getElementById('upload-form').reset(); + document.getElementById('upload-modal').style.display = 'flex'; +} + +function closeUploadModal() { + document.getElementById('upload-modal').style.display = 'none'; +} + +document.getElementById('upload-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const fileInput = document.getElementById('upload-file'); + if (!fileInput.files.length) return; + + const formData = new FormData(); + formData.append('file', fileInput.files[0]); + + try { + const resp = await fetch('/api/upload?path=' + encodeURIComponent(currentPath), { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token }, + body: formData + }); + const json = await resp.json(); + if (json.error) throw new Error(json.error); + showToast('上传成功'); + closeUploadModal(); + loadFiles(currentPath); + } catch (err) { + showToast(err.message, 'error'); + } +}); + +async function createFolder() { + const name = prompt('请输入文件夹名称:'); + if (!name) return; + try { + await api('POST', '/api/files', { path: currentPath, name, type: 'dir' }); + showToast('文件夹已创建'); + loadFiles(currentPath); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- 日志 --- +async function loadLogs() { + const username = document.getElementById('log-username').value; + const action = document.getElementById('log-action').value; + try { + const data = await api('GET', `/api/logs?username=${encodeURIComponent(username)}&action=${action}&page=${logPage}&page_size=20`); + const tbody = document.getElementById('logs-tbody'); + if (!data.logs || !data.logs.length) { + tbody.innerHTML = '暂无日志'; + } else { + tbody.innerHTML = data.logs.map(l => { + let statusClass = l.status === 'success' ? 'status-enabled' : 'status-disabled'; + return ` + ${formatTime(l.created_at)} + ${l.username || '-'} + ${l.ip || '-'} + ${l.action} + ${l.file_path || '-'} + ${l.file_size > 0 ? formatBytes(l.file_size) : '-'} + ${l.status} + `; + }).join(''); + } + + // 分页 + const totalPages = Math.ceil(data.total / 20); + let pagHtml = ``; + pagHtml += `第 ${logPage} / ${totalPages || 1} 页 (共 ${data.total} 条)`; + pagHtml += ``; + document.getElementById('logs-pagination').innerHTML = pagHtml; + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- 在线用户 --- +async function loadOnline() { + try { + const users = await api('GET', '/api/online'); + const tbody = document.getElementById('online-tbody'); + if (!users || !users.length) { + tbody.innerHTML = '暂无在线用户'; + } else { + tbody.innerHTML = users.map(u => ` + + ${u.username || '-'} + ${u.ip} + ${formatTime(u.login_time)} + ${formatTime(u.last_activity)} + ${u.current_dir || '-'} + + `).join(''); + } + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- 系统设置 --- +async function loadConfig() { + try { + const cfg = await api('GET', '/api/config'); + document.getElementById('cfg-ftp-port').value = cfg.ftp.port; + document.getElementById('cfg-ftp-passive-min').value = cfg.ftp.passive_port_min; + document.getElementById('cfg-ftp-passive-max').value = cfg.ftp.passive_port_max; + document.getElementById('cfg-ftp-max-conn').value = cfg.ftp.max_connections; + document.getElementById('cfg-ftp-idle-timeout').value = cfg.ftp.idle_timeout; + document.getElementById('cfg-ftp-anonymous').value = String(cfg.ftp.enable_anonymous); + } catch (err) { + showToast(err.message, 'error'); + } +} + +async function saveConfig() { + const data = { + ftp: { + port: parseInt(document.getElementById('cfg-ftp-port').value), + passive_port_min: parseInt(document.getElementById('cfg-ftp-passive-min').value), + passive_port_max: parseInt(document.getElementById('cfg-ftp-passive-max').value), + max_connections: parseInt(document.getElementById('cfg-ftp-max-conn').value), + idle_timeout: parseInt(document.getElementById('cfg-ftp-idle-timeout').value), + enable_anonymous: document.getElementById('cfg-ftp-anonymous').value === 'true' + }, + admin: { + username: document.getElementById('cfg-admin-username').value, + password: document.getElementById('cfg-admin-password').value + } + }; + try { + await api('PUT', '/api/config', data); + showToast('配置已保存,部分设置需要重启生效'); + } catch (err) { + showToast(err.message, 'error'); + } +} + +// --- 初始化 --- +if (token) { + showMain(); + loadDashboard(); +} else { + showLogin(); +} diff --git a/web/server.go b/web/server.go new file mode 100644 index 0000000..1acfea7 --- /dev/null +++ b/web/server.go @@ -0,0 +1,585 @@ +package web + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "ftp-server/config" + "ftp-server/database" + "ftp-server/ftp" + + "github.com/golang-jwt/jwt/v5" +) + +// Server Web管理服务器 +type Server struct { + config *config.Config + db *database.DB + ftpServer *ftp.Server + jwtSecret []byte +} + +// NewServer 创建Web服务器 +func NewServer(cfg *config.Config, db *database.DB, ftpSrv *ftp.Server) *Server { + secret := make([]byte, 32) + rand.Read(secret) + return &Server{ + config: cfg, + db: db, + ftpServer: ftpSrv, + jwtSecret: secret, + } +} + +// Start 启动Web服务 +func (s *Server) Start() error { + webCfg := s.config.Get().Web + + mux := http.NewServeMux() + + // 静态文件 + fs := http.FileServer(http.Dir("./static")) + mux.Handle("/", fs) + + // API路由 + mux.HandleFunc("/api/login", s.handleLogin) + mux.HandleFunc("/api/dashboard", s.authMiddleware(s.handleDashboard)) + mux.HandleFunc("/api/users", s.authMiddleware(s.handleUsers)) + mux.HandleFunc("/api/users/", s.authMiddleware(s.handleUserOperation)) + mux.HandleFunc("/api/files", s.authMiddleware(s.handleFileBrowse)) + mux.HandleFunc("/api/files/", s.authMiddleware(s.handleFileBrowse)) + mux.HandleFunc("/api/logs", s.authMiddleware(s.handleLogs)) + mux.HandleFunc("/api/online", s.authMiddleware(s.handleOnline)) + mux.HandleFunc("/api/config", s.authMiddleware(s.handleConfig)) + mux.HandleFunc("/api/upload", s.authMiddleware(s.handleUpload)) + + addr := fmt.Sprintf("%s:%d", webCfg.Host, webCfg.Port) + log.Printf("Web管理界面已启动: http://localhost:%d", webCfg.Port) + + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } + + return server.ListenAndServe() +} + +// --- 中间件 --- + +func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tokenStr := r.Header.Get("Authorization") + if tokenStr == "" { + // 也支持 cookie + if cookie, err := r.Cookie("token"); err == nil { + tokenStr = cookie.Value + } + } + + if strings.HasPrefix(tokenStr, "Bearer ") { + tokenStr = tokenStr[7:] + } + + if tokenStr == "" { + s.jsonError(w, "未登录", http.StatusUnauthorized) + return + } + + token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("无效的签名方法") + } + return s.jwtSecret, nil + }) + + if err != nil || !token.Valid { + s.jsonError(w, "登录已过期", http.StatusUnauthorized) + return + } + + next(w, r) + } +} + +// --- 请求处理 --- + +// handleLogin 登录 +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed) + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.jsonError(w, "请求格式错误", http.StatusBadRequest) + return + } + + adminCfg := s.config.Get().Admin + if req.Username != adminCfg.Username || req.Password != adminCfg.Password { + s.jsonError(w, "用户名或密码错误", http.StatusUnauthorized) + return + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "username": req.Username, + "exp": time.Now().Add(24 * time.Hour).Unix(), + }) + + tokenStr, err := token.SignedString(s.jwtSecret) + if err != nil { + s.jsonError(w, "生成令牌失败", http.StatusInternalServerError) + return + } + + s.jsonResponse(w, http.StatusOK, map[string]interface{}{ + "token": tokenStr, + }) +} + +// handleDashboard 仪表盘 +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { + stats, err := s.db.GetLogStats() + if err != nil { + s.jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + // 添加在线用户数 + if s.ftpServer != nil { + online := s.ftpServer.GetOnlineUsers() + stats["online_users"] = len(online) + } else { + stats["online_users"] = 0 + } + + s.jsonResponse(w, http.StatusOK, stats) +} + +// handleUsers 用户管理 +func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + users, err := s.db.ListUsers() + if err != nil { + s.jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + if users == nil { + users = []database.FTPUser{} + } + s.jsonResponse(w, http.StatusOK, users) + + case http.MethodPost: + var user database.FTPUser + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + s.jsonError(w, "请求格式错误", http.StatusBadRequest) + return + } + + if user.Username == "" || user.Password == "" { + s.jsonError(w, "用户名和密码不能为空", http.StatusBadRequest) + return + } + + if user.HomeDir == "" { + ftpCfg := s.config.Get().FTP + user.HomeDir = filepath.Join(ftpCfg.RootDir, user.Username) + } + + // 自动创建用户目录(如果不存在) + if err := os.MkdirAll(user.HomeDir, 0755); err != nil { + s.jsonError(w, fmt.Sprintf("创建用户目录失败: %v", err), http.StatusInternalServerError) + return + } + + if user.Permissions == "" { + user.Permissions = "read,write" + } + user.Enabled = true + + if err := s.db.CreateUser(&user); err != nil { + s.jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + s.jsonResponse(w, http.StatusOK, user) + + default: + s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed) + } +} + +// handleUserOperation 单个用户操作 +func (s *Server) handleUserOperation(w http.ResponseWriter, r *http.Request) { + // 解析路径中的用户名: /api/users/{username} 或 /api/users/{username}/password + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/users/"), "/") + username := pathParts[0] + + if username == "" { + s.jsonError(w, "用户名不能为空", http.StatusBadRequest) + return + } + + // 密码修改: PUT /api/users/{username}/password + if len(pathParts) > 1 && pathParts[1] == "password" && r.Method == http.MethodPut { + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.jsonError(w, "请求格式错误", http.StatusBadRequest) + return + } + if req.Password == "" { + s.jsonError(w, "密码不能为空", http.StatusBadRequest) + return + } + if err := s.db.UpdateUserPassword(username, req.Password); err != nil { + s.jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + s.jsonResponse(w, http.StatusOK, map[string]string{"message": "密码已更新"}) + return + } + + switch r.Method { + case http.MethodGet: + user, err := s.db.GetUser(username) + if err != nil { + s.jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + if user == nil { + s.jsonError(w, "用户不存在", http.StatusNotFound) + return + } + s.jsonResponse(w, http.StatusOK, user) + + case http.MethodPut: + var user database.FTPUser + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + s.jsonError(w, "请求格式错误", http.StatusBadRequest) + return + } + + // 如果目录有变化,自动创建新目录 + if user.HomeDir != "" { + if err := os.MkdirAll(user.HomeDir, 0755); err != nil { + s.jsonError(w, fmt.Sprintf("创建目录失败: %v", err), http.StatusInternalServerError) + return + } + } + + if err := s.db.UpdateUser(&user); err != nil { + s.jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + s.jsonResponse(w, http.StatusOK, map[string]string{"message": "用户已更新"}) + + case http.MethodDelete: + if err := s.db.DeleteUser(username); err != nil { + s.jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + s.jsonResponse(w, http.StatusOK, map[string]string{"message": "用户已删除"}) + + default: + s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed) + } +} + +// FileInfo 文件信息 +type FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + IsDir bool `json:"is_dir"` + ModTime time.Time `json:"mod_time"` + Path string `json:"path"` +} + +// handleFileBrowse 文件浏览 +func (s *Server) handleFileBrowse(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed) + return + } + + dir := r.URL.Query().Get("path") + if dir == "" { + dir = s.config.Get().FTP.RootDir + } + + // 安全检查:确保路径在FTP根目录内 + rootDir, err := filepath.Abs(s.config.Get().FTP.RootDir) + if err != nil { + s.jsonError(w, "无效的根目录", http.StatusInternalServerError) + return + } + + absPath, err := filepath.Abs(dir) + if err != nil { + s.jsonError(w, "无效的路径", http.StatusBadRequest) + return + } + + if !strings.HasPrefix(absPath, rootDir) { + s.jsonError(w, "路径超出允许范围", http.StatusForbidden) + return + } + + entries, err := os.ReadDir(absPath) + if err != nil { + if os.IsNotExist(err) { + s.jsonResponse(w, http.StatusOK, []FileInfo{}) + return + } + s.jsonError(w, fmt.Sprintf("读取目录失败: %v", err), http.StatusInternalServerError) + return + } + + var files []FileInfo + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, FileInfo{ + Name: entry.Name(), + Size: info.Size(), + IsDir: entry.IsDir(), + ModTime: info.ModTime(), + Path: filepath.Join(absPath, entry.Name()), + }) + } + + if files == nil { + files = []FileInfo{} + } + + s.jsonResponse(w, http.StatusOK, map[string]interface{}{ + "path": absPath, + "files": files, + }) +} + +// handleUpload 文件上传 +func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed) + return + } + + targetDir := r.URL.Query().Get("path") + if targetDir == "" { + targetDir = s.config.Get().FTP.RootDir + } + + // 安全检查 + rootDir, _ := filepath.Abs(s.config.Get().FTP.RootDir) + absDir, err := filepath.Abs(targetDir) + if err != nil || !strings.HasPrefix(absDir, rootDir) { + s.jsonError(w, "路径超出允许范围", http.StatusForbidden) + return + } + + r.ParseMultipartForm(100 << 20) // 100MB最大 + + file, header, err := r.FormFile("file") + if err != nil { + s.jsonError(w, "读取文件失败", http.StatusBadRequest) + return + } + defer file.Close() + + targetPath := filepath.Join(absDir, header.Filename) + dst, err := os.Create(targetPath) + if err != nil { + s.jsonError(w, "创建文件失败", http.StatusInternalServerError) + return + } + defer dst.Close() + + written, err := io.Copy(dst, file) + if err != nil { + s.jsonError(w, "写入文件失败", http.StatusInternalServerError) + return + } + + s.jsonResponse(w, http.StatusOK, map[string]interface{}{ + "message": "上传成功", + "size": written, + "path": targetPath, + }) +} + +// handleLogs 日志查询 +func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed) + return + } + + username := r.URL.Query().Get("username") + action := r.URL.Query().Get("action") + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size")) + + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + logs, total, err := s.db.QueryLogs(username, action, page, pageSize) + if err != nil { + s.jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + + if logs == nil { + logs = []database.FTPLog{} + } + + s.jsonResponse(w, http.StatusOK, map[string]interface{}{ + "logs": logs, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// handleOnline 在线用户 +func (s *Server) handleOnline(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed) + return + } + + var users []database.OnlineUser + if s.ftpServer != nil { + users = s.ftpServer.GetOnlineUsers() + } + if users == nil { + users = []database.OnlineUser{} + } + + s.jsonResponse(w, http.StatusOK, users) +} + +// handleConfig 配置管理 +func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + cfg := s.config.Get() + // 隐藏敏感信息 + safeCfg := map[string]interface{}{ + "ftp": map[string]interface{}{ + "host": cfg.FTP.Host, + "port": cfg.FTP.Port, + "passive_port_min": cfg.FTP.PassivePortMin, + "passive_port_max": cfg.FTP.PassivePortMax, + "root_dir": cfg.FTP.RootDir, + "enable_anonymous": cfg.FTP.EnableAnonymous, + "max_connections": cfg.FTP.MaxConnections, + "idle_timeout": cfg.FTP.IdleTimeout, + }, + "web": map[string]interface{}{ + "host": cfg.Web.Host, + "port": cfg.Web.Port, + }, + } + s.jsonResponse(w, http.StatusOK, safeCfg) + + case http.MethodPut: + var update map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + s.jsonError(w, "请求格式错误", http.StatusBadRequest) + return + } + + s.config.Update(func(cfg *config.Config) { + if ftpCfg, ok := update["ftp"].(map[string]interface{}); ok { + if v, ok := ftpCfg["port"].(float64); ok { + cfg.FTP.Port = int(v) + } + if v, ok := ftpCfg["passive_port_min"].(float64); ok { + cfg.FTP.PassivePortMin = int(v) + } + if v, ok := ftpCfg["passive_port_max"].(float64); ok { + cfg.FTP.PassivePortMax = int(v) + } + if v, ok := ftpCfg["enable_anonymous"].(bool); ok { + cfg.FTP.EnableAnonymous = v + } + if v, ok := ftpCfg["max_connections"].(float64); ok { + cfg.FTP.MaxConnections = int(v) + } + if v, ok := ftpCfg["idle_timeout"].(float64); ok { + cfg.FTP.IdleTimeout = int(v) + } + } + if adminCfg, ok := update["admin"].(map[string]interface{}); ok { + if v, ok := adminCfg["username"].(string); ok && v != "" { + cfg.Admin.Username = v + } + if v, ok := adminCfg["password"].(string); ok && v != "" { + cfg.Admin.Password = v + } + } + }) + + if err := s.config.Save("config.json"); err != nil { + s.jsonError(w, fmt.Sprintf("保存配置失败: %v", err), http.StatusInternalServerError) + return + } + + s.jsonResponse(w, http.StatusOK, map[string]string{"message": "配置已保存,部分设置需要重启生效"}) + + default: + s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed) + } +} + +// --- 工具方法 --- + +func (s *Server) jsonResponse(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": status, + "data": data, + }) +} + +func (s *Server) jsonError(w http.ResponseWriter, msg string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": status, + "error": msg, + }) +} + +// GenerateToken 生成随机令牌(用于JWT密钥) +func GenerateToken() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +}