feat: 初始化FTP服务器项目 - 支持Web管理界面

This commit is contained in:
Your Name
2026-05-06 17:32:38 +08:00
commit 1d36000b80
11 changed files with 2714 additions and 0 deletions
+27
View File
@@ -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
+122
View File
@@ -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)
}
+330
View File
@@ -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()
}
+185
View File
@@ -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未配置")
}
+23
View File
@@ -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
)
+69
View File
@@ -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=
+74
View File
@@ -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("服务器已停止")
}
+498
View File
@@ -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;
}
+341
View File
@@ -0,0 +1,341 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FTP Server 管理</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<!-- 登录页面 -->
<div id="login-page" class="login-page">
<div class="login-card">
<h1>FTP Server</h1>
<p>Web 管理控制台</p>
<form id="login-form">
<div class="form-group">
<label>用户名</label>
<input type="text" id="login-username" value="admin" required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="login-password" value="admin123" required>
</div>
<button type="submit" class="btn btn-primary btn-block">登 录</button>
</form>
</div>
</div>
<!-- 主界面 -->
<div id="main-app" class="main-app" style="display:none">
<!-- 侧边栏 -->
<nav class="sidebar">
<div class="sidebar-header">
<h2>FTP Server</h2>
</div>
<ul class="sidebar-menu">
<li class="active" data-page="dashboard">
<span class="icon">&#9632;</span> 仪表盘
</li>
<li data-page="users">
<span class="icon">&#9775;</span> 用户管理
</li>
<li data-page="files">
<span class="icon">&#128193;</span> 文件管理
</li>
<li data-page="logs">
<span class="icon">&#128196;</span> 操作日志
</li>
<li data-page="online">
<span class="icon">&#128279;</span> 在线用户
</li>
<li data-page="settings">
<span class="icon">&#9881;</span> 系统设置
</li>
</ul>
<div class="sidebar-footer">
<button id="logout-btn" class="btn btn-sm">退出登录</button>
</div>
</nav>
<!-- 内容区 -->
<main class="content">
<!-- 仪表盘 -->
<div id="page-dashboard" class="page active">
<h2>仪表盘</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="stat-users">-</div>
<div class="stat-label">总用户数</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-enabled">-</div>
<div class="stat-label">启用用户</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-online">-</div>
<div class="stat-label">在线用户</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-today-logins">-</div>
<div class="stat-label">今日登录</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-today-uploads">-</div>
<div class="stat-label">今日上传</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-today-downloads">-</div>
<div class="stat-label">今日下载</div>
</div>
</div>
<div class="stats-grid" style="margin-top:20px">
<div class="stat-card wide">
<div class="stat-value" id="stat-upload-bytes">-</div>
<div class="stat-label">总上传量</div>
</div>
<div class="stat-card wide">
<div class="stat-value" id="stat-download-bytes">-</div>
<div class="stat-label">总下载量</div>
</div>
</div>
</div>
<!-- 用户管理 -->
<div id="page-users" class="page">
<div class="page-header">
<h2>用户管理</h2>
<button class="btn btn-primary" onclick="showAddUser()">添加用户</button>
</div>
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>主目录</th>
<th>权限</th>
<th>配额</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
<!-- 文件管理 -->
<div id="page-files" class="page">
<div class="page-header">
<h2>文件管理</h2>
<div class="file-actions">
<button class="btn btn-primary" onclick="uploadFile()">上传文件</button>
<button class="btn" onclick="createFolder()">新建文件夹</button>
</div>
</div>
<div class="breadcrumb" id="file-breadcrumb">
<span>/</span>
</div>
<table class="data-table">
<thead>
<tr>
<th>名称</th>
<th>大小</th>
<th>修改时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="files-tbody"></tbody>
</table>
</div>
<!-- 操作日志 -->
<div id="page-logs" class="page">
<div class="page-header">
<h2>操作日志</h2>
<div class="filter-bar">
<input type="text" id="log-username" placeholder="用户名筛选" class="input-sm">
<select id="log-action" class="input-sm">
<option value="">全部操作</option>
<option value="login">登录</option>
<option value="login_failed">登录失败</option>
<option value="upload">上传</option>
<option value="download">下载</option>
<option value="delete">删除</option>
</select>
<button class="btn btn-sm" onclick="loadLogs()">查询</button>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>用户</th>
<th>IP</th>
<th>操作</th>
<th>文件路径</th>
<th>大小</th>
<th>状态</th>
</tr>
</thead>
<tbody id="logs-tbody"></tbody>
</table>
<div class="pagination" id="logs-pagination"></div>
</div>
<!-- 在线用户 -->
<div id="page-online" class="page">
<h2>在线用户</h2>
<table class="data-table">
<thead>
<tr>
<th>用户名</th>
<th>IP地址</th>
<th>登录时间</th>
<th>最后活动</th>
<th>当前目录</th>
</tr>
</thead>
<tbody id="online-tbody"></tbody>
</table>
</div>
<!-- 系统设置 -->
<div id="page-settings" class="page">
<h2>系统设置</h2>
<div class="settings-section">
<h3>FTP设置</h3>
<div class="form-grid">
<div class="form-group">
<label>FTP端口</label>
<input type="number" id="cfg-ftp-port" class="input-sm">
</div>
<div class="form-group">
<label>被动端口范围(起始)</label>
<input type="number" id="cfg-ftp-passive-min" class="input-sm">
</div>
<div class="form-group">
<label>被动端口范围(结束)</label>
<input type="number" id="cfg-ftp-passive-max" class="input-sm">
</div>
<div class="form-group">
<label>最大连接数</label>
<input type="number" id="cfg-ftp-max-conn" class="input-sm">
</div>
<div class="form-group">
<label>空闲超时(秒)</label>
<input type="number" id="cfg-ftp-idle-timeout" class="input-sm">
</div>
<div class="form-group">
<label>启用匿名访问</label>
<select id="cfg-ftp-anonymous" class="input-sm">
<option value="true"></option>
<option value="false"></option>
</select>
</div>
</div>
</div>
<div class="settings-section">
<h3>管理员设置</h3>
<div class="form-grid">
<div class="form-group">
<label>管理员用户名</label>
<input type="text" id="cfg-admin-username" class="input-sm">
</div>
<div class="form-group">
<label>新密码(留空不修改)</label>
<input type="password" id="cfg-admin-password" class="input-sm" placeholder="留空不修改">
</div>
</div>
</div>
<button class="btn btn-primary" onclick="saveConfig()" style="margin-top:20px">保存设置</button>
</div>
</main>
</div>
<!-- 添加/编辑用户对话框 -->
<div id="user-modal" class="modal" style="display:none">
<div class="modal-content">
<div class="modal-header">
<h3 id="user-modal-title">添加用户</h3>
<span class="modal-close" onclick="closeUserModal()">&times;</span>
</div>
<form id="user-form">
<input type="hidden" id="user-edit-mode" value="add">
<div class="form-group">
<label>用户名</label>
<input type="text" id="user-username" required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="user-password" required>
</div>
<div class="form-group">
<label>主目录</label>
<input type="text" id="user-homedir" placeholder="留空自动设置为 /ftp_root/用户名">
<small>目录不存在时将自动创建</small>
</div>
<div class="form-group">
<label>权限</label>
<select id="user-permissions">
<option value="read">只读</option>
<option value="write">只写</option>
<option value="read,write" selected>读写</option>
</select>
</div>
<div class="form-grid">
<div class="form-group">
<label>空间配额(MB, 0=无限制)</label>
<input type="number" id="user-quota-size" value="0">
</div>
<div class="form-group">
<label>文件数配额(0=无限制)</label>
<input type="number" id="user-quota-files" value="0">
</div>
<div class="form-group">
<label>上传限速(KB/s, 0=无限制)</label>
<input type="number" id="user-upload-rate" value="0">
</div>
<div class="form-group">
<label>下载限速(KB/s, 0=无限制)</label>
<input type="number" id="user-download-rate" value="0">
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="user-enabled" checked> 启用账户
</label>
</div>
<div class="modal-footer">
<button type="button" class="btn" onclick="closeUserModal()">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<!-- 文件上传对话框 -->
<div id="upload-modal" class="modal" style="display:none">
<div class="modal-content">
<div class="modal-header">
<h3>上传文件</h3>
<span class="modal-close" onclick="closeUploadModal()">&times;</span>
</div>
<form id="upload-form">
<div class="form-group">
<input type="file" id="upload-file" required>
</div>
<div class="modal-footer">
<button type="button" class="btn" onclick="closeUploadModal()">取消</button>
<button type="submit" class="btn btn-primary">上传</button>
</div>
</form>
</div>
</div>
<!-- 提示消息 -->
<div id="toast" class="toast"></div>
<script src="/js/app.js"></script>
</body>
</html>
+460
View File
@@ -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 => `
<tr>
<td>${u.id}</td>
<td>${u.username}</td>
<td title="${u.home_dir}">${u.home_dir}</td>
<td>${u.permissions}</td>
<td>${u.quota_size > 0 ? formatBytes(u.quota_size) : '无限制'}</td>
<td><span class="${u.enabled ? 'status-enabled' : 'status-disabled'}">${u.enabled ? '启用' : '禁用'}</span></td>
<td>${formatTime(u.created_at)}</td>
<td class="action-btns">
<button class="btn btn-sm" onclick="editUser('${u.username}')">编辑</button>
<button class="btn btn-sm" onclick="resetPassword('${u.username}')">改密</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('${u.username}')">删除</button>
</td>
</tr>
`).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 += `<tr>
<td colspan="4"><span class="dir-link" onclick="loadFiles('')">[根目录]</span></td>
</tr>`;
}
data.files.forEach(f => {
if (f.is_dir) {
html += `<tr>
<td><span class="file-icon">&#128193;</span><span class="dir-link" onclick="loadFiles('${f.path.replace(/\\/g, '\\\\')}')">${f.name}</span></td>
<td>-</td>
<td>${formatTime(f.mod_time)}</td>
<td class="action-btns">
<button class="btn btn-sm btn-danger" onclick="deleteFile('${f.path.replace(/\\/g, '\\\\')}', true)">删除</button>
</td>
</tr>`;
} else {
html += `<tr>
<td><span class="file-icon">&#128196;</span>${f.name}</td>
<td>${formatBytes(f.size)}</td>
<td>${formatTime(f.mod_time)}</td>
<td class="action-btns">
<button class="btn btn-sm btn-danger" onclick="deleteFile('${f.path.replace(/\\/g, '\\\\')}', false)">删除</button>
</td>
</tr>`;
}
});
if (!data.files.length && !currentPath) {
html = '<tr><td colspan="4" style="text-align:center;color:#999;padding:40px">目录为空</td></tr>';
}
tbody.innerHTML = html;
// 面包屑
document.getElementById('file-breadcrumb').innerHTML = '<span onclick="loadFiles(\'\')">/</span> ' +
currentPath.replace(/\\/g, '/').split('/').filter(Boolean).map((p, i, arr) => {
const subPath = arr.slice(0, i + 1).join('/');
return '<span onclick="loadFiles(\'' + subPath + '\')">' + p + '</span>';
}).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 = '<tr><td colspan="7" style="text-align:center;color:#999;padding:40px">暂无日志</td></tr>';
} else {
tbody.innerHTML = data.logs.map(l => {
let statusClass = l.status === 'success' ? 'status-enabled' : 'status-disabled';
return `<tr>
<td>${formatTime(l.created_at)}</td>
<td>${l.username || '-'}</td>
<td>${l.ip || '-'}</td>
<td>${l.action}</td>
<td title="${l.file_path}">${l.file_path || '-'}</td>
<td>${l.file_size > 0 ? formatBytes(l.file_size) : '-'}</td>
<td><span class="${statusClass}">${l.status}</span></td>
</tr>`;
}).join('');
}
// 分页
const totalPages = Math.ceil(data.total / 20);
let pagHtml = `<button ${logPage <= 1 ? 'disabled' : ''} onclick="logPage=${logPage - 1};loadLogs()">上一页</button>`;
pagHtml += `<span style="padding:6px 12px">第 ${logPage} / ${totalPages || 1} 页 (共 ${data.total} 条)</span>`;
pagHtml += `<button ${logPage >= totalPages ? 'disabled' : ''} onclick="logPage=${logPage + 1};loadLogs()">下一页</button>`;
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 = '<tr><td colspan="5" style="text-align:center;color:#999;padding:40px">暂无在线用户</td></tr>';
} else {
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.username || '-'}</td>
<td>${u.ip}</td>
<td>${formatTime(u.login_time)}</td>
<td>${formatTime(u.last_activity)}</td>
<td>${u.current_dir || '-'}</td>
</tr>
`).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();
}
+585
View File
@@ -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)
}