feat: initial FTP Server for Windows with web admin panel
This commit is contained in:
+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