feat: initial FTP Server for Windows with web admin panel

This commit is contained in:
Your Name
2026-04-28 20:51:57 +08:00
commit 799d814503
7 changed files with 1865 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Binaries
*.exe
ftp-server
# Config (contains passwords)
config.json
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
Thumbs.db
Desktop.ini
.DS_Store
# Build output
ftp_root/
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"ftp-server/config"
ftpserver "ftp-server/ftp"
webserver "ftp-server/web"
)
func main() {
configPath := flag.String("config", "config.json", "配置文件路径")
flag.Parse()
// Load configuration
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
fmt.Println("======================================")
fmt.Println(" FTP Server for Windows")
fmt.Println("======================================")
fmt.Printf("FTP Port: %d\n", cfg.FTP.Port)
fmt.Printf("Web Admin: http://127.0.0.1:%d\n", cfg.Web.Port)
fmt.Printf("Admin User: %s\n", cfg.Admin.Username)
fmt.Printf("Root Dir: %s\n", cfg.FTP.RootDir)
fmt.Println("======================================")
// Ensure root directory exists
if err := os.MkdirAll(cfg.FTP.RootDir, 0755); err != nil {
log.Fatalf("Failed to create root directory: %v", err)
}
// Start FTP server
ftpSrv := ftpserver.NewServer(cfg, *configPath)
if err := ftpSrv.Start(); err != nil {
log.Fatalf("Failed to start FTP server: %v", err)
}
// Start Web admin server
webSrv := webserver.NewServer(cfg, *configPath)
go func() {
if err := webSrv.Start(); err != nil {
log.Fatalf("Failed to start web server: %v", err)
}
}()
// Wait for termination signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigChan
fmt.Printf("\nReceived signal %v, shutting down...\n", sig)
ftpSrv.Stop()
fmt.Println("FTP Server stopped.")
}
+171
View File
@@ -0,0 +1,171 @@
package config
import (
"encoding/json"
"os"
"sync"
)
// Config represents the application configuration
type Config struct {
mu sync.RWMutex `json:"-"`
FTP FTPConfig `json:"ftp"`
Web WebConfig `json:"web"`
Admin AdminConfig `json:"admin"`
FTPUsers []FTPUser `json:"ftpUsers"`
}
// FTPConfig holds FTP server settings
type FTPConfig struct {
Host string `json:"host"`
Port int `json:"port"`
PassiveMin int `json:"passivePortMin"`
PassiveMax int `json:"passivePortMax"`
RootDir string `json:"rootDir"`
}
// WebConfig holds web admin panel settings
type WebConfig struct {
Host string `json:"host"`
Port int `json:"port"`
}
// AdminConfig holds admin credentials
type AdminConfig struct {
Username string `json:"username"`
Password string `json:"password"`
}
// FTPUser represents an FTP user account
type FTPUser struct {
Username string `json:"username"`
Password string `json:"password"`
HomeDir string `json:"homeDir"`
Write bool `json:"write"`
}
// DefaultConfig returns a default configuration
func DefaultConfig() *Config {
return &Config{
FTP: FTPConfig{
Host: "0.0.0.0",
Port: 2121,
PassiveMin: 50000,
PassiveMax: 50100,
RootDir: "./ftp_root",
},
Web: WebConfig{
Host: "0.0.0.0",
Port: 8080,
},
Admin: AdminConfig{
Username: "admin",
Password: "admin123",
},
FTPUsers: []FTPUser{
{
Username: "ftpuser",
Password: "ftp123",
HomeDir: "./ftp_root",
Write: true,
},
},
}
}
// Load loads configuration from a file
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
cfg := DefaultConfig()
if saveErr := cfg.Save(path); saveErr != nil {
return nil, saveErr
}
return cfg, nil
}
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
// Save saves configuration to a file
func (c *Config) Save(path string) error {
c.mu.RLock()
defer c.mu.RUnlock()
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// GetFTPUser finds an FTP user by username
func (c *Config) GetFTPUser(username string) *FTPUser {
c.mu.RLock()
defer c.mu.RUnlock()
for i := range c.FTPUsers {
if c.FTPUsers[i].Username == username {
return &c.FTPUsers[i]
}
}
return nil
}
// AddFTPUser adds a new FTP user
func (c *Config) AddFTPUser(user FTPUser) {
c.mu.Lock()
defer c.mu.Unlock()
c.FTPUsers = append(c.FTPUsers, user)
}
// DeleteFTPUser removes an FTP user by username
func (c *Config) DeleteFTPUser(username string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for i, u := range c.FTPUsers {
if u.Username == username {
c.FTPUsers = append(c.FTPUsers[:i], c.FTPUsers[i+1:]...)
return true
}
}
return false
}
// UpdateFTPUser updates an existing FTP user
func (c *Config) UpdateFTPUser(username string, user FTPUser) bool {
c.mu.Lock()
defer c.mu.Unlock()
for i, u := range c.FTPUsers {
if u.Username == username {
c.FTPUsers[i] = user
return true
}
}
return false
}
// AuthenticateAdmin checks admin credentials
func (c *Config) AuthenticateAdmin(username, password string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.Admin.Username == username && c.Admin.Password == password
}
// GetFTPUsers returns a copy of all FTP users
func (c *Config) GetFTPUsers() []FTPUser {
c.mu.RLock()
defer c.mu.RUnlock()
users := make([]FTPUser, len(c.FTPUsers))
copy(users, c.FTPUsers)
return users
}
+701
View File
@@ -0,0 +1,701 @@
package ftp
import (
"bufio"
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"ftp-server/config"
)
// Server represents an FTP server
type Server struct {
config *config.Config
configPath string
listener net.Listener
clients map[net.Conn]*clientConn
mu sync.Mutex
}
type clientConn struct {
conn net.Conn
server *Server
reader *bufio.Reader
username string
authenticated bool
cwd string
dataHost string
dataPort int
pasvMode bool
pasvListener net.Listener
transferType string
renameFrom string
}
// NewServer creates a new FTP server
func NewServer(cfg *config.Config, configPath string) *Server {
return &Server{
config: cfg,
configPath: configPath,
clients: make(map[net.Conn]*clientConn),
}
}
// Start starts the FTP server
func (s *Server) Start() error {
addr := fmt.Sprintf("%s:%d", s.config.FTP.Host, s.config.FTP.Port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("FTP server listen error: %v", err)
}
s.listener = listener
// Ensure root directory exists
rootDir := s.config.FTP.RootDir
if err := os.MkdirAll(rootDir, 0755); err != nil {
return fmt.Errorf("failed to create root directory: %v", err)
}
log.Printf("FTP server listening on %s, root dir: %s", addr, rootDir)
go s.acceptLoop()
return nil
}
// Stop stops the FTP server
func (s *Server) Stop() {
if s.listener != nil {
s.listener.Close()
}
s.mu.Lock()
for conn, client := range s.clients {
client.pasvListener.Close()
conn.Close()
}
s.mu.Unlock()
}
func (s *Server) acceptLoop() {
for {
conn, err := s.listener.Accept()
if err != nil {
log.Printf("FTP accept error: %v", err)
return
}
client := &clientConn{
conn: conn,
server: s,
reader: bufio.NewReader(conn),
cwd: "/",
transferType: "ASCII",
}
s.mu.Lock()
s.clients[conn] = client
s.mu.Unlock()
log.Printf("FTP client connected: %s", conn.RemoteAddr())
client.sendResponse(220, "FTP Server Ready")
go client.handle()
}
}
func (c *clientConn) sendResponse(code int, message string) {
fmt.Fprintf(c.conn, "%d %s\r\n", code, message)
}
func (c *clientConn) sendMultiResponse(code int, lines []string) {
for i, line := range lines {
if i == len(lines)-1 {
fmt.Fprintf(c.conn, "%d %s\r\n", code, line)
} else {
fmt.Fprintf(c.conn, "%d-%s\r\n", code, line)
}
}
}
func (c *clientConn) handle() {
defer func() {
c.conn.Close()
if c.pasvListener != nil {
c.pasvListener.Close()
}
c.server.mu.Lock()
delete(c.server.clients, c.conn)
c.server.mu.Unlock()
log.Printf("FTP client disconnected: %s", c.conn.RemoteAddr())
}()
for {
c.conn.SetDeadline(time.Now().Add(5 * time.Minute))
line, err := c.reader.ReadString('\n')
if err != nil {
if err != io.EOF {
log.Printf("FTP read error: %v", err)
}
return
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
continue
}
log.Printf("FTP [%s] %s", c.conn.RemoteAddr(), line)
c.processCommand(line)
}
}
func (c *clientConn) processCommand(line string) {
parts := strings.SplitN(line, " ", 2)
cmd := strings.ToUpper(parts[0])
var args string
if len(parts) > 1 {
args = parts[1]
}
switch cmd {
case "USER":
c.handleUSER(args)
case "PASS":
c.handlePASS(args)
case "QUIT":
c.handleQUIT()
case "PWD":
c.handlePWD()
case "CWD":
c.handleCWD(args)
case "CDUP":
c.handleCDUP()
case "TYPE":
c.handleTYPE(args)
case "PASV":
c.handlePASV()
case "PORT":
c.handlePORT(args)
case "LIST":
c.handleLIST(args)
case "NLST":
c.handleNLST(args)
case "RETR":
c.handleRETR(args)
case "STOR":
c.handleSTOR(args)
case "DELE":
c.handleDELE(args)
case "MKD":
c.handleMKD(args)
case "RMD":
c.handleRMD(args)
case "RNFR":
c.handleRNFR(args)
case "RNTO":
c.handleRNTO(args)
case "SIZE":
c.handleSIZE(args)
case "SYST":
c.sendResponse(215, "UNIX Type: L8")
case "FEAT":
c.handleFEAT()
case "NOOP":
c.sendResponse(200, "OK")
case "OPTS":
c.handleOPTS(args)
default:
c.sendResponse(502, fmt.Sprintf("Command %s not implemented", cmd))
}
}
func (c *clientConn) handleUSER(args string) {
if args == "" {
c.sendResponse(501, "Syntax error: USER <username>")
return
}
c.username = args
c.authenticated = false
c.sendResponse(331, "Password required")
}
func (c *clientConn) handlePASS(args string) {
if c.username == "" {
c.sendResponse(503, "Login with USER first")
return
}
user := c.server.config.GetFTPUser(c.username)
if user != nil && user.Password == args {
c.authenticated = true
// Set home directory as CWD
homeDir := user.HomeDir
if homeDir == "" {
homeDir = c.server.config.FTP.RootDir
}
c.cwd = "/"
c.sendResponse(230, "Login successful")
log.Printf("FTP user '%s' logged in from %s", c.username, c.conn.RemoteAddr())
} else {
c.username = ""
c.sendResponse(530, "Login incorrect")
}
}
func (c *clientConn) requireAuth() bool {
if !c.authenticated {
c.sendResponse(530, "Please login first")
return false
}
return true
}
func (c *clientConn) handleQUIT() {
c.sendResponse(221, "Goodbye")
c.conn.Close()
}
func (c *clientConn) handlePWD() {
if !c.requireAuth() {
return
}
c.sendResponse(257, fmt.Sprintf("\"%s\" is current directory", c.cwd))
}
func (c *clientConn) handleCWD(args string) {
if !c.requireAuth() {
return
}
newPath := c.resolvePath(args)
realPath := c.toRealPath(newPath)
info, err := os.Stat(realPath)
if err != nil || !info.IsDir() {
c.sendResponse(550, "Directory not found")
return
}
c.cwd = newPath
c.sendResponse(250, fmt.Sprintf("Directory changed to %s", c.cwd))
}
func (c *clientConn) handleCDUP() {
if !c.requireAuth() {
return
}
if c.cwd != "/" {
c.cwd = filepath.Dir(c.cwd)
if c.cwd == "" {
c.cwd = "/"
}
}
c.sendResponse(250, fmt.Sprintf("Directory changed to %s", c.cwd))
}
func (c *clientConn) handleTYPE(args string) {
args = strings.ToUpper(args)
switch args {
case "A", "A N":
c.transferType = "ASCII"
c.sendResponse(200, "Type set to ASCII")
case "I", "L 8":
c.transferType = "BINARY"
c.sendResponse(200, "Type set to Binary")
default:
c.sendResponse(504, "Type not supported")
}
}
func (c *clientConn) handlePASV() {
if !c.requireAuth() {
return
}
if c.pasvListener != nil {
c.pasvListener.Close()
}
listener, err := net.Listen("tcp", "0.0.0.0:0")
if err != nil {
c.sendResponse(425, "Cannot open passive connection")
return
}
c.pasvListener = listener
c.pasvMode = true
addr := listener.Addr().(*net.TCPAddr)
hostParts := strings.Split(c.conn.LocalAddr().(*net.TCPAddr).IP.String(), ".")
p1 := addr.Port / 256
p2 := addr.Port % 256
c.sendResponse(227, fmt.Sprintf("Entering Passive Mode (%s,%s,%s,%s,%d,%d)",
hostParts[0], hostParts[1], hostParts[2], hostParts[3], p1, p2))
}
func (c *clientConn) handlePORT(args string) {
if !c.requireAuth() {
return
}
parts := strings.Split(args, ",")
if len(parts) != 6 {
c.sendResponse(501, "Syntax error in PORT command")
return
}
host := strings.Join(parts[0:4], ".")
p1, _ := strconv.Atoi(parts[4])
p2, _ := strconv.Atoi(parts[5])
c.dataHost = host
c.dataPort = p1*256 + p2
c.pasvMode = false
c.sendResponse(200, "PORT command successful")
}
func (c *clientConn) getDataConn() (net.Conn, error) {
if c.pasvMode && c.pasvListener != nil {
conn, err := c.pasvListener.Accept()
c.pasvListener.Close()
c.pasvListener = nil
return conn, err
}
addr := fmt.Sprintf("%s:%d", c.dataHost, c.dataPort)
return net.DialTimeout("tcp", addr, 10*time.Second)
}
func (c *clientConn) handleLIST(args string) {
if !c.requireAuth() {
return
}
targetPath := args
if targetPath == "" || targetPath == "-a" || targetPath == "-la" || targetPath == "-al" {
targetPath = c.cwd
} else if !strings.HasPrefix(targetPath, "/") {
targetPath = c.resolvePath(targetPath)
}
realPath := c.toRealPath(targetPath)
entries, err := os.ReadDir(realPath)
if err != nil {
c.sendResponse(550, "Directory listing failed")
return
}
dataConn, err := c.getDataConn()
if err != nil {
c.sendResponse(425, "Cannot open data connection")
return
}
defer dataConn.Close()
c.sendResponse(150, "Opening data connection for directory listing")
var lines []string
for _, entry := range entries {
info, _ := entry.Info()
modTime := info.ModTime()
perm := "-rwxr-xr-x"
if info.IsDir() {
perm = "drwxr-xr-x"
}
line := fmt.Sprintf("%s 1 ftp ftp %12d %s %s",
perm,
info.Size(),
modTime.Format("Jan 02 15:04"),
entry.Name(),
)
lines = append(lines, line)
}
if len(lines) == 0 {
dataConn.Write([]byte(""))
} else {
dataConn.Write([]byte(strings.Join(lines, "\r\n") + "\r\n"))
}
c.sendResponse(226, "Transfer complete")
}
func (c *clientConn) handleNLST(args string) {
if !c.requireAuth() {
return
}
targetPath := args
if targetPath == "" {
targetPath = c.cwd
}
realPath := c.toRealPath(c.resolvePath(targetPath))
entries, err := os.ReadDir(realPath)
if err != nil {
c.sendResponse(550, "Directory listing failed")
return
}
dataConn, err := c.getDataConn()
if err != nil {
c.sendResponse(425, "Cannot open data connection")
return
}
defer dataConn.Close()
c.sendResponse(150, "Opening data connection")
var names []string
for _, entry := range entries {
names = append(names, entry.Name())
}
dataConn.Write([]byte(strings.Join(names, "\r\n") + "\r\n"))
c.sendResponse(226, "Transfer complete")
}
func (c *clientConn) handleRETR(args string) {
if !c.requireAuth() {
return
}
filePath := c.resolvePath(args)
realPath := c.toRealPath(filePath)
file, err := os.Open(realPath)
if err != nil {
c.sendResponse(550, "File not found")
return
}
defer file.Close()
dataConn, err := c.getDataConn()
if err != nil {
c.sendResponse(425, "Cannot open data connection")
return
}
defer dataConn.Close()
c.sendResponse(150, "Opening data connection")
written, err := io.Copy(dataConn, file)
if err != nil {
c.sendResponse(426, "Transfer aborted")
return
}
log.Printf("FTP RETR %s (%d bytes)", filePath, written)
c.sendResponse(226, "Transfer complete")
}
func (c *clientConn) handleSTOR(args string) {
if !c.requireAuth() {
return
}
// Check write permission
user := c.server.config.GetFTPUser(c.username)
if user == nil || !user.Write {
c.sendResponse(550, "Permission denied")
return
}
filePath := c.resolvePath(args)
realPath := c.toRealPath(filePath)
// Ensure parent directory exists
os.MkdirAll(filepath.Dir(realPath), 0755)
file, err := os.Create(realPath)
if err != nil {
c.sendResponse(550, "Cannot create file")
return
}
defer file.Close()
dataConn, err := c.getDataConn()
if err != nil {
c.sendResponse(425, "Cannot open data connection")
return
}
defer dataConn.Close()
c.sendResponse(150, "Opening data connection")
written, err := io.Copy(file, dataConn)
if err != nil {
c.sendResponse(426, "Transfer aborted")
return
}
log.Printf("FTP STOR %s (%d bytes)", filePath, written)
c.sendResponse(226, "Transfer complete")
}
func (c *clientConn) handleDELE(args string) {
if !c.requireAuth() {
return
}
user := c.server.config.GetFTPUser(c.username)
if user == nil || !user.Write {
c.sendResponse(550, "Permission denied")
return
}
filePath := c.resolvePath(args)
realPath := c.toRealPath(filePath)
if err := os.Remove(realPath); err != nil {
c.sendResponse(550, "Delete failed")
return
}
c.sendResponse(250, "File deleted")
}
func (c *clientConn) handleMKD(args string) {
if !c.requireAuth() {
return
}
user := c.server.config.GetFTPUser(c.username)
if user == nil || !user.Write {
c.sendResponse(550, "Permission denied")
return
}
dirPath := c.resolvePath(args)
realPath := c.toRealPath(dirPath)
if err := os.MkdirAll(realPath, 0755); err != nil {
c.sendResponse(550, "Cannot create directory")
return
}
c.sendResponse(257, fmt.Sprintf("\"%s\" created", dirPath))
}
func (c *clientConn) handleRMD(args string) {
if !c.requireAuth() {
return
}
user := c.server.config.GetFTPUser(c.username)
if user == nil || !user.Write {
c.sendResponse(550, "Permission denied")
return
}
dirPath := c.resolvePath(args)
realPath := c.toRealPath(dirPath)
if err := os.RemoveAll(realPath); err != nil {
c.sendResponse(550, "Cannot remove directory")
return
}
c.sendResponse(250, "Directory removed")
}
func (c *clientConn) handleRNFR(args string) {
if !c.requireAuth() {
return
}
user := c.server.config.GetFTPUser(c.username)
if user == nil || !user.Write {
c.sendResponse(550, "Permission denied")
return
}
filePath := c.resolvePath(args)
realPath := c.toRealPath(filePath)
if _, err := os.Stat(realPath); err != nil {
c.sendResponse(550, "File not found")
return
}
c.renameFrom = filePath
c.sendResponse(350, "Ready for RNTO")
}
func (c *clientConn) handleRNTO(args string) {
if !c.requireAuth() {
return
}
if c.renameFrom == "" {
c.sendResponse(503, "RNFR required first")
return
}
fromPath := c.toRealPath(c.renameFrom)
toPath := c.toRealPath(c.resolvePath(args))
if err := os.Rename(fromPath, toPath); err != nil {
c.sendResponse(550, "Rename failed")
return
}
c.sendResponse(250, "Renamed")
c.renameFrom = ""
}
func (c *clientConn) handleSIZE(args string) {
if !c.requireAuth() {
return
}
filePath := c.resolvePath(args)
realPath := c.toRealPath(filePath)
info, err := os.Stat(realPath)
if err != nil {
c.sendResponse(550, "File not found")
return
}
c.sendResponse(213, fmt.Sprintf("%d", info.Size()))
}
func (c *clientConn) handleFEAT() {
features := []string{
"Features:",
" PASV",
" UTF8",
" SIZE",
}
c.sendMultiResponse(211, features)
}
func (c *clientConn) handleOPTS(args string) {
if strings.ToUpper(args) == "UTF8 ON" {
c.sendResponse(200, "UTF8 set to on")
} else {
c.sendResponse(501, "Option not understood")
}
}
// resolvePath resolves a relative path to an absolute path within the FTP root
func (c *clientConn) resolvePath(path string) string {
if strings.HasPrefix(path, "/") {
return filepath.Clean(path)
}
return filepath.Clean(filepath.Join(c.cwd, path))
}
// toRealPath converts an FTP virtual path to a real filesystem path
func (c *clientConn) toRealPath(ftpPath string) string {
user := c.server.config.GetFTPUser(c.username)
rootDir := c.server.config.FTP.RootDir
if user != nil && user.HomeDir != "" {
rootDir = user.HomeDir
}
// Remove leading slash
cleanPath := strings.TrimPrefix(ftpPath, "/")
return filepath.Join(rootDir, cleanPath)
}
+3
View File
@@ -0,0 +1,3 @@
module ftp-server
go 1.26.2
+545
View File
@@ -0,0 +1,545 @@
package static
// IndexHTML is the embedded admin panel HTML
var IndexHTML = `<!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>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #333; }
/* Login Page */
.login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.login-card { background: white; border-radius: 12px; padding: 40px; width: 400px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
.login-card h1 { text-align: center; margin-bottom: 30px; color: #333; font-size: 24px; }
.login-card h1 .icon { font-size: 48px; display: block; margin-bottom: 10px; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 6px; font-weight: 600; color: #555; font-size: 14px; }
.form-group input { width: 100%; padding: 12px 16px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; transition: border-color 0.3s; }
.form-group input:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
.btn { padding: 12px 24px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.3s; }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: white; width: 100%; }
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 15px rgba(102,126,234,0.4); }
.btn-danger { background: #dc3545; color: white; }
.btn-danger:hover { background: #c82333; }
.btn-success { background: #28a745; color: white; }
.btn-success:hover { background: #218838; }
.btn-sm { padding: 6px 14px; font-size: 12px; }
.error-msg { color: #dc3545; text-align: center; margin-top: 12px; font-size: 14px; min-height: 20px; }
/* Dashboard */
.app-container { display: none; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 16px 30px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 10px rgba(0,0,0,0.2); }
.header h1 { font-size: 20px; font-weight: 600; }
.header .user-info { display: flex; align-items: center; gap: 16px; }
.header .user-info span { font-size: 14px; opacity: 0.9; }
.sidebar { position: fixed; left: 0; top: 60px; bottom: 0; width: 220px; background: #fff; box-shadow: 2px 0 10px rgba(0,0,0,0.05); padding-top: 20px; }
.sidebar a { display: block; padding: 14px 24px; color: #666; text-decoration: none; font-size: 14px; transition: all 0.3s; border-left: 3px solid transparent; }
.sidebar a:hover, .sidebar a.active { background: #f8f9ff; color: #667eea; border-left-color: #667eea; }
.main-content { margin-left: 220px; padding: 30px; margin-top: 60px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 20px; margin-bottom: 30px; }
.stat-card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
.stat-card .label { color: #888; font-size: 13px; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .value { font-size: 28px; font-weight: 700; color: #333; }
.stat-card .value.purple { color: #667eea; }
.stat-card .value.green { color: #28a745; }
.stat-card .value.blue { color: #007bff; }
.card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); margin-bottom: 20px; }
.card h2 { font-size: 18px; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid #eee; color: #333; }
.card h3 { font-size: 15px; margin-bottom: 16px; color: #555; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
th { background: #fafafa; font-weight: 600; color: #555; }
tr:hover { background: #f8f9ff; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.badge-success { background: #e8f5e9; color: #28a745; }
.badge-secondary { background: #f5f5f5; color: #888; }
.section { display: none; }
.section.active { display: block; }
.config-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.config-grid .form-group input, .config-grid .form-group select { width: 100%; padding: 10px 14px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
.config-grid .form-group input:focus { outline: none; border-color: #667eea; }
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
.modal-overlay.show { display: flex; }
.modal { background: white; border-radius: 12px; padding: 30px; width: 480px; max-width: 90%; }
.modal h2 { margin-bottom: 24px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
.toast { position: fixed; top: 80px; right: 30px; padding: 14px 24px; border-radius: 8px; color: white; font-size: 14px; font-weight: 500; z-index: 2000; animation: slideIn 0.3s ease; }
.toast-success { background: #28a745; }
.toast-error { background: #dc3545; }
@keyframes slideIn { from { transform: translateX(100px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.actions-cell { display: flex; gap: 8px; }
</style>
</head>
<body>
<!-- Login Page -->
<div class="login-container" id="loginPage">
<div class="login-card">
<h1><span class="icon">&#128193;</span>FTP Server 管理面板</h1>
<div class="form-group">
<label>用户名</label>
<input type="text" id="loginUser" placeholder="请输入管理员用户名" value="admin">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="loginPass" placeholder="请输入密码" value="">
</div>
<button class="btn btn-primary" onclick="doLogin()">登 录</button>
<div class="error-msg" id="loginError"></div>
</div>
</div>
<!-- Dashboard -->
<div class="app-container" id="appContainer">
<div class="header">
<h1>&#128193; FTP Server 管理面板</h1>
<div class="user-info">
<span id="welcomeText">欢迎, Admin</span>
<button class="btn btn-sm btn-danger" onclick="doLogout()">退出登录</button>
</div>
</div>
<div class="sidebar">
<a href="#" class="active" onclick="showSection('dashboard', this)">&#128202; 仪表盘</a>
<a href="#" onclick="showSection('users', this)">&#128100; 用户管理</a>
<a href="#" onclick="showSection('settings', this)">&#9881; 系统设置</a>
</div>
<div class="main-content">
<!-- Dashboard Section -->
<div class="section active" id="section-dashboard">
<div class="stats-grid">
<div class="stat-card">
<div class="label">服务状态</div>
<div class="value green">运行中</div>
</div>
<div class="stat-card">
<div class="label">FTP 端口</div>
<div class="value purple" id="statFtpPort">-</div>
</div>
<div class="stat-card">
<div class="label">Web 端口</div>
<div class="value blue" id="statWebPort">-</div>
</div>
<div class="stat-card">
<div class="label">FTP 用户数</div>
<div class="value" id="statUserCount">-</div>
</div>
</div>
<div class="card">
<h2>服务器信息</h2>
<table>
<tr><td style="width:200px;font-weight:600;">FTP 根目录</td><td id="infoRootDir">-</td></tr>
<tr><td style="font-weight:600;">被动模式端口范围</td><td id="infoPassiveRange">-</td></tr>
<tr><td style="font-weight:600;">管理面板地址</td><td id="infoWebAddr">-</td></tr>
</table>
</div>
</div>
<!-- Users Section -->
<div class="section" id="section-users">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="margin:0;border:none;padding:0;">FTP 用户管理</h2>
<button class="btn btn-primary" style="width:auto;background:linear-gradient(135deg,#667eea,#764ba2);" onclick="showAddUserModal()">+ 添加用户</button>
</div>
<table>
<thead>
<tr><th>用户名</th><th>主目录</th><th>写入权限</th><th>操作</th></tr>
</thead>
<tbody id="userTableBody">
</tbody>
</table>
</div>
</div>
<!-- Settings Section -->
<div class="section" id="section-settings">
<div class="card">
<h2>FTP 服务设置</h2>
<div class="config-grid">
<div class="form-group">
<label>FTP 监听地址</label>
<input type="text" id="cfgFtpHost">
</div>
<div class="form-group">
<label>FTP 端口</label>
<input type="number" id="cfgFtpPort">
</div>
<div class="form-group">
<label>被动模式起始端口</label>
<input type="number" id="cfgPassiveMin">
</div>
<div class="form-group">
<label>被动模式结束端口</label>
<input type="number" id="cfgPassiveMax">
</div>
<div class="form-group">
<label>FTP 根目录</label>
<input type="text" id="cfgRootDir">
</div>
</div>
</div>
<div class="card">
<h2>Web 管理面板设置</h2>
<div class="config-grid">
<div class="form-group">
<label>Web 监听地址</label>
<input type="text" id="cfgWebHost">
</div>
<div class="form-group">
<label>Web 端口</label>
<input type="number" id="cfgWebPort">
</div>
</div>
</div>
<div class="card">
<h2>管理员密码</h2>
<div class="config-grid">
<div class="form-group">
<label>新密码</label>
<input type="password" id="cfgAdminPass" placeholder="输入新密码">
</div>
<div class="form-group">
<label>确认密码</label>
<input type="password" id="cfgAdminPassConfirm" placeholder="再次输入新密码">
</div>
</div>
</div>
<button class="btn btn-primary" style="width:auto;padding:12px 40px;" onclick="saveSettings()">保存设置</button>
</div>
</div>
</div>
<!-- Add/Edit User Modal -->
<div class="modal-overlay" id="userModal">
<div class="modal">
<h2 id="userModalTitle">添加 FTP 用户</h2>
<input type="hidden" id="editUserName">
<div class="form-group">
<label>用户名</label>
<input type="text" id="modalUsername" placeholder="输入用户名">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="modalPassword" placeholder="输入密码">
</div>
<div class="form-group">
<label>主目录</label>
<input type="text" id="modalHomeDir" placeholder="例如: ./ftp_root/user1">
</div>
<div class="form-group">
<label><input type="checkbox" id="modalWrite" checked> 允许写入</label>
</div>
<div class="modal-actions">
<button class="btn" style="background:#eee;" onclick="closeUserModal()">取消</button>
<button class="btn btn-primary" style="width:auto;" onclick="saveUser()">保存</button>
</div>
</div>
</div>
<script>
let authToken = localStorage.getItem('ftp_admin_token') || '';
// Check auth on load
window.onload = function() {
if (authToken) {
checkAuth();
}
};
function checkAuth() {
fetch('/api/status', {
headers: { 'Authorization': 'Bearer ' + authToken }
}).then(r => {
if (r.ok) {
showDashboard();
} else {
showLogin();
}
}).catch(() => showLogin());
}
function showLogin() {
document.getElementById('loginPage').style.display = 'flex';
document.getElementById('appContainer').style.display = 'none';
}
function showDashboard() {
document.getElementById('loginPage').style.display = 'none';
document.getElementById('appContainer').style.display = 'block';
loadStatus();
loadUsers();
loadConfig();
}
function doLogin() {
const username = document.getElementById('loginUser').value;
const password = document.getElementById('loginPass').value;
const errorEl = document.getElementById('loginError');
if (!username || !password) {
errorEl.textContent = '请输入用户名和密码';
return;
}
fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
}).then(r => r.json()).then(data => {
if (data.token) {
authToken = data.token;
localStorage.setItem('ftp_admin_token', authToken);
showDashboard();
} else {
errorEl.textContent = data.error || '登录失败';
}
}).catch(() => {
errorEl.textContent = '网络错误';
});
}
function doLogout() {
fetch('/api/logout', {
headers: { 'Authorization': 'Bearer ' + authToken }
}).finally(() => {
authToken = '';
localStorage.removeItem('ftp_admin_token');
showLogin();
});
}
function loadStatus() {
fetch('/api/status', {
headers: { 'Authorization': 'Bearer ' + authToken }
}).then(r => r.json()).then(data => {
document.getElementById('statFtpPort').textContent = data.ftpPort;
document.getElementById('statWebPort').textContent = data.webPort;
document.getElementById('statUserCount').textContent = data.userCount;
document.getElementById('infoRootDir').textContent = data.rootDir;
});
}
function loadConfig() {
fetch('/api/config', {
headers: { 'Authorization': 'Bearer ' + authToken }
}).then(r => r.json()).then(data => {
document.getElementById('cfgFtpHost').value = data.ftp.host;
document.getElementById('cfgFtpPort').value = data.ftp.port;
document.getElementById('cfgPassiveMin').value = data.ftp.passivePortMin;
document.getElementById('cfgPassiveMax').value = data.ftp.passivePortMax;
document.getElementById('cfgRootDir').value = data.ftp.rootDir;
document.getElementById('cfgWebHost').value = data.web.host;
document.getElementById('cfgWebPort').value = data.web.port;
document.getElementById('infoPassiveRange').textContent = data.ftp.passivePortMin + ' - ' + data.ftp.passivePortMax;
document.getElementById('infoWebAddr').textContent = 'http://' + data.web.host + ':' + data.web.port;
});
}
function loadUsers() {
fetch('/api/users', {
headers: { 'Authorization': 'Bearer ' + authToken }
}).then(r => r.json()).then(users => {
const tbody = document.getElementById('userTableBody');
tbody.innerHTML = '';
if (users && users.length > 0) {
users.forEach(u => {
const tr = document.createElement('tr');
const writeBadge = u.write ? '<span class="badge badge-success">可写</span>' : '<span class="badge badge-secondary">只读</span>';
tr.innerHTML =
'<td>' + u.username + '</td>' +
'<td>' + u.homeDir + '</td>' +
'<td>' + writeBadge + '</td>' +
'<td class="actions-cell">' +
'<button class="btn btn-sm" style="background:#667eea;color:white;" onclick="editUser(\'' + u.username + '\', \'' + u.homeDir + '\', ' + u.write + ')">编辑</button>' +
'<button class="btn btn-sm btn-danger" onclick="deleteUser(\'' + u.username + '\')">删除</button>' +
'</td>';
tbody.appendChild(tr);
});
} else {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#888;">暂无用户</td></tr>';
}
});
}
function showSection(name, el) {
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.getElementById('section-' + name).classList.add('active');
document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'));
el.classList.add('active');
if (name === 'dashboard') { loadStatus(); loadConfig(); }
if (name === 'users') { loadUsers(); }
if (name === 'settings') { loadConfig(); }
}
function showAddUserModal() {
document.getElementById('userModalTitle').textContent = '添加 FTP 用户';
document.getElementById('editUserName').value = '';
document.getElementById('modalUsername').value = '';
document.getElementById('modalUsername').disabled = false;
document.getElementById('modalPassword').value = '';
document.getElementById('modalHomeDir').value = '';
document.getElementById('modalWrite').checked = true;
document.getElementById('userModal').classList.add('show');
}
function editUser(username, homeDir, write) {
document.getElementById('userModalTitle').textContent = '编辑 FTP 用户';
document.getElementById('editUserName').value = username;
document.getElementById('modalUsername').value = username;
document.getElementById('modalUsername').disabled = true;
document.getElementById('modalPassword').value = '';
document.getElementById('modalPassword').placeholder = '留空则不修改密码';
document.getElementById('modalHomeDir').value = homeDir;
document.getElementById('modalWrite').checked = write;
document.getElementById('userModal').classList.add('show');
}
function closeUserModal() {
document.getElementById('userModal').classList.remove('show');
document.getElementById('modalPassword').placeholder = '输入密码';
}
function saveUser() {
const editUser = document.getElementById('editUserName').value;
const username = document.getElementById('modalUsername').value;
const password = document.getElementById('modalPassword').value;
const homeDir = document.getElementById('modalHomeDir').value;
const write = document.getElementById('modalWrite').checked;
if (!username) { showToast('请输入用户名', 'error'); return; }
if (editUser) {
// Update existing user
const user = { username, homeDir, write };
if (password) user.password = password;
else {
// Keep existing password - send a flag
user.keepPassword = true;
}
fetch('/api/users/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken },
body: JSON.stringify({ username: editUser, user })
}).then(r => r.json()).then(data => {
if (data.status === 'ok') {
showToast('用户已更新');
loadUsers();
closeUserModal();
} else {
showToast(data.error || '更新失败', 'error');
}
});
} else {
// Add new user
if (!password) { showToast('请输入密码', 'error'); return; }
fetch('/api/users/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken },
body: JSON.stringify({ username, password, homeDir, write })
}).then(r => r.json()).then(data => {
if (data.status === 'ok') {
showToast('用户已添加');
loadUsers();
closeUserModal();
} else {
showToast(data.error || '添加失败', 'error');
}
});
}
}
function deleteUser(username) {
if (!confirm('确定要删除用户 "' + username + '" 吗?')) return;
fetch('/api/users/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken },
body: JSON.stringify({ username })
}).then(r => r.json()).then(data => {
if (data.status === 'ok') {
showToast('用户已删除');
loadUsers();
} else {
showToast(data.error || '删除失败', 'error');
}
});
}
function saveSettings() {
const adminPass = document.getElementById('cfgAdminPass').value;
const adminPassConfirm = document.getElementById('cfgAdminPassConfirm').value;
if (adminPass && adminPass !== adminPassConfirm) {
showToast('两次密码输入不一致', 'error');
return;
}
const update = {
ftp: {
host: document.getElementById('cfgFtpHost').value,
port: parseInt(document.getElementById('cfgFtpPort').value),
passivePortMin: parseInt(document.getElementById('cfgPassiveMin').value),
passivePortMax: parseInt(document.getElementById('cfgPassiveMax').value),
rootDir: document.getElementById('cfgRootDir').value
},
web: {
host: document.getElementById('cfgWebHost').value,
port: parseInt(document.getElementById('cfgWebPort').value)
}
};
if (adminPass) {
update.adminPassword = adminPass;
}
fetch('/api/config/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authToken },
body: JSON.stringify(update)
}).then(r => r.json()).then(data => {
if (data.status === 'ok') {
showToast('设置已保存,部分设置需重启服务生效');
document.getElementById('cfgAdminPass').value = '';
document.getElementById('cfgAdminPassConfirm').value = '';
loadConfig();
loadStatus();
} else {
showToast(data.error || '保存失败', 'error');
}
});
}
function showToast(msg, type) {
const toast = document.createElement('div');
toast.className = 'toast ' + (type === 'error' ? 'toast-error' : 'toast-success');
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// Handle Enter key on login
document.getElementById('loginPass').addEventListener('keypress', function(e) {
if (e.key === 'Enter') doLogin();
});
</script>
</body>
</html>
`
+363
View File
@@ -0,0 +1,363 @@
package web
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"ftp-server/config"
"ftp-server/static"
)
// Server represents the web admin server
type Server struct {
config *config.Config
configPath string
sessions map[string]time.Time
mu sync.RWMutex
}
// NewServer creates a new web admin server
func NewServer(cfg *config.Config, configPath string) *Server {
return &Server{
config: cfg,
configPath: configPath,
sessions: make(map[string]time.Time),
}
}
// Start starts the web admin server
func (s *Server) Start() error {
mux := http.NewServeMux()
// Static files served directly from code
// No file server needed - HTML is embedded in static package
// Serve the main page
mux.HandleFunc("/", s.handleIndex)
// API routes
mux.HandleFunc("/api/login", s.handleLogin)
mux.HandleFunc("/api/logout", s.authRequired(s.handleLogout))
mux.HandleFunc("/api/status", s.authRequired(s.handleStatus))
mux.HandleFunc("/api/users", s.authRequired(s.handleUsers))
mux.HandleFunc("/api/users/add", s.authRequired(s.handleAddUser))
mux.HandleFunc("/api/users/delete", s.authRequired(s.handleDeleteUser))
mux.HandleFunc("/api/users/update", s.authRequired(s.handleUpdateUser))
mux.HandleFunc("/api/config", s.authRequired(s.handleConfig))
mux.HandleFunc("/api/config/update", s.authRequired(s.handleUpdateConfig))
// Start session cleanup
go s.cleanupSessions()
addr := fmt.Sprintf("%s:%d", s.config.Web.Host, s.config.Web.Port)
log.Printf("Web admin server listening on http://%s", addr)
return http.ListenAndServe(addr, mux)
}
// authRequired is middleware that requires authentication
func (s *Server) authRequired(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
cookie, err := r.Cookie("session")
if err == nil {
token = cookie.Value
}
}
token = strings.TrimPrefix(token, "Bearer ")
if !s.isValidSession(token) {
http.Error(w, `{"error":"Unauthorized"}`, http.StatusUnauthorized)
return
}
next(w, r)
}
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(static.IndexHTML))
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var creds struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
return
}
if !s.config.AuthenticateAdmin(creds.Username, creds.Password) {
http.Error(w, `{"error":"Invalid credentials"}`, http.StatusUnauthorized)
return
}
token := s.createSession()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"token": token,
})
log.Printf("Web admin '%s' logged in from %s", creds.Username, r.RemoteAddr)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
token = strings.TrimPrefix(token, "Bearer ")
if cookie, err := r.Cookie("session"); err == nil {
token = cookie.Value
}
s.deleteSession(token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "running",
"ftpPort": s.config.FTP.Port,
"webPort": s.config.Web.Port,
"rootDir": s.config.FTP.RootDir,
"userCount": len(s.config.GetFTPUsers()),
})
}
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
users := s.config.GetFTPUsers()
// Hide passwords in response
type safeUser struct {
Username string `json:"username"`
HomeDir string `json:"homeDir"`
Write bool `json:"write"`
}
var safeUsers []safeUser
for _, u := range users {
safeUsers = append(safeUsers, safeUser{
Username: u.Username,
HomeDir: u.HomeDir,
Write: u.Write,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(safeUsers)
}
func (s *Server) handleAddUser(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var user config.FTPUser
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
return
}
if user.Username == "" || user.Password == "" {
http.Error(w, `{"error":"Username and password required"}`, http.StatusBadRequest)
return
}
if s.config.GetFTPUser(user.Username) != nil {
http.Error(w, `{"error":"User already exists"}`, http.StatusConflict)
return
}
if user.HomeDir == "" {
user.HomeDir = s.config.FTP.RootDir
}
s.config.AddFTPUser(user)
if err := s.config.Save(s.configPath); err != nil {
http.Error(w, `{"error":"Failed to save config"}`, http.StatusInternalServerError)
return
}
log.Printf("FTP user '%s' added via web admin", user.Username)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
return
}
if !s.config.DeleteFTPUser(req.Username) {
http.Error(w, `{"error":"User not found"}`, http.StatusNotFound)
return
}
if err := s.config.Save(s.configPath); err != nil {
http.Error(w, `{"error":"Failed to save config"}`, http.StatusInternalServerError)
return
}
log.Printf("FTP user '%s' deleted via web admin", req.Username)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
User config.FTPUser `json:"user"`
KeepPassword bool `json:"keepPassword"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
return
}
if req.KeepPassword && req.User.Password == "" {
existing := s.config.GetFTPUser(req.Username)
if existing != nil {
req.User.Password = existing.Password
}
}
if !s.config.UpdateFTPUser(req.Username, req.User) {
http.Error(w, `{"error":"User not found"}`, http.StatusNotFound)
return
}
if err := s.config.Save(s.configPath); err != nil {
http.Error(w, `{"error":"Failed to save config"}`, http.StatusInternalServerError)
return
}
log.Printf("FTP user '%s' updated via web admin", req.Username)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"ftp": s.config.FTP,
"web": s.config.Web,
"adminUsername": s.config.Admin.Username,
})
}
func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var update struct {
FTP *config.FTPConfig `json:"ftp,omitempty"`
Web *config.WebConfig `json:"web,omitempty"`
AdminPass string `json:"adminPassword,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
return
}
if update.FTP != nil {
s.config.FTP = *update.FTP
}
if update.Web != nil {
s.config.Web = *update.Web
}
if update.AdminPass != "" {
s.config.Admin.Password = update.AdminPass
}
if err := s.config.Save(s.configPath); err != nil {
http.Error(w, `{"error":"Failed to save config"}`, http.StatusInternalServerError)
return
}
log.Println("Configuration updated via web admin")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// Session management
func (s *Server) createSession() string {
b := make([]byte, 32)
rand.Read(b)
token := hex.EncodeToString(b)
s.mu.Lock()
s.sessions[token] = time.Now().Add(24 * time.Hour)
s.mu.Unlock()
return token
}
func (s *Server) isValidSession(token string) bool {
if token == "" {
return false
}
s.mu.RLock()
expiry, ok := s.sessions[token]
s.mu.RUnlock()
return ok && time.Now().Before(expiry)
}
func (s *Server) deleteSession(token string) {
s.mu.Lock()
delete(s.sessions, token)
s.mu.Unlock()
}
func (s *Server) cleanupSessions() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
s.mu.Lock()
now := time.Now()
for token, expiry := range s.sessions {
if now.After(expiry) {
delete(s.sessions, token)
}
}
s.mu.Unlock()
}
}