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") data := 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()), } if s.config.HTTPFile.Enable { data["httpFilePort"] = s.config.HTTPFile.Port } json.NewEncoder(w).Encode(data) } 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() } }