feat: initial FTP Server for Windows with web admin panel
This commit is contained in:
+20
@@ -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
@@ -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.")
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
+545
@@ -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">📁</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>📁 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)">📊 仪表盘</a>
|
||||||
|
<a href="#" onclick="showSection('users', this)">👤 用户管理</a>
|
||||||
|
<a href="#" onclick="showSection('settings', this)">⚙ 系统设置</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
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user