Compare commits

7 Commits

26 changed files with 2551 additions and 4007 deletions
+10 -14
View File
@@ -1,28 +1,24 @@
# 编译产物
ftp-server.exe
ftp-server
# Binaries
*.exe
ftp-server
# 数据目录
data/
# 配置文件(包含密码等敏感信息)
# Config (contains passwords)
config.json
# FTP根目录
ftp_root/
# Go 相关
vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
Thumbs.db
Desktop.ini
.DS_Store
# Build output
ftp_root/
# Runtime
*.pid
ftp-server.log
-25
View File
@@ -1,25 +0,0 @@
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o ftp-server .
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY --from=builder /build/ftp-server .
COPY static/ ./static/
RUN mkdir -p /app/data /app/ftp_root
EXPOSE 2121 8080 50000-50100
VOLUME ["/app/data", "/app/ftp_root", "/app/config.json"]
ENTRYPOINT ["./ftp-server"]
CMD ["-config", "/app/config.json"]
View File
+142 -358
View File
@@ -1,355 +1,100 @@
# FTP Server with Web Management
# FTP Server
一个基于 Go 语言开发的 FTP 服务器,带 Web 管理界面。支持多用户管理、文件浏览、操作日志、在线监控等功能。编译为单个可执行文件,无需额外依赖
基于 Go 语言开发的轻量级 FTP 服务器,带 Web 管理面板,**跨平台支持 Windows / Linux / macOS**
## 功能特性
- **FTP 服务**:支持主动/被动模式、多用户隔离、权限控制、速率限制
- **Web 管理**:仪表盘、用户管理、文件管理、日志查询、在线用户监控、系统设置
- **用户管理**:添加/编辑/删除用户、独立主目录、读写权限、空间配额、速度限制
- **自动创建目录**:添加用户时指定的主目录不存在会自动创建
- **操作日志**:记录登录、上传、下载等操作,支持筛选和分页
- **单文件部署**:编译后一个 exe/bin 文件即可运行,零依赖
### FTP 服务
- 完整的 FTP 协议支持(RFC 959)
- 主动模式(PORT)和被动模式(PASV)
- 文件上传 / 下载(STOR / RETR
- 目录浏览、创建、删除(LIST / MKD / RMD
- 文件删除、重命名(DELE / RNFR / RNTO
- 每用户独立的 HomeDir 和权限控制(只读 / 可写)
### Web 管理面板
- 管理员登录认证(Token 机制,24h 自动过期)
- 仪表盘 — 服务器状态实时概览
- 用户管理 — 添加 / 编辑 / 删除 FTP 用户
- 系统设置 — 修改 FTP 端口、Web 端口、管理员密码
## 快速开始
### Windows
### 编译
#### 方式一:直接下载可执行文件
确保已安装 Go 1.21+,然后执行:
1. 从 Releases 页面下载 `ftp-server.exe`
2. 放到任意目录,双击运行或命令行启动:
```bash
# 编译为当前平台
go build -o ftp-server ./cmd/
```powershell
# 交叉编译 - Windows (64位)
GOOS=windows GOARCH=amd64 go build -o ftp-server.exe ./cmd/
# 交叉编译 - Linux (64位)
GOOS=linux GOARCH=amd64 go build -o ftp-server ./cmd/
# 交叉编译 - macOS
GOOS=darwin GOARCH=amd64 go build -o ftp-server ./cmd/
```
### 运行
**Windows:**
```bash
# 使用默认配置启动
.\ftp-server.exe
# 指定配置文件
.\ftp-server.exe -config myconfig.json
```
#### 方式二:从源码编译
1. 安装 [Go 语言环境](https://go.dev/dl/)1.21 或更高版本)
2. 克隆项目并编译:
```powershell
git clone ssh://git@git.cnbugs.com:10022/AI-Agent/FTP-Server.git
cd FTP-Server
go build -o ftp-server.exe .
```
3. 运行:
```powershell
.\ftp-server.exe
```
### Linux
#### 从源码编译
1. 安装 Go 语言环境:
```bash
# Ubuntu/Debian
sudo apt install golang-go
# CentOS/RHEL
sudo yum install golang
# 或从官网下载安装
wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
```
2. 克隆项目并编译:
```bash
git clone ssh://git@git.cnbugs.com:10022/AI-Agent/FTP-Server.git
cd FTP-Server
go build -o ftp-server .
```
3. 运行:
**Linux / macOS:**
```bash
# 使用默认配置启动
./ftp-server
# 指定配置文件
./ftp-server -config myconfig.json
# 后台运行
nohup ./ftp-server > ftp-server.log 2>&1 &
```
#### 交叉编译(在 Windows 上编译 Linux 版本)
首次运行会自动生成 `config.json` 配置文件和 `ftp_root` 根目录。
```powershell
$env:GOOS="linux"; $env:GOARCH="amd64"; go build -o ftp-server .
```
### 访问
```bash
# Linux/Mac 上交叉编译 Windows 版本
GOOS=windows GOARCH=amd64 go build -o ftp-server.exe .
```
### 使用 Docker 运行(可选)
```bash
docker build -t ftp-server .
docker run -d -p 2121:2121 -p 50000-50100:50000-50100 -p 8080:8080 ftp-server
```
## 启动后访问
启动成功后会显示:
```
========================================
FTP Server with Web Management
========================================
FTP端口: 2121
Web管理: http://localhost:8080
按 Ctrl+C 停止服务器
```
### Web 管理界面
浏览器打开 `http://localhost:8080`,使用默认管理员账号登录:
- 用户名:`admin`
- 密码:`admin123`
> **请登录后立即在「系统设置」中修改管理员密码!**
### FTP 连接
使用任意 FTP 客户端连接:
- 主机:`localhost`(或服务器 IP
- 端口:`2121`
- 用户名/密码:在 Web 管理界面中创建的 FTP 用户
推荐的 FTP 客户端:
- Windows[FileZilla](https://filezilla-project.org/)、WinSCP
- Linux`ftp` 命令、FileZilla、lftp
```bash
# Linux 命令行连接示例
ftp localhost 2121
# 或使用 lftp
lftp -p 2121 localhost
```
## 配置说明
首次运行会在当前目录自动生成 `config.json`
```json
{
"ftp": {
"host": "0.0.0.0",
"port": 2121,
"passive_port_min": 50000,
"passive_port_max": 50100,
"root_dir": "./ftp_root",
"enable_anonymous": false,
"max_connections": 50,
"idle_timeout": 300
},
"web": {
"host": "0.0.0.0",
"port": 8080
},
"admin": {
"username": "admin",
"password": "admin123"
},
"database": {
"path": "./data/ftp.db"
}
}
```
### 配置项说明
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| `ftp.host` | FTP 监听地址 | `0.0.0.0` |
| `ftp.port` | FTP 端口 | `2121` |
| `ftp.passive_port_min` | 被动模式端口范围起始 | `50000` |
| `ftp.passive_port_max` | 被动模式端口范围结束 | `50100` |
| `ftp.root_dir` | FTP 根目录 | `./ftp_root` |
| `ftp.enable_anonymous` | 允许匿名访问 | `false` |
| `ftp.max_connections` | 最大连接数 | `50` |
| `ftp.idle_timeout` | 空闲超时(秒) | `300` |
| `web.host` | Web 管理界面监听地址 | `0.0.0.0` |
| `web.port` | Web 管理界面端口 | `8080` |
| `admin.username` | 管理员用户名 | `admin` |
| `admin.password` | 管理员密码 | `admin123` |
也可以通过 Web 管理界面的「系统设置」页面在线修改配置。
### 指定配置文件
```bash
# 使用自定义配置文件路径
./ftp-server -config /etc/ftp/config.json
```
## Web 管理功能
### 仪表盘
展示系统概览:用户总数、启用用户、在线用户、今日登录/上传/下载统计、总传输量。
### 用户管理
- 添加/编辑/删除 FTP 用户
- 设置用户主目录(不存在会自动创建)
- 配置权限:只读、只写、读写
- 设置空间配额和文件数限制
- 设置上传/下载速率限制
- 启用/禁用账户
- 修改用户密码
### 文件管理
- 浏览 FTP 目录结构
- 上传文件
- 新建文件夹
- 删除文件/文件夹
### 操作日志
- 记录所有 FTP 操作(登录、上传、下载等)
- 按用户名、操作类型筛选
- 分页浏览
### 在线用户
- 实时查看当前连接的 FTP 用户
- 显示用户名、IP、登录时间、当前目录
## 防火墙配置
需要开放以下端口:
| 端口 | 用途 |
| 服务 | 地址 |
|------|------|
| 2121 | FTP 控制连接 |
| 50000-50100 | FTP 被动模式数据传输 |
| 8080 | Web 管理界面 |
| Web 管理面板 | http://localhost:8080 |
| FTP 服务 | localhost:2121 |
### Windows 防火墙
### 默认账号
```powershell
# 以管理员身份运行 PowerShell
netsh advfirewall firewall add rule name="FTP Server" dir=in action=allow protocol=TCP localport=2121
netsh advfirewall firewall add rule name="FTP Passive" dir=in action=allow protocol=TCP localport=50000-50100
netsh advfirewall firewall add rule name="FTP Web" dir=in action=allow protocol=TCP localport=8080
```
| 服务 | 用户名 | 密码 |
|------|--------|------|
| Web 管理面板 | `admin` | `admin123` |
| FTP 默认用户 | `ftpuser` | `ftp123` |
### Linux 防火墙
> 请在首次登录后立即修改默认密码。
## Linux Systemd 服务(可选)
在 Linux 上可以配置为 systemd 服务,实现开机自启:
```bash
# firewalld
sudo firewall-cmd --permanent --add-port=2121/tcp
sudo firewall-cmd --permanent --add-port=50000-50100/tcp
sudo firewall-cmd --permanent --add-port=8080/tcp
sudo firewall-cmd --reload
# iptables
sudo iptables -A INPUT -p tcp --dport 2121 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 50000:50100 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 8080 -j ACCEPT
# 创建服务文件
sudo nano /etc/systemd/system/ftp-server.service
```
## 服务管理脚本
项目提供了 Windows 和 Linux 的服务管理脚本,支持安装、卸载、启动、停止、重启、开机自启等操作。
### Windows - service-windows.bat
以**管理员身份**运行 `scripts/service-windows.bat`,进入交互式菜单:
```
========================================
FTP Server 服务管理工具
========================================
1. 安装服务 (注册为 Windows 服务)
2. 卸载服务
3. 启动服务
4. 停止服务
5. 重启服务
6. 查看服务状态
7. 开机自启 - 开启
8. 开机自启 - 关闭
9. 查看运行日志
0. 退出
```
使用前将 `scripts/service-windows.bat` 复制到 `ftp-server.exe` 同目录下,然后右键"以管理员身份运行"。
### Linux - service-linux.sh
```bash
# 添加执行权限
chmod +x scripts/service-linux.sh
# 交互式菜单
sudo ./scripts/service-linux.sh menu
# 或直接使用命令
sudo ./scripts/service-linux.sh install # 安装为 systemd 服务
sudo ./scripts/service-linux.sh start # 启动
sudo ./scripts/service-linux.sh stop # 停止
sudo ./scripts/service-linux.sh restart # 重启
sudo ./scripts/service-linux.sh status # 查看状态
sudo ./scripts/service-linux.sh enable # 设置开机自启
sudo ./scripts/service-linux.sh disable # 关闭开机自启
sudo ./scripts/service-linux.sh logs # 查看实时日志
sudo ./scripts/service-linux.sh uninstall # 卸载服务
```
脚本会自动在 `/etc/systemd/system/ftp-server.service` 创建 systemd 服务文件。
### Docker 运行
```bash
# 构建镜像
docker build -t ftp-server .
# 运行容器
docker run -d \
--name ftp-server \
-p 2121:2121 \
-p 50000-50100:50000-50100 \
-p 8080:8080 \
-v ./data:/app/data \
-v ./ftp_root:/app/ftp_root \
-v ./config.json:/app/config.json \
ftp-server
# 查看日志
docker logs -f ftp-server
```
### 手动设置开机自启
<details>
<summary>Windows - 任务计划程序(点击展开)</summary>
```powershell
schtasks /create /tn "FTP Server" /tr "C:\path\to\ftp-server.exe" /sc onstart /ru SYSTEM
```
</details>
<details>
<summary>Linux - 手动创建 systemd 服务(点击展开)</summary>
创建 `/etc/systemd/system/ftp-server.service`
写入以下内容(按实际路径修改):
```ini
[Unit]
Description=FTP Server with Web Management
Description=FTP Server
After=network.target
[Service]
@@ -364,57 +109,96 @@ RestartSec=5
WantedBy=multi-user.target
```
启动并设置开机自启:
```bash
sudo systemctl daemon-reload
sudo systemctl start ftp-server
sudo systemctl enable ftp-server
# 查看状态
sudo systemctl status ftp-server
# 查看日志
sudo journalctl -u ftp-server -f
```
</details>
## 配置说明
配置文件为 `config.json`,结构如下:
```json
{
"ftp": {
"host": "0.0.0.0",
"port": 2121,
"passivePortMin": 50000,
"passivePortMax": 50100,
"rootDir": "./ftp_root"
},
"web": {
"host": "0.0.0.0",
"port": 8080
},
"admin": {
"username": "admin",
"password": "admin123"
},
"ftpUsers": [
{
"username": "ftpuser",
"password": "ftp123",
"homeDir": "./ftp_root",
"write": true
}
]
}
```
| 字段 | 说明 |
|------|------|
| `ftp.host` | FTP 监听地址 |
| `ftp.port` | FTP 端口(默认 2121,避免需要管理员权限) |
| `ftp.passivePortMin/Max` | 被动模式端口范围 |
| `ftp.rootDir` | FTP 根目录 |
| `web.host` | Web 面板监听地址 |
| `web.port` | Web 面板端口 |
| `admin.username/password` | 管理员凭据 |
| `ftpUsers` | FTP 用户列表 |
| `ftpUsers[].homeDir` | 用户主目录 |
| `ftpUsers[].write` | 是否允许写入 |
## FTP 客户端连接
推荐使用 [FileZilla](https://filezilla-project.org/) 连接:
```
主机: localhost
端口: 2121
用户名: ftpuser
密码: ftp123
```
## 项目结构
```
ftp-server/
├── main.go # 主入口
├── config/config.go # 配置管理
├── database/db.go # SQLite 数据库层
├── ftp/server.go # FTP 服务器
├── web/server.go # Web 管理 API
├── static/
│ ├── index.html # 管理界面
│ ├── css/style.css # 样式
│ └── js/app.js # 前端逻辑
├── scripts/
│ ├── service-windows.bat # Windows 服务管理脚本
│ └── service-linux.sh # Linux 服务管理脚本
├── Dockerfile # Docker 构建文件
FTP-server/
├── cmd/main.go # 主程序入口
├── config/config.go # 配置管理模块
├── ftp/server.go # FTP 服务核心
├── web/server.go # Web 管理面板后端 API
├── static/embed.go # 前端 HTML 页面(内嵌)
├── go.mod # Go 模块定义
└── go.sum # 依赖校验
└── config.json # 运行时配置(自动生成)
```
## 技术栈
- **语言**Go 1.21+
- **FTP 服务**[ftpserverlib](https://github.com/fclairamb/ftpserverlib) + [afero](https://github.com/spf13/afero)
- **数据库**SQLite[pure Go 实现](https://modernc.org/sqlite/),无需 CGO
- **Web 认证**JWT
- **前端**:原生 HTML/CSS/JavaScript
- **Go** — 无需运行时依赖,单文件部署,跨平台支持
- **标准库 net/http** — Web 管理面板
- **标准库 net** — FTP 协议实现
- **内嵌 HTML/CSS/JS** — 无需额外前端文件
## 常见问题
## 许可证
**Q: FTP 客户端连接后无法获取目录列表?**
A: 检查防火墙是否开放了被动模式端口(默认 50000-50100),FTP 客户端需要设置为被动模式(PASV)。
**Q: 如何修改 FTP 端口或 Web 端口?**
A: 编辑 `config.json` 文件或在 Web 管理界面的「系统设置」中修改,修改后需要重启服务。
**Q: 忘记管理员密码怎么办?**
A: 停止服务,编辑 `config.json` 中的 `admin.password` 字段,重新启动即可。
**Q: 如何备份数据?**
A: 备份 `data/ftp.db`(数据库)和 `config.json`(配置)以及 `ftp_root/`(用户文件)即可。
## License
MIT
MIT License
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"runtime"
"syscall"
"ftp-server/config"
ftpserver "ftp-server/ftp"
httpfileserver "ftp-server/httpfile"
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 (Cross-Platform)")
fmt.Println("======================================")
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Printf("FTP Port: %d\n", cfg.FTP.Port)
fmt.Printf("Web Admin: http://127.0.0.1:%d\n", cfg.Web.Port)
if cfg.HTTPFile.Enable {
fmt.Printf("HTTP Files: http://127.0.0.1:%d\n", cfg.HTTPFile.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)
}
}()
// Start HTTP file server
if cfg.HTTPFile.Enable {
fileSrv := httpfileserver.NewServer(cfg)
go func() {
if err := fileSrv.Start(); err != nil {
log.Fatalf("Failed to start HTTP file 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.")
}
+110 -44
View File
@@ -3,79 +3,101 @@ package config
import (
"encoding/json"
"os"
"path/filepath"
"sync"
)
// Config 全局配置结构
// Config represents the application configuration
type Config struct {
mu sync.RWMutex
FTP FTPConfig `json:"ftp"`
Web WebConfig `json:"web"`
Admin AdminConfig `json:"admin"`
Database DBConfig `json:"database"`
mu sync.RWMutex `json:"-"`
FTP FTPConfig `json:"ftp"`
Web WebConfig `json:"web"`
HTTPFile HTTPFileConfig `json:"httpFile"`
Admin AdminConfig `json:"admin"`
FTPUsers []FTPUser `json:"ftpUsers"`
}
// FTPConfig holds FTP server settings
type FTPConfig struct {
Host string `json:"host"`
Port int `json:"port"`
PassivePortMin int `json:"passive_port_min"`
PassivePortMax int `json:"passive_port_max"`
RootDir string `json:"root_dir"`
EnableAnonymous bool `json:"enable_anonymous"`
MaxConnections int `json:"max_connections"`
IdleTimeout int `json:"idle_timeout"` // 秒
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"`
}
// HTTPFileConfig holds HTTP file server settings
type HTTPFileConfig struct {
Enable bool `json:"enable"`
Host string `json:"host"`
Port int `json:"port"`
RootDir string `json:"rootDir"`
Upload bool `json:"upload"`
}
// AdminConfig holds admin credentials
type AdminConfig struct {
Username string `json:"username"`
Password string `json:"password"`
}
type DBConfig struct {
Path string `json:"path"`
// 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 返回默认配置
// DefaultConfig returns a default configuration
func DefaultConfig() *Config {
return &Config{
FTP: FTPConfig{
Host: "0.0.0.0",
Port: 2121,
PassivePortMin: 50000,
PassivePortMax: 50100,
RootDir: "./ftp_root",
EnableAnonymous: false,
MaxConnections: 50,
IdleTimeout: 300,
Host: "0.0.0.0",
Port: 2121,
PassiveMin: 50000,
PassiveMax: 50100,
RootDir: "./ftp_root",
},
Web: WebConfig{
Host: "0.0.0.0",
Port: 8080,
},
HTTPFile: HTTPFileConfig{
Enable: true,
Host: "0.0.0.0",
Port: 9090,
RootDir: "./ftp_root",
Upload: true,
},
Admin: AdminConfig{
Username: "admin",
Password: "admin123",
},
Database: DBConfig{
Path: "./data/ftp.db",
FTPUsers: []FTPUser{
{
Username: "ftpuser",
Password: "ftp123",
HomeDir: "./ftp_root",
Write: true,
},
},
}
}
// Load 从文件加载配置
// 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 err := cfg.Save(path); err != nil {
return nil, err
if saveErr := cfg.Save(path); saveErr != nil {
return nil, saveErr
}
return cfg, nil
}
@@ -89,34 +111,78 @@ func Load(path string) (*Config, error) {
return &cfg, nil
}
// Save 保存配置到文件
// Save saves configuration to a file
func (c *Config) Save(path string) error {
c.mu.RLock()
defer c.mu.RUnlock()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// Get 安全获取配置副本
func (c *Config) Get() Config {
// GetFTPUser finds an FTP user by username
func (c *Config) GetFTPUser(username string) *FTPUser {
c.mu.RLock()
defer c.mu.RUnlock()
return *c
for i := range c.FTPUsers {
if c.FTPUsers[i].Username == username {
return &c.FTPUsers[i]
}
}
return nil
}
// Update 安全更新配置
func (c *Config) Update(fn func(cfg *Config)) {
// AddFTPUser adds a new FTP user
func (c *Config) AddFTPUser(user FTPUser) {
c.mu.Lock()
defer c.mu.Unlock()
fn(c)
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
}
-451
View File
@@ -1,451 +0,0 @@
package database
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
// DB 数据库实例
type DB struct {
*sql.DB
}
// FTPUser FTP用户
type FTPUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
HomeDir string `json:"home_dir"`
Permissions string `json:"permissions"` // "read", "write", "read,write", "admin"
QuotaSize int64 `json:"quota_size"` // 字节, 0 = 无限制
QuotaFiles int `json:"quota_files"` // 文件数, 0 = 无限制
UploadRate int `json:"upload_rate"` // KB/s, 0 = 无限制
DownloadRate int `json:"download_rate"` // KB/s, 0 = 无限制
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// FTPLog FTP操作日志
type FTPLog struct {
ID int64 `json:"id"`
Username string `json:"username"`
IP string `json:"ip"`
Action string `json:"action"`
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
// OnlineUser 在线用户
type OnlineUser struct {
ID int `json:"id"`
Username string `json:"username"`
IP string `json:"ip"`
LoginTime time.Time `json:"login_time"`
LastActivity time.Time `json:"last_activity"`
CurrentDir string `json:"current_dir"`
}
// IPAccessRule IP访问规则
type IPAccessRule struct {
ID int64 `json:"id"`
Username string `json:"username"` // 为空表示全局规则,有值表示用户专属规则
IP string `json:"ip"` // 支持单IP、CIDR(192.168.1.0/24)、IP范围(192.168.1.1-192.168.1.100)
Type string `json:"type"` // "whitelist" 或 "blacklist"
Note string `json:"note"` // 备注说明
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
}
// Open 打开数据库
func Open(dbPath string) (*DB, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败: %w", err)
}
db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("打开数据库失败: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
d := &DB{db}
if err := d.initTables(); err != nil {
return nil, err
}
return d, nil
}
// initTables 初始化数据表
func (db *DB) initTables() error {
schema := `
CREATE TABLE IF NOT EXISTS ftp_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
home_dir TEXT NOT NULL,
permissions TEXT DEFAULT 'read',
quota_size INTEGER DEFAULT 0,
quota_files INTEGER DEFAULT 0,
upload_rate INTEGER DEFAULT 0,
download_rate INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ftp_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT DEFAULT '',
ip TEXT DEFAULT '',
action TEXT NOT NULL,
file_path TEXT DEFAULT '',
file_size INTEGER DEFAULT 0,
status TEXT DEFAULT 'success',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_logs_username ON ftp_logs(username);
CREATE INDEX IF NOT EXISTS idx_logs_created ON ftp_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_logs_action ON ftp_logs(action);
CREATE TABLE IF NOT EXISTS system_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
total_uploads INTEGER DEFAULT 0,
total_downloads INTEGER DEFAULT 0,
total_upload_bytes INTEGER DEFAULT 0,
total_download_bytes INTEGER DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ip_access_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT DEFAULT '',
ip TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'blacklist',
note TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ip_rules_type ON ip_access_rules(type);
CREATE INDEX IF NOT EXISTS idx_ip_rules_enabled ON ip_access_rules(enabled);
CREATE INDEX IF NOT EXISTS idx_ip_rules_username ON ip_access_rules(username);
`
_, err := db.Exec(schema)
return err
}
// --- FTP用户 CRUD ---
// CreateUser 创建FTP用户
func (db *DB) CreateUser(user *FTPUser) error {
now := time.Now().Format("2006-01-02 15:04:05")
result, err := db.Exec(`
INSERT INTO ftp_users (username, password, home_dir, permissions, quota_size, quota_files,
upload_rate, download_rate, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.Username, user.Password, user.HomeDir, user.Permissions,
user.QuotaSize, user.QuotaFiles, user.UploadRate, user.DownloadRate,
user.Enabled, now, now)
if err != nil {
return fmt.Errorf("创建用户失败: %w", err)
}
user.ID, _ = result.LastInsertId()
user.CreatedAt = now
user.UpdatedAt = now
return nil
}
// GetUser 获取用户
func (db *DB) GetUser(username string) (*FTPUser, error) {
user := &FTPUser{}
var enabled int
err := db.QueryRow(`
SELECT id, username, password, home_dir, permissions, quota_size, quota_files,
upload_rate, download_rate, enabled, created_at, updated_at
FROM ftp_users WHERE username = ?`, username,
).Scan(&user.ID, &user.Username, &user.Password, &user.HomeDir, &user.Permissions,
&user.QuotaSize, &user.QuotaFiles, &user.UploadRate, &user.DownloadRate,
&enabled, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Enabled = enabled == 1
return user, nil
}
// ListUsers 列出所有用户
func (db *DB) ListUsers() ([]FTPUser, error) {
rows, err := db.Query(`
SELECT id, username, password, home_dir, permissions, quota_size, quota_files,
upload_rate, download_rate, enabled, created_at, updated_at
FROM ftp_users ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []FTPUser
for rows.Next() {
var user FTPUser
var enabled int
err := rows.Scan(&user.ID, &user.Username, &user.Password, &user.HomeDir, &user.Permissions,
&user.QuotaSize, &user.QuotaFiles, &user.UploadRate, &user.DownloadRate,
&enabled, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return nil, err
}
user.Enabled = enabled == 1
users = append(users, user)
}
return users, nil
}
// UpdateUser 更新用户
func (db *DB) UpdateUser(user *FTPUser) error {
now := time.Now().Format("2006-01-02 15:04:05")
_, err := db.Exec(`
UPDATE ftp_users SET home_dir=?, permissions=?, quota_size=?, quota_files=?,
upload_rate=?, download_rate=?, enabled=?, updated_at=?
WHERE username=?`,
user.HomeDir, user.Permissions, user.QuotaSize, user.QuotaFiles,
user.UploadRate, user.DownloadRate, user.Enabled, now, user.Username)
return err
}
// UpdateUserPassword 更新用户密码
func (db *DB) UpdateUserPassword(username, password string) error {
now := time.Now().Format("2006-01-02 15:04:05")
_, err := db.Exec(`UPDATE ftp_users SET password=?, updated_at=? WHERE username=?`,
password, now, username)
return err
}
// DeleteUser 删除用户
func (db *DB) DeleteUser(username string) error {
_, err := db.Exec(`DELETE FROM ftp_users WHERE username=?`, username)
return err
}
// --- 日志 ---
// AddLog 添加操作日志
func (db *DB) AddLog(log *FTPLog) error {
now := time.Now().Format("2006-01-02 15:04:05")
result, err := db.Exec(`
INSERT INTO ftp_logs (username, ip, action, file_path, file_size, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
log.Username, log.IP, log.Action, log.FilePath, log.FileSize, log.Status, now)
if err != nil {
return err
}
log.ID, _ = result.LastInsertId()
log.CreatedAt = now
return nil
}
// QueryLogs 查询日志
func (db *DB) QueryLogs(username, action string, page, pageSize int) ([]FTPLog, int, error) {
where := "WHERE 1=1"
args := []interface{}{}
if username != "" {
where += " AND username LIKE ?"
args = append(args, "%"+username+"%")
}
if action != "" {
where += " AND action = ?"
args = append(args, action)
}
// 获取总数
var total int
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM ftp_logs %s", where)
err := db.QueryRow(countSQL, args...).Scan(&total)
if err != nil {
return nil, 0, err
}
// 分页查询
offset := (page - 1) * pageSize
querySQL := fmt.Sprintf(`
SELECT id, username, ip, action, file_path, file_size, status, created_at
FROM ftp_logs %s ORDER BY id DESC LIMIT ? OFFSET ?`, where)
queryArgs := append(args, pageSize, offset)
rows, err := db.Query(querySQL, queryArgs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var logs []FTPLog
for rows.Next() {
var log FTPLog
err := rows.Scan(&log.ID, &log.Username, &log.IP, &log.Action, &log.FilePath,
&log.FileSize, &log.Status, &log.CreatedAt)
if err != nil {
return nil, 0, err
}
logs = append(logs, log)
}
return logs, total, nil
}
// GetLogStats 获取日志统计
func (db *DB) GetLogStats() (map[string]interface{}, error) {
stats := make(map[string]interface{})
var totalUsers int
db.QueryRow("SELECT COUNT(*) FROM ftp_users").Scan(&totalUsers)
stats["total_users"] = totalUsers
var enabledUsers int
db.QueryRow("SELECT COUNT(*) FROM ftp_users WHERE enabled=1").Scan(&enabledUsers)
stats["enabled_users"] = enabledUsers
var todayLogins int
db.QueryRow("SELECT COUNT(DISTINCT username) FROM ftp_logs WHERE action='login' AND created_at >= date('now')").Scan(&todayLogins)
stats["today_logins"] = todayLogins
var todayUploads int
db.QueryRow("SELECT COUNT(*) FROM ftp_logs WHERE action='upload' AND created_at >= date('now')").Scan(&todayUploads)
stats["today_uploads"] = todayUploads
var todayDownloads int
db.QueryRow("SELECT COUNT(*) FROM ftp_logs WHERE action='download' AND created_at >= date('now')").Scan(&todayDownloads)
stats["today_downloads"] = todayDownloads
var totalUploadBytes int64
db.QueryRow("SELECT COALESCE(SUM(file_size),0) FROM ftp_logs WHERE action='upload'").Scan(&totalUploadBytes)
stats["total_upload_bytes"] = totalUploadBytes
var totalDownloadBytes int64
db.QueryRow("SELECT COALESCE(SUM(file_size),0) FROM ftp_logs WHERE action='download'").Scan(&totalDownloadBytes)
stats["total_download_bytes"] = totalDownloadBytes
return stats, nil
}
// CleanOldLogs 清理旧日志(保留最近N天)
func (db *DB) CleanOldLogs(days int) (int64, error) {
result, err := db.Exec(`DELETE FROM ftp_logs WHERE created_at < datetime('now', ?||' days')`,
fmt.Sprintf("-%d", days))
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// --- IP访问规则 CRUD ---
// CreateIPRule 创建IP规则
func (db *DB) CreateIPRule(rule *IPAccessRule) error {
now := time.Now().Format("2006-01-02 15:04:05")
result, err := db.Exec(`
INSERT INTO ip_access_rules (username, ip, type, note, enabled, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
rule.Username, rule.IP, rule.Type, rule.Note, rule.Enabled, now)
if err != nil {
return fmt.Errorf("创建IP规则失败: %w", err)
}
rule.ID, _ = result.LastInsertId()
rule.CreatedAt = now
return nil
}
// ListIPRules 列出IP规则,ruleType和username为空时列出全部
func (db *DB) ListIPRules(ruleType, username string) ([]IPAccessRule, error) {
query := `SELECT id, username, ip, type, note, enabled, created_at FROM ip_access_rules`
var conditions []string
var args []interface{}
if ruleType != "" {
conditions = append(conditions, "type=?")
args = append(args, ruleType)
}
if username != "" {
if username == "__empty__" {
conditions = append(conditions, "username='')")
} else if username == "__has__" {
conditions = append(conditions, "username!='')")
} else {
conditions = append(conditions, "username=?")
args = append(args, username)
}
}
if len(conditions) > 0 {
query += " WHERE " + strings.Join(conditions, " AND ")
}
query += " ORDER BY id"
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var rules []IPAccessRule
for rows.Next() {
var rule IPAccessRule
var enabled int
if err := rows.Scan(&rule.ID, &rule.Username, &rule.IP, &rule.Type, &rule.Note, &enabled, &rule.CreatedAt); err != nil {
return nil, err
}
rule.Enabled = enabled == 1
rules = append(rules, rule)
}
return rules, nil
}
// DeleteIPRule 删除IP规则
func (db *DB) DeleteIPRule(id int64) error {
_, err := db.Exec(`DELETE FROM ip_access_rules WHERE id=?`, id)
return err
}
// UpdateIPRule 更新IP规则
func (db *DB) UpdateIPRule(rule *IPAccessRule) error {
_, err := db.Exec(`UPDATE ip_access_rules SET username=?, ip=?, type=?, note=?, enabled=? WHERE id=?`,
rule.Username, rule.IP, rule.Type, rule.Note, rule.Enabled, rule.ID)
return err
}
// GetEnabledIPRules 获取所有启用的IP规则(全局+指定用户)
func (db *DB) GetEnabledIPRules(username string) ([]IPAccessRule, error) {
rows, err := db.Query(`SELECT id, username, ip, type, note, enabled, created_at
FROM ip_access_rules WHERE enabled=1 AND (username='' OR username=?) ORDER BY id`, username)
if err != nil {
return nil, err
}
defer rows.Close()
var rules []IPAccessRule
for rows.Next() {
var rule IPAccessRule
var enabled int
if err := rows.Scan(&rule.ID, &rule.Username, &rule.IP, &rule.Type, &rule.Note, &enabled, &rule.CreatedAt); err != nil {
return nil, err
}
rule.Enabled = enabled == 1
rules = append(rules, rule)
}
return rules, nil
}
+97
View File
@@ -0,0 +1,97 @@
@echo off
chcp 65001 >nul 2>&1
title FTP Server
:: ============================
:: FTP Server 启动/停止脚本 (Windows)
:: ============================
set "APP_NAME=ftp-server"
set "APP_EXE=%~dp0ftp-server.exe"
set "CONFIG=%~dp0config.json"
set "LOG_FILE=%~dp0ftp-server.log"
set "PID_FILE=%~dp0ftp-server.pid"
if "%1"=="" goto :usage
if "%1"=="start" goto :start
if "%1"=="stop" goto :stop
if "%1"=="restart" goto :restart
if "%1"=="status" goto :status
goto :usage
:: ---------- 启动 ----------
:start
if not exist "%APP_EXE%" (
echo [错误] 未找到 %APP_EXE%
echo 请先编译: go build -o ftp-server.exe ./cmd/
exit /b 1
)
:: 检查是否已在运行
tasklist /FI "IMAGENAME eq %APP_NAME%.exe" 2>nul | find /i "%APP_NAME%.exe" >nul
if %errorlevel%==0 (
echo [提示] %APP_NAME% 已经在运行中
goto :status
)
echo [启动] 正在启动 %APP_NAME% ...
start "%APP_NAME%" /MIN "%APP_EXE%" -config "%CONFIG%"
:: 等待启动
timeout /t 2 /nobreak >nul
:: 检查是否启动成功
tasklist /FI "IMAGENAME eq %APP_NAME%.exe" 2>nul | find /i "%APP_NAME%.exe" >nul
if %errorlevel%==0 (
echo [成功] %APP_NAME% 已启动
echo Web 管理面板: http://localhost:8080
echo FTP 端口: 2121
) else (
echo [失败] %APP_NAME% 启动失败,请检查配置
)
goto :eof
:: ---------- 停止 ----------
:stop
echo [停止] 正在停止 %APP_NAME% ...
tasklist /FI "IMAGENAME eq %APP_NAME%.exe" 2>nul | find /i "%APP_NAME%.exe" >nul
if %errorlevel%==0 (
taskkill /F /IM "%APP_NAME%.exe" >nul 2>&1
timeout /t 1 /nobreak >nul
echo [成功] %APP_NAME% 已停止
) else (
echo [提示] %APP_NAME% 未在运行
)
goto :eof
:: ---------- 重启 ----------
:restart
call %0 stop
timeout /t 2 /nobreak >nul
call %0 start
goto :eof
:: ---------- 状态 ----------
:status
tasklist /FI "IMAGENAME eq %APP_NAME%.exe" 2>nul | find /i "%APP_NAME%.exe" >nul
if %errorlevel%==0 (
echo [状态] %APP_NAME% 正在运行
for /f "tokens=2" %%a in ('tasklist /FI "IMAGENAME eq %APP_NAME%.exe" /NH 2^>nul') do (
echo PID: %%a
)
) else (
echo [状态] %APP_NAME% 未运行
)
goto :eof
:: ---------- 帮助 ----------
:usage
echo.
echo 用法: %~nx0 {start^|stop^|restart^|status}
echo.
echo 命令:
echo start 启动 FTP Server
echo stop 停止 FTP Server
echo restart 重启 FTP Server
echo status 查看运行状态
echo.
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# ============================
# FTP Server 启动/停止脚本 (Linux/macOS)
# ============================
APP_NAME="ftp-server"
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_BIN="$APP_DIR/ftp-server"
CONFIG="$APP_DIR/config.json"
LOG_FILE="$APP_DIR/ftp-server.log"
PID_FILE="$APP_DIR/ftp-server.pid"
# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[信息]${NC} $1"; }
warn() { echo -e "${YELLOW}[提示]${NC} $1"; }
error() { echo -e "${RED}[错误]${NC} $1"; }
# 获取 PID
get_pid() {
if [ -f "$PID_FILE" ]; then
local pid=$(cat "$PID_FILE" 2>/dev/null)
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo "$pid"
return 0
fi
# PID 文件过期,清理
rm -f "$PID_FILE"
fi
# 尝试通过进程名查找
pgrep -f "$APP_BIN" 2>/dev/null | head -1
}
# ---------- 启动 ----------
do_start() {
if [ ! -f "$APP_BIN" ]; then
error "未找到 $APP_BIN"
echo "请先编译: go build -o ftp-server ./cmd/"
exit 1
fi
local pid=$(get_pid)
if [ -n "$pid" ]; then
warn "$APP_NAME 已经在运行中 (PID: $pid)"
exit 0
fi
info "正在启动 $APP_NAME ..."
nohup "$APP_BIN" -config "$CONFIG" >> "$LOG_FILE" 2>&1 &
local new_pid=$!
echo "$new_pid" > "$PID_FILE"
sleep 2
if kill -0 "$new_pid" 2>/dev/null; then
info "$APP_NAME 已启动 (PID: $new_pid)"
echo " Web 管理面板: http://localhost:8080"
echo " FTP 端口: 2121"
echo " 日志文件: $LOG_FILE"
else
error "$APP_NAME 启动失败,请查看日志: $LOG_FILE"
rm -f "$PID_FILE"
exit 1
fi
}
# ---------- 停止 ----------
do_stop() {
local pid=$(get_pid)
if [ -z "$pid" ]; then
warn "$APP_NAME 未在运行"
exit 0
fi
info "正在停止 $APP_NAME (PID: $pid) ..."
kill "$pid" 2>/dev/null
# 等待进程退出
local count=0
while kill -0 "$pid" 2>/dev/null && [ $count -lt 10 ]; do
sleep 1
count=$((count + 1))
done
# 如果还没退出,强制杀掉
if kill -0 "$pid" 2>/dev/null; then
warn "正常停止超时,强制终止..."
kill -9 "$pid" 2>/dev/null
fi
rm -f "$PID_FILE"
info "$APP_NAME 已停止"
}
# ---------- 状态 ----------
do_status() {
local pid=$(get_pid)
if [ -n "$pid" ]; then
info "$APP_NAME 正在运行 (PID: $pid)"
else
warn "$APP_NAME 未运行"
fi
}
# ---------- 帮助 ----------
usage() {
echo ""
echo " 用法: $0 {start|stop|restart|status}"
echo ""
echo " 命令:"
echo " start 启动 FTP Server"
echo " stop 停止 FTP Server"
echo " restart 重启 FTP Server"
echo " status 查看运行状态"
echo ""
}
# ---------- 主入口 ----------
case "$1" in
start) do_start ;;
stop) do_stop ;;
restart) do_stop; sleep 2; do_start ;;
status) do_status ;;
*) usage; exit 1 ;;
esac
-116
View File
@@ -1,116 +0,0 @@
package ftp
import (
"io"
"os"
"ftp-server/database"
"github.com/spf13/afero"
)
// loggingFs 带日志功能的文件系统包装器
type loggingFs struct {
afero.Fs
db *database.DB
username string
}
// loggingFile 带日志功能的文件包装器
type loggingFile struct {
afero.File
fs *loggingFs
name string
flags int
written int64
read int64
logged bool
}
// newLoggingFs 创建带日志的文件系统
func newLoggingFs(base afero.Fs, db *database.DB, username string) *loggingFs {
return &loggingFs{
Fs: base,
db: db,
username: username,
}
}
// GetHandle 实现 ftpserverlib.ClientDriverExtentionFileTransfer 接口
// flags: os.O_RDONLY 表示下载, os.O_WRONLY/os.O_CREATE 表示上传
func (fs *loggingFs) GetHandle(name string, flags int, offset int64) (interface {
io.Reader
io.Writer
io.Seeker
io.Closer
}, error) {
file, err := fs.Fs.OpenFile(name, flags, 0666)
if err != nil {
return nil, err
}
// 如果有偏移量(断点续传),先 Seek
if offset > 0 {
if _, err := file.Seek(offset, 0); err != nil {
file.Close()
return nil, err
}
}
return &loggingFile{
File: file,
fs: fs,
name: name,
flags: flags,
logged: false,
}, nil
}
func (f *loggingFile) Write(p []byte) (int, error) {
n, err := f.File.Write(p)
f.written += int64(n)
return n, err
}
func (f *loggingFile) Read(p []byte) (int, error) {
n, err := f.File.Read(p)
f.read += int64(n)
return n, err
}
func (f *loggingFile) Close() error {
err := f.File.Close()
// 避免重复记录
if f.logged {
return err
}
f.logged = true
if f.fs.db == nil {
return err
}
// 判断是上传还是下载
isWrite := (f.flags & os.O_WRONLY) != 0 || (f.flags & os.O_RDWR) != 0 || (f.flags & os.O_CREATE) != 0
if isWrite && f.written > 0 {
f.fs.db.AddLog(&database.FTPLog{
Username: f.fs.username,
Action: "upload",
FilePath: f.name,
FileSize: f.written,
Status: "success",
})
} else if !isWrite && f.read > 0 {
f.fs.db.AddLog(&database.FTPLog{
Username: f.fs.username,
Action: "download",
FilePath: f.name,
FileSize: f.read,
Status: "success",
})
}
return err
}
+661 -299
View File
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,23 +1,3 @@
module ftp-server
go 1.26.2
require (
github.com/fclairamb/ftpserverlib v0.30.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/spf13/afero v1.15.0
modernc.org/sqlite v1.50.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.28.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
-69
View File
@@ -1,69 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fclairamb/ftpserverlib v0.30.0 h1:caB9sDn1Au//q0j2ev/icPn388qPuk4k1ajSvglDcMQ=
github.com/fclairamb/ftpserverlib v0.30.0/go.mod h1:QmogtltTOgkihyKza0GNo37Mu4AEzbJ+sH6W9Y0MBIQ=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+347
View File
@@ -0,0 +1,347 @@
package httpfile
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"ftp-server/config"
)
// Server represents the HTTP file server
type Server struct {
config *config.Config
}
// NewServer creates a new HTTP file server
func NewServer(cfg *config.Config) *Server {
return &Server{config: cfg}
}
// Start starts the HTTP file server
func (s *Server) Start() error {
if !s.config.HTTPFile.Enable {
return nil
}
rootDir := s.config.HTTPFile.RootDir
if err := os.MkdirAll(rootDir, 0755); err != nil {
return fmt.Errorf("failed to create root directory: %v", err)
}
mux := http.NewServeMux()
// Serve HTML page
mux.HandleFunc("/", s.handleFileBrowser)
// API endpoints
mux.HandleFunc("/api/list", s.apiListDir)
mux.HandleFunc("/api/download", s.apiDownload)
addr := fmt.Sprintf("%s:%d", s.config.HTTPFile.Host, s.config.HTTPFile.Port)
log.Printf("HTTP file server listening on http://%s", addr)
return http.ListenAndServe(addr, mux)
}
func (s *Server) handleFileBrowser(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.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Write([]byte(fileBrowserHTML))
}
func (s *Server) apiListDir(w http.ResponseWriter, r *http.Request) {
dir := r.URL.Query().Get("path")
if dir == "" {
dir = "/"
}
realPath := s.toRealPath(dir)
entries, err := os.ReadDir(realPath)
if err != nil {
http.Error(w, `{"error":"Cannot read directory"}`, http.StatusInternalServerError)
return
}
var files []map[string]interface{}
for _, entry := range entries {
info, _ := entry.Info()
modTime := info.ModTime().Format("2006-01-02 15:04:05")
size := info.Size()
file := map[string]interface{}{
"name": entry.Name(),
"modTime": modTime,
"size": formatSize(size),
"isDir": entry.IsDir(),
}
if !entry.IsDir() {
file["ext"] = strings.ToLower(filepath.Ext(entry.Name()))
}
files = append(files, file)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"files": files,
})
}
func (s *Server) apiDownload(w http.ResponseWriter, r *http.Request) {
file := r.URL.Query().Get("file")
if file == "" {
http.Error(w, "Missing file parameter", http.StatusBadRequest)
return
}
realPath := s.toRealPath(file)
if info, err := os.Stat(realPath); err != nil || info.IsDir() {
http.Error(w, "File not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, realPath)
log.Printf("HTTP download: %s from %s", file, r.RemoteAddr)
}
func (s *Server) toRealPath(httpPath string) string {
rootDir := s.config.HTTPFile.RootDir
cleanPath := strings.TrimPrefix(httpPath, "/")
return filepath.Join(rootDir, cleanPath)
}
func formatSize(size int64) string {
switch {
case size < 1024:
return fmt.Sprintf("%d B", size)
case size < 1024*1024:
return fmt.Sprintf("%.1f KB", float64(size)/1024)
case size < 1024*1024*1024:
return fmt.Sprintf("%.1f MB", float64(size)/(1024*1024))
default:
return fmt.Sprintf("%.1f GB", float64(size)/(1024*1024*1024))
}
}
// fileBrowserHTML is the embedded HTML page
const fileBrowserHTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTTP 文件服务器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.header { background: white; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); }
.header h1 { font-size: 28px; color: #333; margin-bottom: 8px; }
.header p { color: #888; font-size: 14px; }
.breadcrumb { background: white; border-radius: 12px; padding: 16px 24px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.breadcrumb a { color: #667eea; text-decoration: none; font-weight: 500; padding: 4px 8px; border-radius: 6px; transition: background 0.2s; }
.breadcrumb a:hover { background: #f0f4ff; }
.breadcrumb span { color: #999; font-size: 20px; }
.actions { background: white; border-radius: 12px; padding: 16px 24px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.3s; display: inline-flex; align-items: center; gap: 6px; }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: white; }
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(102,126,234,0.4); }
.btn-success { background: #28a745; color: white; }
.btn-success:hover { background: #218838; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.file-list { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); }
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; }
.file-name { display: flex; align-items: center; gap: 8px; }
.file-icon { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; }
.folder-icon { background: #ffeaa7; }
.file-icon-doc { background: #74b9ff; }
.file-icon-img { background: #ff7675; }
.file-icon-zip { background: #a29bfe; }
.file-icon-code { background: #55efc4; }
.file-icon-other { background: #dfe6e9; }
.file-link { color: #667eea; text-decoration: none; font-weight: 500; }
.file-link:hover { text-decoration: underline; }
.actions-cell { display: flex; gap: 8px; }
.empty-state { text-align: center; padding: 60px 20px; color: #999; }
.empty-state .icon { font-size: 64px; margin-bottom: 16px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>HTTP 文件服务器</h1>
<p>在线浏览、下载文件</p>
</div>
<div class="breadcrumb" id="breadcrumb"></div>
<div class="actions">
<button class="btn btn-primary" onclick="refresh()">刷新</button>
</div>
<div class="file-list">
<table id="fileTable">
<thead>
<tr><th>名称</th><th>大小</th><th>修改时间</th><th>操作</th></tr>
</thead>
<tbody id="fileTableBody"></tbody>
</table>
<div class="empty-state" id="emptyState" style="display:none">
<div class="icon">&#128193;</div>
<div>目录为空</div>
</div>
</div>
</div>
<script>
var currentPath = '/';
function init() {
loadFiles();
}
function loadFiles() {
fetch('/api/list?path=' + encodeURIComponent(currentPath))
.then(function(r) { return r.json(); })
.then(function(data) {
var tbody = document.getElementById('fileTableBody');
tbody.innerHTML = '';
if (!data.files || data.files.length === 0) {
document.getElementById('emptyState').style.display = 'block';
document.getElementById('fileTable').style.display = 'none';
return;
}
document.getElementById('emptyState').style.display = 'none';
document.getElementById('fileTable').style.display = 'table';
data.files.forEach(function(f) {
var tr = document.createElement('tr');
var nameTd = document.createElement('td');
var nameDiv = document.createElement('div');
nameDiv.className = 'file-name';
var iconDiv = document.createElement('div');
var icon = f.isDir ? '&#128193;' : getFileIcon(f.name);
var iconClass = f.isDir ? 'folder-icon' : 'file-icon-' + getFileType(f.name);
iconDiv.className = 'file-icon ' + iconClass;
iconDiv.innerHTML = icon;
nameDiv.appendChild(iconDiv);
if (f.isDir) {
var link = document.createElement('a');
link.className = 'file-link';
link.href = '#';
link.textContent = f.name;
link.onclick = function() { enterFolder(f.name); return false; };
nameDiv.appendChild(link);
} else {
var span = document.createElement('span');
span.textContent = f.name;
nameDiv.appendChild(span);
}
nameTd.appendChild(nameDiv);
var sizeTd = document.createElement('td');
sizeTd.textContent = f.isDir ? '-' : f.size;
var timeTd = document.createElement('td');
timeTd.textContent = f.modTime;
var actionTd = document.createElement('td');
actionTd.className = 'actions-cell';
if (f.isDir) {
var btn = document.createElement('button');
btn.className = 'btn btn-sm btn-primary';
btn.textContent = '打开';
btn.onclick = function() { enterFolder(f.name); };
actionTd.appendChild(btn);
} else {
var link = document.createElement('a');
link.className = 'btn btn-sm btn-success';
link.href = '/api/download?file=' + encodeURIComponent(currentPath + f.name);
link.textContent = '下载';
actionTd.appendChild(link);
}
tr.appendChild(nameTd);
tr.appendChild(sizeTd);
tr.appendChild(timeTd);
tr.appendChild(actionTd);
tbody.appendChild(tr);
});
updateBreadcrumb();
});
}
function enterFolder(name) {
currentPath = currentPath === '/' ? '/' + name + '/' : currentPath + name + '/';
loadFiles();
}
function updateBreadcrumb() {
var bc = document.getElementById('breadcrumb');
bc.innerHTML = '<a href="#" onclick="goTo(\'/\');return false;">根目录</a>';
var parts = currentPath.split('/').filter(function(p) { return p; });
var path = '';
parts.forEach(function(p) {
path += p + '/';
bc.innerHTML += '<span>/</span><a href="#" onclick="goTo(\'' + path + '\');return false;">' + p + '</a>';
});
}
function goTo(path) {
currentPath = path;
loadFiles();
}
function refresh() {
loadFiles();
}
function getFileIcon(name) {
var ext = name.split('.').pop().toLowerCase();
var icons = {
'doc': '&#128196;', 'docx': '&#128196;', 'pdf': '&#128196;', 'txt': '&#128196;',
'xls': '&#128202;', 'xlsx': '&#128202;', 'csv': '&#128202;',
'jpg': '&#127748;', 'jpeg': '&#127748;', 'png': '&#127748;', 'gif': '&#127748;', 'bmp': '&#127748;',
'mp3': '&#127925;', 'wav': '&#127925;', 'flac': '&#127925;',
'mp4': '&#127916;', 'avi': '&#127916;', 'mkv': '&#127916;', 'mov': '&#127916;',
'zip': '&#128230;', 'rar': '&#128230;', '7z': '&#128230;', 'tar': '&#128230;', 'gz': '&#128230;',
'js': '&#128187;', 'ts': '&#128187;', 'py': '&#128187;', 'go': '&#128187;', 'java': '&#128187;', 'c': '&#128187;', 'cpp': '&#128187;', 'html': '&#128187;', 'css': '&#128187;', 'json': '&#128187;', 'xml': '&#128187;', 'yml': '&#128187;', 'yaml': '&#128187;'
};
return icons[ext] || '&#128196;';
}
function getFileType(name) {
var ext = name.split('.').pop().toLowerCase();
if (['jpg','jpeg','png','gif','bmp','svg'].indexOf(ext) !== -1) return 'img';
if (['zip','rar','7z','tar','gz'].indexOf(ext) !== -1) return 'zip';
if (['js','ts','py','go','java','c','cpp','html','css','json','xml','yml','yaml'].indexOf(ext) !== -1) return 'code';
return 'doc';
}
init();
</script>
</body>
</html>
`
-74
View File
@@ -1,74 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"ftp-server/config"
"ftp-server/database"
"ftp-server/ftp"
"ftp-server/web"
)
func main() {
configPath := flag.String("config", "config.json", "配置文件路径")
flag.Parse()
fmt.Println("========================================")
fmt.Println(" FTP Server with Web Management")
fmt.Println("========================================")
fmt.Println()
// 加载配置
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
log.Println("配置加载成功")
// 打开数据库
dbCfg := cfg.Get().Database
db, err := database.Open(dbCfg.Path)
if err != nil {
log.Fatalf("打开数据库失败: %v", err)
}
defer db.Close()
log.Println("数据库初始化成功")
// 启动FTP服务器
ftpServer := ftp.NewServer(cfg, db)
if err := ftpServer.Start(); err != nil {
log.Fatalf("启动FTP服务器失败: %v", err)
}
// 启动Web管理服务器
webServer := web.NewServer(cfg, db, ftpServer)
// 信号处理
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
if err := webServer.Start(); err != nil {
log.Fatalf("启动Web服务器失败: %v", err)
}
}()
webCfg := cfg.Get().Web
fmt.Println()
fmt.Printf(" FTP端口: %d\n", cfg.Get().FTP.Port)
fmt.Printf(" Web管理: http://localhost:%d\n", webCfg.Port)
fmt.Println()
fmt.Println(" 按 Ctrl+C 停止服务器")
fmt.Println()
<-sigChan
fmt.Println()
log.Println("正在关闭服务器...")
ftpServer.Stop()
log.Println("服务器已停止")
}
-305
View File
@@ -1,305 +0,0 @@
#!/bin/bash
# FTP Server 服务管理脚本
# 用法: ./service-linux.sh {install|uninstall|start|stop|restart|status|enable|disable|logs}
# 配置区域 - 请根据实际路径修改
INSTALL_DIR="/opt/ftp-server"
FTP_EXE="$INSTALL_DIR/ftp-server"
CONFIG_FILE="$INSTALL_DIR/config.json"
SERVICE_NAME="ftp-server"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
LOG_FILE="$INSTALL_DIR/server.log"
PID_FILE="$INSTALL_DIR/ftp-server.pid"
# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[信息]${NC} $1"; }
warn() { echo -e "${YELLOW}[警告]${NC} $1"; }
error() { echo -e "${RED}[错误]${NC} $1"; }
# 检查 root 权限
check_root() {
if [ "$EUID" -ne 0 ]; then
error "请使用 root 权限运行此脚本 (sudo ./service-linux.sh ...)"
exit 1
fi
}
# 检查可执行文件
check_exe() {
if [ ! -f "$FTP_EXE" ]; then
error "找不到 $FTP_EXE"
error "请先编译: go build -o $FTP_EXE ."
exit 1
fi
if [ ! -x "$FTP_EXE" ]; then
chmod +x "$FTP_EXE"
fi
}
# 安装服务
do_install() {
check_root
check_exe
# 创建工作目录
mkdir -p "$INSTALL_DIR/data"
info "正在创建 systemd 服务文件..."
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=FTP Server with Web Management
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=$INSTALL_DIR
ExecStart=$FTP_EXE -config $CONFIG_FILE
ExecReload=/bin/kill -HUP \$MAINPID
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
# 日志输出到 journalctl
StandardOutput=journal
StandardError=journal
SyslogIdentifier=$SERVICE_NAME
[Install]
WantedBy=multi-user.target
EOF
# 重载 systemd
systemctl daemon-reload
info "服务已安装: $SERVICE_FILE"
info "使用 '$0 start' 启动服务"
info "使用 '$0 enable' 设置开机自启"
}
# 卸载服务
do_uninstall() {
check_root
info "正在停止服务..."
systemctl stop "$SERVICE_NAME" 2>/dev/null
systemctl disable "$SERVICE_NAME" 2>/dev/null
if [ -f "$SERVICE_FILE" ]; then
rm -f "$SERVICE_FILE"
systemctl daemon-reload
info "服务已卸载"
else
warn "服务文件不存在,可能未安装"
fi
}
# 启动服务
do_start() {
if [ -f "$SERVICE_FILE" ]; then
check_root
systemctl start "$SERVICE_NAME"
if [ $? -eq 0 ]; then
info "服务已启动"
info " FTP端口: 2121"
info " Web管理: http://localhost:8080"
else
error "服务启动失败,查看日志: $0 logs"
exit 1
fi
else
# 前台/后台直接运行
check_exe
info "以后台模式启动..."
cd "$INSTALL_DIR"
nohup "$FTP_EXE" -config "$CONFIG_FILE" >> "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
sleep 1
if kill -0 $(cat "$PID_FILE" 2>/dev/null) 2>/dev/null; then
info "服务已启动 (PID: $(cat $PID_FILE))"
info " FTP端口: 2121"
info " Web管理: http://localhost:8080"
info " 日志文件: $LOG_FILE"
else
error "服务启动失败,查看日志: $LOG_FILE"
rm -f "$PID_FILE"
exit 1
fi
fi
}
# 停止服务
do_stop() {
if [ -f "$SERVICE_FILE" ]; then
check_root
systemctl stop "$SERVICE_NAME"
if [ $? -eq 0 ]; then
info "服务已停止"
else
error "服务停止失败"
exit 1
fi
elif [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
kill "$PID"
sleep 2
if kill -0 "$PID" 2>/dev/null; then
kill -9 "$PID"
fi
rm -f "$PID_FILE"
info "服务已停止 (PID: $PID)"
else
warn "进程 $PID 已不存在"
rm -f "$PID_FILE"
fi
else
# 尝试查找进程
PID=$(pgrep -f "$FTP_EXE")
if [ -n "$PID" ]; then
kill "$PID"
info "服务已停止 (PID: $PID)"
else
warn "服务未在运行"
fi
fi
}
# 重启服务
do_restart() {
do_stop
sleep 2
do_start
}
# 查看状态
do_status() {
if [ -f "$SERVICE_FILE" ]; then
systemctl status "$SERVICE_NAME" --no-pager
elif [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
info "服务运行中 (PID: $PID)"
else
warn "服务未在运行 (PID文件过期)"
rm -f "$PID_FILE"
fi
else
PID=$(pgrep -f "$FTP_EXE")
if [ -n "$PID" ]; then
info "服务运行中 (PID: $PID)"
else
warn "服务未在运行"
fi
fi
}
# 开机自启
do_enable() {
check_root
if [ -f "$SERVICE_FILE" ]; then
systemctl enable "$SERVICE_NAME"
info "已设置开机自启"
else
error "请先运行 '$0 install' 安装服务"
exit 1
fi
}
# 关闭自启
do_disable() {
check_root
if [ -f "$SERVICE_FILE" ]; then
systemctl disable "$SERVICE_NAME"
info "已关闭开机自启"
else
error "服务未安装"
exit 1
fi
}
# 查看日志
do_logs() {
if [ -f "$SERVICE_FILE" ]; then
journalctl -u "$SERVICE_NAME" -f --no-pager -n 50
elif [ -f "$LOG_FILE" ]; then
tail -f "$LOG_FILE"
else
warn "没有可用的日志文件"
fi
}
# 菜单模式
do_menu() {
while true; do
echo ""
echo "========================================"
echo " FTP Server 服务管理工具 (Linux)"
echo "========================================"
echo ""
echo " 1. 安装服务 (systemd)"
echo " 2. 卸载服务"
echo " 3. 启动服务"
echo " 4. 停止服务"
echo " 5. 重启服务"
echo " 6. 查看服务状态"
echo " 7. 开机自启 - 开启"
echo " 8. 开机自启 - 关闭"
echo " 9. 查看运行日志"
echo " 0. 退出"
echo ""
read -p "请输入选项: " choice
case $choice in
1) do_install ;;
2) do_uninstall ;;
3) do_start ;;
4) do_stop ;;
5) do_restart ;;
6) do_status ;;
7) do_enable ;;
8) do_disable ;;
9) do_logs ;;
0) exit 0 ;;
*) warn "无效选项" ;;
esac
read -p "按 Enter 继续..."
done
}
# 主入口
case "${1:-}" in
install) do_install ;;
uninstall) do_uninstall ;;
start) do_start ;;
stop) do_stop ;;
restart) do_restart ;;
status) do_status ;;
enable) do_enable ;;
disable) do_disable ;;
logs) do_logs ;;
menu|"") do_menu ;;
*)
echo "用法: $0 {install|uninstall|start|stop|restart|status|enable|disable|logs|menu}"
echo ""
echo "命令说明:"
echo " install - 安装为 systemd 服务"
echo " uninstall - 卸载服务"
echo " start - 启动服务"
echo " stop - 停止服务"
echo " restart - 重启服务"
echo " status - 查看服务状态"
echo " enable - 设置开机自启"
echo " disable - 关闭开机自启"
echo " logs - 查看运行日志 (实时)"
echo " menu - 交互式菜单 (默认)"
exit 1
;;
esac
-166
View File
@@ -1,166 +0,0 @@
@echo off
chcp 65001 >nul
title FTP Server 服务管理
:: 配置区域 - 请根据实际路径修改
set FTP_HOME=%~dp0
set FTP_EXE=%FTP_HOME%ftp-server.exe
set SERVICE_NAME=FTP-Server
set CONFIG_FILE=%FTP_HOME%config.json
:: 检查管理员权限
net session >nul 2>&1
if %errorlevel% neq 0 (
echo [错误] 请以管理员身份运行此脚本!
echo 右键点击脚本 -^> "以管理员身份运行"
pause
exit /b 1
)
:: 检查可执行文件
if not exist "%FTP_EXE%" (
echo [错误] 找不到 %FTP_EXE%
echo 请确保 ftp-server.exe 在脚本同目录下
pause
exit /b 1
)
:menu
cls
echo ========================================
echo FTP Server 服务管理工具
echo ========================================
echo.
echo 1. 安装服务 (注册为 Windows 服务)
echo 2. 卸载服务
echo 3. 启动服务
echo 4. 停止服务
echo 5. 重启服务
echo 6. 查看服务状态
echo 7. 开机自启 - 开启
echo 8. 开机自启 - 关闭
echo 9. 查看运行日志
echo 0. 退出
echo.
set /p choice=请输入选项:
if "%choice%"=="1" goto install
if "%choice%"=="2" goto uninstall
if "%choice%"=="3" goto start
if "%choice%"=="4" goto stop
if "%choice%"=="5" goto restart
if "%choice%"=="6" goto status
if "%choice%"=="7" goto enable
if "%choice%"=="8" goto disable
if "%choice%"=="9" goto logs
if "%choice%"=="0" exit /b 0
echo 无效选项
pause
goto menu
:install
echo [信息] 正在安装服务...
sc create %SERVICE_NAME% binPath= "%FTP_EXE% -config %CONFIG_FILE%" start= demand DisplayName= "FTP Server with Web Management"
if %errorlevel% equ 0 (
echo [成功] 服务安装成功!
echo [信息] 使用选项 3 启动服务,或选项 7 设置开机自启
) else (
echo [失败] 服务安装失败
)
pause
goto menu
:uninstall
echo [信息] 正在停止服务...
net stop %SERVICE_NAME% >nul 2>&1
echo [信息] 正在卸载服务...
sc delete %SERVICE_NAME%
if %errorlevel% equ 0 (
echo [成功] 服务已卸载
) else (
echo [失败] 服务卸载失败,可能服务不存在
)
pause
goto menu
:start
echo [信息] 正在启动服务...
net start %SERVICE_NAME%
if %errorlevel% equ 0 (
echo [成功] 服务已启动
echo FTP端口: 2121
echo Web管理: http://localhost:8080
) else (
echo [失败] 服务启动失败
)
pause
goto menu
:stop
echo [信息] 正在停止服务...
net stop %SERVICE_NAME%
if %errorlevel% equ 0 (
echo [成功] 服务已停止
) else (
echo [失败] 服务停止失败,可能服务未运行
)
pause
goto menu
:restart
echo [信息] 正在重启服务...
net stop %SERVICE_NAME% >nul 2>&1
timeout /t 2 /nobreak >nul
net start %SERVICE_NAME%
if %errorlevel% equ 0 (
echo [成功] 服务已重启
) else (
echo [失败] 服务重启失败
)
pause
goto menu
:status
echo [信息] 服务状态:
sc query %SERVICE_NAME%
echo.
echo [信息] 服务配置:
sc qc %SERVICE_NAME%
pause
goto menu
:enable
echo [信息] 正在设置开机自启...
sc config %SERVICE_NAME% start= auto
if %errorlevel% equ 0 (
echo [成功] 已设置开机自启
) else (
echo [失败] 设置失败
)
pause
goto menu
:disable
echo [信息] 正在关闭开机自启...
sc config %SERVICE_NAME% start= demand
if %errorlevel% equ 0 (
echo [成功] 已关闭开机自启
) else (
echo [失败] 设置失败
)
pause
goto menu
:logs
echo [信息] 最近的应用程序日志 (FTP Server):
echo ========================================
wevtutil qe Application /q:"*[System[Provider[@Name='%SERVICE_NAME%']]]" /c:20 /f:text /rd:true
if %errorlevel% neq 0 (
echo 暂无事件日志
echo.
echo 提示: 可以直接查看控制台输出或重定向日志到文件:
echo %FTP_EXE% ^> server.log 2^>^&1
)
echo ========================================
pause
goto menu
-498
View File
@@ -1,498 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f0f2f5;
color: #333;
}
/* 登录页面 */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: #fff;
border-radius: 12px;
padding: 40px;
width: 400px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
text-align: center;
}
.login-card h1 {
font-size: 28px;
margin-bottom: 8px;
color: #1a1a2e;
}
.login-card p {
color: #888;
margin-bottom: 30px;
}
/* 主布局 */
.main-app {
display: flex;
min-height: 100vh;
}
/* 侧边栏 */
.sidebar {
width: 220px;
background: #1a1a2e;
color: #fff;
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
z-index: 100;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-header h2 {
font-size: 20px;
font-weight: 600;
}
.sidebar-menu {
list-style: none;
flex: 1;
padding: 10px 0;
}
.sidebar-menu li {
padding: 12px 20px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 10px;
color: rgba(255, 255, 255, 0.7);
}
.sidebar-menu li:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.sidebar-menu li.active {
background: rgba(255, 255, 255, 0.15);
color: #fff;
border-left: 3px solid #667eea;
}
.sidebar-menu .icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.sidebar-footer {
padding: 15px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* 内容区 */
.content {
margin-left: 220px;
flex: 1;
padding: 30px;
}
.page {
display: none;
}
.page.active {
display: block;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
}
h2 {
font-size: 22px;
margin-bottom: 20px;
color: #1a1a2e;
}
/* 统计卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.stat-card.wide {
grid-column: span 3;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #667eea;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #888;
}
/* 表格 */
.data-table {
width: 100%;
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-collapse: collapse;
}
.data-table th {
background: #f8f9fa;
padding: 12px 16px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: #666;
border-bottom: 2px solid #eee;
}
.data-table td {
padding: 10px 16px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.data-table tr:hover td {
background: #f8f9fa;
}
.data-table .status-enabled {
color: #52c41a;
font-weight: 600;
}
.data-table .status-disabled {
color: #ff4d4f;
font-weight: 600;
}
/* 按钮 */
.btn {
padding: 8px 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn:hover {
border-color: #667eea;
color: #667eea;
}
.btn-primary {
background: #667eea;
color: #fff;
border-color: #667eea;
}
.btn-primary:hover {
background: #5a6fd6;
color: #fff;
}
.btn-danger {
background: #ff4d4f;
color: #fff;
border-color: #ff4d4f;
}
.btn-danger:hover {
background: #e04346;
color: #fff;
}
.btn-sm {
padding: 4px 12px;
font-size: 13px;
}
.btn-block {
width: 100%;
padding: 12px;
font-size: 16px;
}
/* 表单 */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 14px;
color: #333;
}
.form-group small {
display: block;
margin-top: 4px;
color: #999;
font-size: 12px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.input-sm {
padding: 6px 10px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 13px;
}
.input-sm:focus {
outline: none;
border-color: #667eea;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
/* 对话框 */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
border-radius: 12px;
width: 520px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
}
.modal-close {
font-size: 24px;
cursor: pointer;
color: #999;
line-height: 1;
}
.modal-close:hover {
color: #333;
}
.modal-content form {
padding: 24px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
/* 面包屑 */
.breadcrumb {
background: #fff;
padding: 10px 16px;
border-radius: 6px;
margin-bottom: 12px;
font-size: 14px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.breadcrumb span {
cursor: pointer;
color: #667eea;
}
.breadcrumb span:hover {
text-decoration: underline;
}
/* 过滤栏 */
.filter-bar {
display: flex;
gap: 10px;
align-items: center;
}
/* 分页 */
.pagination {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 16px;
}
.pagination button {
padding: 6px 14px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 13px;
}
.pagination button.active {
background: #667eea;
color: #fff;
border-color: #667eea;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 文件操作 */
.file-actions {
display: flex;
gap: 10px;
}
/* 设置 */
.settings-section {
background: #fff;
border-radius: 8px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.settings-section h3 {
margin-bottom: 16px;
font-size: 16px;
color: #1a1a2e;
border-bottom: 1px solid #eee;
padding-bottom: 8px;
}
/* Toast提示 */
.toast {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 24px;
border-radius: 8px;
color: #fff;
font-size: 14px;
z-index: 2000;
display: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.toast.success {
background: #52c41a;
display: block;
}
.toast.error {
background: #ff4d4f;
display: block;
}
/* 操作按钮组 */
.action-btns {
display: flex;
gap: 4px;
}
.action-btns .btn {
padding: 4px 8px;
font-size: 12px;
}
/* 文件图标 */
.file-icon {
margin-right: 6px;
}
.dir-link {
color: #667eea;
cursor: pointer;
}
.dir-link:hover {
text-decoration: underline;
}
+559
View File
@@ -0,0 +1,559 @@
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">HTTP 文件服务</div>
<div class="value" id="statHttpFilePort">-</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;">HTTP 文件服务地址</td><td id="infoHttpFileAddr">-</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;
if (data.httpFilePort) {
document.getElementById('statHttpFilePort').textContent = data.httpFilePort;
document.getElementById('statHttpFilePort').style.color = '#28a745';
document.getElementById('infoHttpFileAddr').textContent = 'http://' + location.hostname + ':' + data.httpFilePort;
} else {
document.getElementById('statHttpFilePort').textContent = '未启用';
document.getElementById('statHttpFilePort').style.color = '#999';
document.getElementById('infoHttpFileAddr').textContent = '未启用';
}
});
}
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>
`
-424
View File
@@ -1,424 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FTP Server 管理</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<!-- 登录页面 -->
<div id="login-page" class="login-page">
<div class="login-card">
<h1>FTP Server</h1>
<p>Web 管理控制台</p>
<form id="login-form">
<div class="form-group">
<label>用户名</label>
<input type="text" id="login-username" value="admin" required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="login-password" value="admin123" required>
</div>
<button type="submit" class="btn btn-primary btn-block">登 录</button>
</form>
</div>
</div>
<!-- 主界面 -->
<div id="main-app" class="main-app" style="display:none">
<!-- 侧边栏 -->
<nav class="sidebar">
<div class="sidebar-header">
<h2>FTP Server</h2>
</div>
<ul class="sidebar-menu">
<li class="active" data-page="dashboard">
<span class="icon">&#9632;</span> 仪表盘
</li>
<li data-page="users">
<span class="icon">&#9775;</span> 用户管理
</li>
<li data-page="files">
<span class="icon">&#128193;</span> 文件管理
</li>
<li data-page="logs">
<span class="icon">&#128196;</span> 操作日志
</li>
<li data-page="online">
<span class="icon">&#128279;</span> 在线用户
</li>
<li data-page="ip-rules">
<span class="icon">&#128737;</span> IP白/黑名单
</li>
<li data-page="settings">
<span class="icon">&#9881;</span> 系统设置
</li>
</ul>
<div class="sidebar-footer">
<button id="logout-btn" class="btn btn-sm">退出登录</button>
</div>
</nav>
<!-- 内容区 -->
<main class="content">
<!-- 仪表盘 -->
<div id="page-dashboard" class="page active">
<h2>仪表盘</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="stat-users">-</div>
<div class="stat-label">总用户数</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-enabled">-</div>
<div class="stat-label">启用用户</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-online">-</div>
<div class="stat-label">在线用户</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-today-logins">-</div>
<div class="stat-label">今日登录</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-today-uploads">-</div>
<div class="stat-label">今日上传</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-today-downloads">-</div>
<div class="stat-label">今日下载</div>
</div>
</div>
<div class="stats-grid" style="margin-top:20px">
<div class="stat-card wide">
<div class="stat-value" id="stat-upload-bytes">-</div>
<div class="stat-label">总上传量</div>
</div>
<div class="stat-card wide">
<div class="stat-value" id="stat-download-bytes">-</div>
<div class="stat-label">总下载量</div>
</div>
</div>
</div>
<!-- 用户管理 -->
<div id="page-users" class="page">
<div class="page-header">
<h2>用户管理</h2>
<button class="btn btn-primary" onclick="showAddUser()">添加用户</button>
</div>
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>主目录</th>
<th>权限</th>
<th>配额</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
<!-- 文件管理 -->
<div id="page-files" class="page">
<div class="page-header">
<h2>文件管理</h2>
<div class="file-actions">
<button class="btn btn-primary" onclick="uploadFile()">上传文件</button>
<button class="btn" onclick="createFolder()">新建文件夹</button>
</div>
</div>
<div class="breadcrumb" id="file-breadcrumb">
<span>/</span>
</div>
<table class="data-table">
<thead>
<tr>
<th>名称</th>
<th>大小</th>
<th>修改时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="files-tbody"></tbody>
</table>
</div>
<!-- 操作日志 -->
<div id="page-logs" class="page">
<div class="page-header">
<h2>操作日志</h2>
<div class="filter-bar">
<input type="text" id="log-username" placeholder="用户名筛选" class="input-sm">
<select id="log-action" class="input-sm">
<option value="">全部操作</option>
<option value="login">登录</option>
<option value="login_failed">登录失败</option>
<option value="upload">上传</option>
<option value="download">下载</option>
<option value="delete">删除</option>
</select>
<button class="btn btn-sm" onclick="loadLogs()">查询</button>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>用户</th>
<th>IP</th>
<th>操作</th>
<th>文件路径</th>
<th>大小</th>
<th>状态</th>
</tr>
</thead>
<tbody id="logs-tbody"></tbody>
</table>
<div class="pagination" id="logs-pagination"></div>
</div>
<!-- 在线用户 -->
<div id="page-online" class="page">
<h2>在线用户</h2>
<table class="data-table">
<thead>
<tr>
<th>用户名</th>
<th>IP地址</th>
<th>登录时间</th>
<th>最后活动</th>
<th>当前目录</th>
</tr>
</thead>
<tbody id="online-tbody"></tbody>
</table>
</div>
<!-- IP白/黑名单 -->
<div id="page-ip-rules" class="page">
<div class="page-header">
<h2>IP 白名单/黑名单</h2>
<button class="btn btn-primary" onclick="showAddIPRule()">添加规则</button>
</div>
<div class="ip-rules-info" style="margin-bottom:16px;padding:12px;background:#fff;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.08)">
<p style="color:#666;font-size:13px;line-height:1.8">
<strong>规则说明:</strong><br>
- 支持<strong>单IP</strong>(如 192.168.1.1)、<strong>CIDR</strong>(如 192.168.1.0/24)、<strong>IP范围</strong>(如 192.168.1.1-192.168.1.100<br>
- <strong>全局规则</strong>:对所有用户生效,在连接时即检查<br>
- <strong>用户规则</strong>:仅对指定用户生效,在用户登录时检查<br>
- <strong>优先级</strong>:全局黑名单 > 全局白名单 > 用户黑名单 > 用户白名单
</p>
</div>
<div class="filter-bar" style="margin-bottom:12px">
<select id="ip-rule-filter" class="input-sm" onchange="loadIPRules()">
<option value="">全部规则</option>
<option value="global">仅全局规则</option>
<option value="user">仅用户规则</option>
</select>
</div>
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>作用范围</th>
<th>IP地址/网段</th>
<th>类型</th>
<th>备注</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="ip-rules-tbody"></tbody>
</table>
</div>
<!-- 系统设置 -->
<div id="page-settings" class="page">
<h2>系统设置</h2>
<div class="settings-section">
<h3>FTP设置</h3>
<div class="form-grid">
<div class="form-group">
<label>FTP端口</label>
<input type="number" id="cfg-ftp-port" class="input-sm">
</div>
<div class="form-group">
<label>被动端口范围(起始)</label>
<input type="number" id="cfg-ftp-passive-min" class="input-sm">
</div>
<div class="form-group">
<label>被动端口范围(结束)</label>
<input type="number" id="cfg-ftp-passive-max" class="input-sm">
</div>
<div class="form-group">
<label>最大连接数</label>
<input type="number" id="cfg-ftp-max-conn" class="input-sm">
</div>
<div class="form-group">
<label>空闲超时(秒)</label>
<input type="number" id="cfg-ftp-idle-timeout" class="input-sm">
</div>
<div class="form-group">
<label>启用匿名访问</label>
<select id="cfg-ftp-anonymous" class="input-sm">
<option value="true"></option>
<option value="false"></option>
</select>
</div>
</div>
</div>
<div class="settings-section">
<h3>管理员设置</h3>
<div class="form-grid">
<div class="form-group">
<label>管理员用户名</label>
<input type="text" id="cfg-admin-username" class="input-sm">
</div>
<div class="form-group">
<label>新密码(留空不修改)</label>
<input type="password" id="cfg-admin-password" class="input-sm" placeholder="留空不修改">
</div>
</div>
</div>
<button class="btn btn-primary" onclick="saveConfig()" style="margin-top:20px">保存设置</button>
</div>
</main>
</div>
<!-- 添加/编辑用户对话框 -->
<div id="user-modal" class="modal" style="display:none">
<div class="modal-content">
<div class="modal-header">
<h3 id="user-modal-title">添加用户</h3>
<span class="modal-close" onclick="closeUserModal()">&times;</span>
</div>
<form id="user-form">
<input type="hidden" id="user-edit-mode" value="add">
<div class="form-group">
<label>用户名</label>
<input type="text" id="user-username" required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="user-password" required>
</div>
<div class="form-group">
<label>主目录</label>
<input type="text" id="user-homedir" placeholder="留空自动设置为 /ftp_root/用户名">
<small>目录不存在时将自动创建</small>
</div>
<div class="form-group">
<label>权限</label>
<select id="user-permissions">
<option value="read">只读</option>
<option value="write">只写</option>
<option value="read,write" selected>读写</option>
</select>
</div>
<div class="form-grid">
<div class="form-group">
<label>空间配额(MB, 0=无限制)</label>
<input type="number" id="user-quota-size" value="0">
</div>
<div class="form-group">
<label>文件数配额(0=无限制)</label>
<input type="number" id="user-quota-files" value="0">
</div>
<div class="form-group">
<label>上传限速(KB/s, 0=无限制)</label>
<input type="number" id="user-upload-rate" value="0">
</div>
<div class="form-group">
<label>下载限速(KB/s, 0=无限制)</label>
<input type="number" id="user-download-rate" value="0">
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="user-enabled" checked> 启用账户
</label>
</div>
<div class="modal-footer">
<button type="button" class="btn" onclick="closeUserModal()">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<!-- 文件上传对话框 -->
<div id="upload-modal" class="modal" style="display:none">
<div class="modal-content">
<div class="modal-header">
<h3>上传文件</h3>
<span class="modal-close" onclick="closeUploadModal()">&times;</span>
</div>
<form id="upload-form">
<div class="form-group">
<input type="file" id="upload-file" required>
</div>
<div class="modal-footer">
<button type="button" class="btn" onclick="closeUploadModal()">取消</button>
<button type="submit" class="btn btn-primary">上传</button>
</div>
</form>
</div>
</div>
<!-- IP规则弹窗 -->
<div id="ip-rule-modal" class="modal" style="display:none">
<div class="modal-content">
<div class="modal-header">
<h3 id="ip-rule-modal-title">添加IP规则</h3>
<span class="modal-close" onclick="closeIPRuleModal()">&times;</span>
</div>
<form id="ip-rule-form">
<input type="hidden" id="ip-rule-edit-id" value="">
<div class="form-group">
<label>作用用户(留空为全局规则)</label>
<input type="text" id="ip-rule-username" placeholder="留空表示全局规则,填入用户名表示仅对该用户生效">
</div>
<div class="form-group">
<label>IP地址/网段</label>
<input type="text" id="ip-rule-ip" placeholder="如: 192.168.1.1 或 192.168.1.0/24 或 10.0.0.1-10.0.0.255" required>
</div>
<div class="form-group">
<label>规则类型</label>
<select id="ip-rule-type">
<option value="blacklist">黑名单(禁止连接)</option>
<option value="whitelist">白名单(允许连接)</option>
</select>
</div>
<div class="form-group">
<label>备注说明</label>
<input type="text" id="ip-rule-note" placeholder="可选,填写备注说明">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="ip-rule-enabled" checked> 启用此规则
</label>
</div>
<div class="modal-footer">
<button type="button" class="btn" onclick="closeIPRuleModal()">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<!-- 提示消息 -->
<div id="toast" class="toast"></div>
<script src="/js/app.js"></script>
</body>
</html>
-575
View File
@@ -1,575 +0,0 @@
// 全局变量
let token = localStorage.getItem('ftp_token') || '';
let currentPath = '';
let logPage = 1;
// --- API 封装 ---
async function api(method, url, data) {
const opts = {
method,
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
}
};
if (data && method !== 'GET') {
opts.body = JSON.stringify(data);
}
const resp = await fetch(url, opts);
const json = await resp.json();
if (resp.status === 401) {
token = '';
localStorage.removeItem('ftp_token');
showLogin();
throw new Error('登录已过期');
}
if (json.error) {
throw new Error(json.error);
}
return json.data;
}
// --- 工具函数 ---
function showToast(msg, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.className = 'toast ' + type;
setTimeout(() => { toast.className = 'toast'; }, 3000);
}
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
}
function formatTime(t) {
if (!t) return '-';
return t.replace('T', ' ').substring(0, 19);
}
function showLogin() {
document.getElementById('login-page').style.display = 'flex';
document.getElementById('main-app').style.display = 'none';
}
function showMain() {
document.getElementById('login-page').style.display = 'none';
document.getElementById('main-app').style.display = 'flex';
}
// --- 登录 ---
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
const data = await api('POST', '/api/login', { username, password });
token = data.token;
localStorage.setItem('ftp_token', token);
showMain();
loadDashboard();
} catch (err) {
showToast(err.message, 'error');
}
});
document.getElementById('logout-btn').addEventListener('click', () => {
token = '';
localStorage.removeItem('ftp_token');
showLogin();
});
// --- 导航切换 ---
document.querySelectorAll('.sidebar-menu li').forEach(li => {
li.addEventListener('click', () => {
document.querySelectorAll('.sidebar-menu li').forEach(el => el.classList.remove('active'));
li.classList.add('active');
const page = li.dataset.page;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
loadPage(page);
});
});
function loadPage(page) {
switch (page) {
case 'dashboard': loadDashboard(); break;
case 'users': loadUsers(); break;
case 'files': loadFiles(currentPath); break;
case 'logs': loadLogs(); break;
case 'online': loadOnline(); break;
case 'ip-rules': loadIPRules(); break;
case 'settings': loadConfig(); break;
}
}
// --- 仪表盘 ---
async function loadDashboard() {
try {
const stats = await api('GET', '/api/dashboard');
document.getElementById('stat-users').textContent = stats.total_users || 0;
document.getElementById('stat-enabled').textContent = stats.enabled_users || 0;
document.getElementById('stat-online').textContent = stats.online_users || 0;
document.getElementById('stat-today-logins').textContent = stats.today_logins || 0;
document.getElementById('stat-today-uploads').textContent = stats.today_uploads || 0;
document.getElementById('stat-today-downloads').textContent = stats.today_downloads || 0;
document.getElementById('stat-upload-bytes').textContent = formatBytes(stats.total_upload_bytes);
document.getElementById('stat-download-bytes').textContent = formatBytes(stats.total_download_bytes);
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 用户管理 ---
async function loadUsers() {
try {
const users = await api('GET', '/api/users');
const tbody = document.getElementById('users-tbody');
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${u.username}</td>
<td title="${u.home_dir}">${u.home_dir}</td>
<td>${u.permissions}</td>
<td>${u.quota_size > 0 ? formatBytes(u.quota_size) : '无限制'}</td>
<td><span class="${u.enabled ? 'status-enabled' : 'status-disabled'}">${u.enabled ? '启用' : '禁用'}</span></td>
<td>${formatTime(u.created_at)}</td>
<td class="action-btns">
<button class="btn btn-sm" onclick="editUser('${u.username}')">编辑</button>
<button class="btn btn-sm" onclick="resetPassword('${u.username}')">改密</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('${u.username}')">删除</button>
</td>
</tr>
`).join('');
} catch (err) {
showToast(err.message, 'error');
}
}
function showAddUser() {
document.getElementById('user-modal-title').textContent = '添加用户';
document.getElementById('user-edit-mode').value = 'add';
document.getElementById('user-form').reset();
document.getElementById('user-username').disabled = false;
document.getElementById('user-password').required = true;
document.getElementById('user-enabled').checked = true;
document.getElementById('user-modal').style.display = 'flex';
}
async function editUser(username) {
try {
const user = await api('GET', '/api/users/' + username);
document.getElementById('user-modal-title').textContent = '编辑用户';
document.getElementById('user-edit-mode').value = 'edit';
document.getElementById('user-username').value = user.username;
document.getElementById('user-username').disabled = true;
document.getElementById('user-password').value = '';
document.getElementById('user-password').required = false;
document.getElementById('user-homedir').value = user.home_dir;
document.getElementById('user-permissions').value = user.permissions;
document.getElementById('user-quota-size').value = Math.round(user.quota_size / 1024 / 1024);
document.getElementById('user-quota-files').value = user.quota_files;
document.getElementById('user-upload-rate').value = user.upload_rate;
document.getElementById('user-download-rate').value = user.download_rate;
document.getElementById('user-enabled').checked = user.enabled;
document.getElementById('user-modal').style.display = 'flex';
} catch (err) {
showToast(err.message, 'error');
}
}
document.getElementById('user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const mode = document.getElementById('user-edit-mode').value;
const username = document.getElementById('user-username').value;
const password = document.getElementById('user-password').value;
const homeDir = document.getElementById('user-homedir').value;
const quotaMB = parseInt(document.getElementById('user-quota-size').value) || 0;
const data = {
username,
password,
home_dir: homeDir,
permissions: document.getElementById('user-permissions').value,
quota_size: quotaMB * 1024 * 1024,
quota_files: parseInt(document.getElementById('user-quota-files').value) || 0,
upload_rate: parseInt(document.getElementById('user-upload-rate').value) || 0,
download_rate: parseInt(document.getElementById('user-download-rate').value) || 0,
enabled: document.getElementById('user-enabled').checked
};
try {
if (mode === 'add') {
await api('POST', '/api/users', data);
showToast('用户添加成功');
} else {
await api('PUT', '/api/users/' + username, data);
if (password) {
await api('PUT', '/api/users/' + username + '/password', { password });
}
showToast('用户更新成功');
}
closeUserModal();
loadUsers();
} catch (err) {
showToast(err.message, 'error');
}
});
function closeUserModal() {
document.getElementById('user-modal').style.display = 'none';
}
async function deleteUser(username) {
if (!confirm('确定删除用户 "' + username + '" 吗?')) return;
try {
await api('DELETE', '/api/users/' + username);
showToast('用户已删除');
loadUsers();
} catch (err) {
showToast(err.message, 'error');
}
}
async function resetPassword(username) {
const password = prompt('请输入新密码:');
if (!password) return;
try {
await api('PUT', '/api/users/' + username + '/password', { password });
showToast('密码已更新');
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 文件管理 ---
async function loadFiles(path) {
try {
const url = '/api/files?path=' + encodeURIComponent(path || '');
const data = await api('GET', url);
currentPath = data.path;
const tbody = document.getElementById('files-tbody');
let html = '';
// 返回上级
if (currentPath) {
html += `<tr>
<td colspan="4"><span class="dir-link" onclick="loadFiles('')">[根目录]</span></td>
</tr>`;
}
data.files.forEach(f => {
if (f.is_dir) {
html += `<tr>
<td><span class="file-icon">&#128193;</span><span class="dir-link" onclick="loadFiles('${f.path.replace(/\\/g, '\\\\')}')">${f.name}</span></td>
<td>-</td>
<td>${formatTime(f.mod_time)}</td>
<td class="action-btns">
<button class="btn btn-sm btn-danger" onclick="deleteFile('${f.path.replace(/\\/g, '\\\\')}', true)">删除</button>
</td>
</tr>`;
} else {
html += `<tr>
<td><span class="file-icon">&#128196;</span>${f.name}</td>
<td>${formatBytes(f.size)}</td>
<td>${formatTime(f.mod_time)}</td>
<td class="action-btns">
<button class="btn btn-sm btn-danger" onclick="deleteFile('${f.path.replace(/\\/g, '\\\\')}', false)">删除</button>
</td>
</tr>`;
}
});
if (!data.files.length && !currentPath) {
html = '<tr><td colspan="4" style="text-align:center;color:#999;padding:40px">目录为空</td></tr>';
}
tbody.innerHTML = html;
// 面包屑
document.getElementById('file-breadcrumb').innerHTML = '<span onclick="loadFiles(\'\')">/</span> ' +
currentPath.replace(/\\/g, '/').split('/').filter(Boolean).map((p, i, arr) => {
const subPath = arr.slice(0, i + 1).join('/');
return '<span onclick="loadFiles(\'' + subPath + '\')">' + p + '</span>';
}).join(' / ');
} catch (err) {
showToast(err.message, 'error');
}
}
async function deleteFile(path, isDir) {
const name = path.split(/[\\/]/).pop();
if (!confirm('确定删除 "' + name + '" 吗?' + (isDir ? '将删除文件夹内所有内容!' : ''))) return;
try {
await api('DELETE', '/api/files?path=' + encodeURIComponent(path));
showToast('删除成功');
loadFiles(currentPath);
} catch (err) {
showToast(err.message, 'error');
}
}
function uploadFile() {
document.getElementById('upload-form').reset();
document.getElementById('upload-modal').style.display = 'flex';
}
function closeUploadModal() {
document.getElementById('upload-modal').style.display = 'none';
}
document.getElementById('upload-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('upload-file');
if (!fileInput.files.length) return;
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const resp = await fetch('/api/upload?path=' + encodeURIComponent(currentPath), {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const json = await resp.json();
if (json.error) throw new Error(json.error);
showToast('上传成功');
closeUploadModal();
loadFiles(currentPath);
} catch (err) {
showToast(err.message, 'error');
}
});
async function createFolder() {
const name = prompt('请输入文件夹名称:');
if (!name) return;
try {
await api('POST', '/api/files', { path: currentPath, name, type: 'dir' });
showToast('文件夹已创建');
loadFiles(currentPath);
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 日志 ---
async function loadLogs() {
const username = document.getElementById('log-username').value;
const action = document.getElementById('log-action').value;
try {
const data = await api('GET', `/api/logs?username=${encodeURIComponent(username)}&action=${action}&page=${logPage}&page_size=20`);
const tbody = document.getElementById('logs-tbody');
if (!data.logs || !data.logs.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#999;padding:40px">暂无日志</td></tr>';
} else {
tbody.innerHTML = data.logs.map(l => {
let statusClass = l.status === 'success' ? 'status-enabled' : 'status-disabled';
return `<tr>
<td>${formatTime(l.created_at)}</td>
<td>${l.username || '-'}</td>
<td>${l.ip || '-'}</td>
<td>${l.action}</td>
<td title="${l.file_path}">${l.file_path || '-'}</td>
<td>${l.file_size > 0 ? formatBytes(l.file_size) : '-'}</td>
<td><span class="${statusClass}">${l.status}</span></td>
</tr>`;
}).join('');
}
// 分页
const totalPages = Math.ceil(data.total / 20);
let pagHtml = `<button ${logPage <= 1 ? 'disabled' : ''} onclick="logPage=${logPage - 1};loadLogs()">上一页</button>`;
pagHtml += `<span style="padding:6px 12px">第 ${logPage} / ${totalPages || 1} 页 (共 ${data.total} 条)</span>`;
pagHtml += `<button ${logPage >= totalPages ? 'disabled' : ''} onclick="logPage=${logPage + 1};loadLogs()">下一页</button>`;
document.getElementById('logs-pagination').innerHTML = pagHtml;
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 在线用户 ---
async function loadOnline() {
try {
const users = await api('GET', '/api/online');
const tbody = document.getElementById('online-tbody');
if (!users || !users.length) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#999;padding:40px">暂无在线用户</td></tr>';
} else {
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.username || '-'}</td>
<td>${u.ip}</td>
<td>${formatTime(u.login_time)}</td>
<td>${formatTime(u.last_activity)}</td>
<td>${u.current_dir || '-'}</td>
</tr>
`).join('');
}
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 系统设置 ---
async function loadConfig() {
try {
const cfg = await api('GET', '/api/config');
document.getElementById('cfg-ftp-port').value = cfg.ftp.port;
document.getElementById('cfg-ftp-passive-min').value = cfg.ftp.passive_port_min;
document.getElementById('cfg-ftp-passive-max').value = cfg.ftp.passive_port_max;
document.getElementById('cfg-ftp-max-conn').value = cfg.ftp.max_connections;
document.getElementById('cfg-ftp-idle-timeout').value = cfg.ftp.idle_timeout;
document.getElementById('cfg-ftp-anonymous').value = String(cfg.ftp.enable_anonymous);
} catch (err) {
showToast(err.message, 'error');
}
}
async function saveConfig() {
const data = {
ftp: {
port: parseInt(document.getElementById('cfg-ftp-port').value),
passive_port_min: parseInt(document.getElementById('cfg-ftp-passive-min').value),
passive_port_max: parseInt(document.getElementById('cfg-ftp-passive-max').value),
max_connections: parseInt(document.getElementById('cfg-ftp-max-conn').value),
idle_timeout: parseInt(document.getElementById('cfg-ftp-idle-timeout').value),
enable_anonymous: document.getElementById('cfg-ftp-anonymous').value === 'true'
},
admin: {
username: document.getElementById('cfg-admin-username').value,
password: document.getElementById('cfg-admin-password').value
}
};
try {
await api('PUT', '/api/config', data);
showToast('配置已保存,部分设置需要重启生效');
} catch (err) {
showToast(err.message, 'error');
}
}
// --- 初始化 ---
if (token) {
showMain();
loadDashboard();
} else {
showLogin();
}
// --- IP规则管理 ---
async function loadIPRules() {
try {
const filter = document.getElementById('ip-rule-filter').value;
let url = '/api/ip-rules';
if (filter === 'global') url += '?username=__empty__';
else if (filter === 'user') url += '?username=__has__';
const rules = await api('GET', url);
const tbody = document.getElementById('ip-rules-tbody');
if (!rules || !rules.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#999;padding:40px">暂无IP规则,所有IP默认允许连接</td></tr>';
return;
}
tbody.innerHTML = rules.map(r => {
const scopeLabel = r.username
? `<span style="color:#e67e22;font-weight:600">用户: ${r.username}</span>`
: '<span style="color:#667eea;font-weight:600">全局</span>';
const typeLabel = r.type === 'whitelist'
? '<span style="color:#667eea;font-weight:600">白名单</span>'
: '<span style="color:#ff4d4f;font-weight:600">黑名单</span>';
const statusLabel = r.enabled
? '<span class="status-enabled">启用</span>'
: '<span class="status-disabled">禁用</span>';
return `<tr>
<td>${r.id}</td>
<td>${scopeLabel}</td>
<td><code style="background:#f5f5f5;padding:2px 6px;border-radius:3px">${r.ip}</code></td>
<td>${typeLabel}</td>
<td>${r.note || '-'}</td>
<td>${statusLabel}</td>
<td>${formatTime(r.created_at)}</td>
<td class="action-btns">
<button class="btn btn-sm" onclick="editIPRule(${r.id}, '${r.username||''}', '${r.ip}', '${r.type}', '${(r.note||'').replace(/'/g, "\\'")}', ${r.enabled})">编辑</button>
<button class="btn btn-sm" onclick="toggleIPRule(${r.id}, '${r.username||''}', '${r.ip}', '${r.type}', '${(r.note||'').replace(/'/g, "\\'")}', ${r.enabled})">${r.enabled ? '禁用' : '启用'}</button>
<button class="btn btn-sm btn-danger" onclick="deleteIPRule(${r.id})">删除</button>
</td>
</tr>`;
}).join('');
} catch (err) {
showToast(err.message, 'error');
}
}
function showAddIPRule() {
document.getElementById('ip-rule-modal-title').textContent = '添加IP规则';
document.getElementById('ip-rule-edit-id').value = '';
document.getElementById('ip-rule-form').reset();
document.getElementById('ip-rule-enabled').checked = true;
document.getElementById('ip-rule-modal').style.display = 'flex';
}
function editIPRule(id, username, ip, type, note, enabled) {
document.getElementById('ip-rule-modal-title').textContent = '编辑IP规则';
document.getElementById('ip-rule-edit-id').value = id;
document.getElementById('ip-rule-username').value = username;
document.getElementById('ip-rule-ip').value = ip;
document.getElementById('ip-rule-type').value = type;
document.getElementById('ip-rule-note').value = note;
document.getElementById('ip-rule-enabled').checked = enabled;
document.getElementById('ip-rule-modal').style.display = 'flex';
}
function closeIPRuleModal() {
document.getElementById('ip-rule-modal').style.display = 'none';
}
document.getElementById('ip-rule-form').addEventListener('submit', async (e) => {
e.preventDefault();
const editId = document.getElementById('ip-rule-edit-id').value;
const data = {
username: document.getElementById('ip-rule-username').value,
ip: document.getElementById('ip-rule-ip').value,
type: document.getElementById('ip-rule-type').value,
note: document.getElementById('ip-rule-note').value,
enabled: document.getElementById('ip-rule-enabled').checked
};
try {
if (editId) {
await api('PUT', '/api/ip-rules/' + editId, data);
showToast('规则已更新');
} else {
await api('POST', '/api/ip-rules', data);
showToast('规则添加成功');
}
closeIPRuleModal();
loadIPRules();
} catch (err) {
showToast(err.message, 'error');
}
});
async function toggleIPRule(id, username, ip, type, note, enabled) {
try {
await api('PUT', '/api/ip-rules/' + id, {
username, ip, type, note, enabled: !enabled
});
showToast(!enabled ? '规则已启用' : '规则已禁用');
loadIPRules();
} catch (err) {
showToast(err.message, 'error');
}
}
async function deleteIPRule(id) {
if (!confirm('确定删除此IP规则吗?')) return;
try {
await api('DELETE', '/api/ip-rules/' + id);
showToast('规则已删除');
loadIPRules();
} catch (err) {
showToast(err.message, 'error');
}
}
+268 -569
View File
@@ -5,109 +5,78 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"ftp-server/config"
"ftp-server/database"
"ftp-server/ftp"
"github.com/golang-jwt/jwt/v5"
"ftp-server/static"
)
// Server Web管理服务器
// Server represents the web admin server
type Server struct {
config *config.Config
db *database.DB
ftpServer *ftp.Server
jwtSecret []byte
config *config.Config
configPath string
sessions map[string]time.Time
mu sync.RWMutex
}
// NewServer 创建Web服务器
func NewServer(cfg *config.Config, db *database.DB, ftpSrv *ftp.Server) *Server {
secret := make([]byte, 32)
rand.Read(secret)
// NewServer creates a new web admin server
func NewServer(cfg *config.Config, configPath string) *Server {
return &Server{
config: cfg,
db: db,
ftpServer: ftpSrv,
jwtSecret: secret,
config: cfg,
configPath: configPath,
sessions: make(map[string]time.Time),
}
}
// Start 启动Web服务
// Start starts the web admin server
func (s *Server) Start() error {
webCfg := s.config.Get().Web
mux := http.NewServeMux()
// 静态文件
fs := http.FileServer(http.Dir("./static"))
mux.Handle("/", fs)
// Static files served directly from code
// No file server needed - HTML is embedded in static package
// API路由
// Serve the main page
mux.HandleFunc("/", s.handleIndex)
// API routes
mux.HandleFunc("/api/login", s.handleLogin)
mux.HandleFunc("/api/dashboard", s.authMiddleware(s.handleDashboard))
mux.HandleFunc("/api/users", s.authMiddleware(s.handleUsers))
mux.HandleFunc("/api/users/", s.authMiddleware(s.handleUserOperation))
mux.HandleFunc("/api/files", s.authMiddleware(s.handleFileBrowse))
mux.HandleFunc("/api/files/", s.authMiddleware(s.handleFileBrowse))
mux.HandleFunc("/api/logs", s.authMiddleware(s.handleLogs))
mux.HandleFunc("/api/online", s.authMiddleware(s.handleOnline))
mux.HandleFunc("/api/config", s.authMiddleware(s.handleConfig))
mux.HandleFunc("/api/upload", s.authMiddleware(s.handleUpload))
mux.HandleFunc("/api/ip-rules", s.authMiddleware(s.handleIPRules))
mux.HandleFunc("/api/ip-rules/", s.authMiddleware(s.handleIPRuleOperation))
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))
addr := fmt.Sprintf("%s:%d", webCfg.Host, webCfg.Port)
log.Printf("Web管理界面已启动: http://localhost:%d", webCfg.Port)
// Start session cleanup
go s.cleanupSessions()
server := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
addr := fmt.Sprintf("%s:%d", s.config.Web.Host, s.config.Web.Port)
log.Printf("Web admin server listening on http://%s", addr)
return server.ListenAndServe()
return http.ListenAndServe(addr, mux)
}
// --- 中间件 ---
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
// authRequired is middleware that requires authentication
func (s *Server) authRequired(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tokenStr := r.Header.Get("Authorization")
if tokenStr == "" {
// 也支持 cookie
if cookie, err := r.Cookie("token"); err == nil {
tokenStr = cookie.Value
token := r.Header.Get("Authorization")
if token == "" {
cookie, err := r.Cookie("session")
if err == nil {
token = cookie.Value
}
}
if strings.HasPrefix(tokenStr, "Bearer ") {
tokenStr = tokenStr[7:]
}
token = strings.TrimPrefix(token, "Bearer ")
if tokenStr == "" {
s.jsonError(w, "未登录", http.StatusUnauthorized)
return
}
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("无效的签名方法")
}
return s.jwtSecret, nil
})
if err != nil || !token.Valid {
s.jsonError(w, "登录已过期", http.StatusUnauthorized)
if !s.isValidSession(token) {
http.Error(w, `{"error":"Unauthorized"}`, http.StatusUnauthorized)
return
}
@@ -115,554 +84,284 @@ func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
}
// --- 请求处理 ---
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))
}
// handleLogin 登录
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
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"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.jsonError(w, "请求格式错误", http.StatusBadRequest)
http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
return
}
adminCfg := s.config.Get().Admin
if req.Username != adminCfg.Username || req.Password != adminCfg.Password {
s.jsonError(w, "用户名或密码错误", http.StatusUnauthorized)
if !s.config.DeleteFTPUser(req.Username) {
http.Error(w, `{"error":"User not found"}`, http.StatusNotFound)
return
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"username": req.Username,
"exp": time.Now().Add(24 * time.Hour).Unix(),
})
tokenStr, err := token.SignedString(s.jwtSecret)
if err != nil {
s.jsonError(w, "生成令牌失败", http.StatusInternalServerError)
if err := s.config.Save(s.configPath); err != nil {
http.Error(w, `{"error":"Failed to save config"}`, http.StatusInternalServerError)
return
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"token": tokenStr,
})
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"})
}
// handleDashboard 仪表盘
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
stats, err := s.db.GetLogStats()
if err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
// 添加在线用户数
if s.ftpServer != nil {
online := s.ftpServer.GetOnlineUsers()
stats["online_users"] = len(online)
} else {
stats["online_users"] = 0
}
s.jsonResponse(w, http.StatusOK, stats)
}
// handleUsers 用户管理
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
users, err := s.db.ListUsers()
if err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if users == nil {
users = []database.FTPUser{}
}
s.jsonResponse(w, http.StatusOK, users)
case http.MethodPost:
var user database.FTPUser
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
s.jsonError(w, "请求格式错误", http.StatusBadRequest)
return
}
if user.Username == "" || user.Password == "" {
s.jsonError(w, "用户名和密码不能为空", http.StatusBadRequest)
return
}
if user.HomeDir == "" {
ftpCfg := s.config.Get().FTP
user.HomeDir = filepath.Join(ftpCfg.RootDir, user.Username)
}
// 自动创建用户目录(如果不存在)
if err := os.MkdirAll(user.HomeDir, 0755); err != nil {
s.jsonError(w, fmt.Sprintf("创建用户目录失败: %v", err), http.StatusInternalServerError)
return
}
if user.Permissions == "" {
user.Permissions = "read,write"
}
user.Enabled = true
if err := s.db.CreateUser(&user); err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.jsonResponse(w, http.StatusOK, user)
default:
s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
}
}
// handleUserOperation 单个用户操作
func (s *Server) handleUserOperation(w http.ResponseWriter, r *http.Request) {
// 解析路径中的用户名: /api/users/{username} 或 /api/users/{username}/password
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/users/"), "/")
username := pathParts[0]
if username == "" {
s.jsonError(w, "用户名不能为空", http.StatusBadRequest)
return
}
// 密码修改: PUT /api/users/{username}/password
if len(pathParts) > 1 && pathParts[1] == "password" && r.Method == http.MethodPut {
var req struct {
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.jsonError(w, "请求格式错误", http.StatusBadRequest)
return
}
if req.Password == "" {
s.jsonError(w, "密码不能为空", http.StatusBadRequest)
return
}
if err := s.db.UpdateUserPassword(username, req.Password); err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{"message": "密码已更新"})
return
}
switch r.Method {
case http.MethodGet:
user, err := s.db.GetUser(username)
if err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if user == nil {
s.jsonError(w, "用户不存在", http.StatusNotFound)
return
}
s.jsonResponse(w, http.StatusOK, user)
case http.MethodPut:
var user database.FTPUser
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
s.jsonError(w, "请求格式错误", http.StatusBadRequest)
return
}
// 如果目录有变化,自动创建新目录
if user.HomeDir != "" {
if err := os.MkdirAll(user.HomeDir, 0755); err != nil {
s.jsonError(w, fmt.Sprintf("创建目录失败: %v", err), http.StatusInternalServerError)
return
}
}
if err := s.db.UpdateUser(&user); err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{"message": "用户已更新"})
case http.MethodDelete:
if err := s.db.DeleteUser(username); err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{"message": "用户已删除"})
default:
s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
}
}
// FileInfo 文件信息
type FileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"is_dir"`
ModTime time.Time `json:"mod_time"`
Path string `json:"path"`
}
// handleFileBrowse 文件浏览
func (s *Server) handleFileBrowse(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
return
}
dir := r.URL.Query().Get("path")
if dir == "" {
dir = s.config.Get().FTP.RootDir
}
// 安全检查:确保路径在FTP根目录内
rootDir, err := filepath.Abs(s.config.Get().FTP.RootDir)
if err != nil {
s.jsonError(w, "无效的根目录", http.StatusInternalServerError)
return
}
absPath, err := filepath.Abs(dir)
if err != nil {
s.jsonError(w, "无效的路径", http.StatusBadRequest)
return
}
if !strings.HasPrefix(absPath, rootDir) {
s.jsonError(w, "路径超出允许范围", http.StatusForbidden)
return
}
entries, err := os.ReadDir(absPath)
if err != nil {
if os.IsNotExist(err) {
s.jsonResponse(w, http.StatusOK, []FileInfo{})
return
}
s.jsonError(w, fmt.Sprintf("读取目录失败: %v", err), http.StatusInternalServerError)
return
}
var files []FileInfo
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, FileInfo{
Name: entry.Name(),
Size: info.Size(),
IsDir: entry.IsDir(),
ModTime: info.ModTime(),
Path: filepath.Join(absPath, entry.Name()),
})
}
if files == nil {
files = []FileInfo{}
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"path": absPath,
"files": files,
})
}
// handleUpload 文件上传
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
http.Error(w, `{"error":"Method not allowed"}`, http.StatusMethodNotAllowed)
return
}
targetDir := r.URL.Query().Get("path")
if targetDir == "" {
targetDir = s.config.Get().FTP.RootDir
var req struct {
Username string `json:"username"`
User config.FTPUser `json:"user"`
KeepPassword bool `json:"keepPassword"`
}
// 安全检查
rootDir, _ := filepath.Abs(s.config.Get().FTP.RootDir)
absDir, err := filepath.Abs(targetDir)
if err != nil || !strings.HasPrefix(absDir, rootDir) {
s.jsonError(w, "路径超出允许范围", http.StatusForbidden)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"Invalid request"}`, http.StatusBadRequest)
return
}
r.ParseMultipartForm(100 << 20) // 100MB最大
file, header, err := r.FormFile("file")
if err != nil {
s.jsonError(w, "读取文件失败", http.StatusBadRequest)
return
if req.KeepPassword && req.User.Password == "" {
existing := s.config.GetFTPUser(req.Username)
if existing != nil {
req.User.Password = existing.Password
}
}
defer file.Close()
targetPath := filepath.Join(absDir, header.Filename)
dst, err := os.Create(targetPath)
if err != nil {
s.jsonError(w, "创建文件失败", http.StatusInternalServerError)
return
}
defer dst.Close()
written, err := io.Copy(dst, file)
if err != nil {
s.jsonError(w, "写入文件失败", http.StatusInternalServerError)
if !s.config.UpdateFTPUser(req.Username, req.User) {
http.Error(w, `{"error":"User not found"}`, http.StatusNotFound)
return
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"message": "上传成功",
"size": written,
"path": targetPath,
})
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"})
}
// handleLogs 日志查询
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
return
}
username := r.URL.Query().Get("username")
action := r.URL.Query().Get("action")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
logs, total, err := s.db.QueryLogs(username, action, page, pageSize)
if err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if logs == nil {
logs = []database.FTPLog{}
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"logs": logs,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// handleOnline 在线用户
func (s *Server) handleOnline(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
return
}
var users []database.OnlineUser
if s.ftpServer != nil {
users = s.ftpServer.GetOnlineUsers()
}
if users == nil {
users = []database.OnlineUser{}
}
s.jsonResponse(w, http.StatusOK, users)
}
// handleConfig 配置管理
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg := s.config.Get()
// 隐藏敏感信息
safeCfg := map[string]interface{}{
"ftp": map[string]interface{}{
"host": cfg.FTP.Host,
"port": cfg.FTP.Port,
"passive_port_min": cfg.FTP.PassivePortMin,
"passive_port_max": cfg.FTP.PassivePortMax,
"root_dir": cfg.FTP.RootDir,
"enable_anonymous": cfg.FTP.EnableAnonymous,
"max_connections": cfg.FTP.MaxConnections,
"idle_timeout": cfg.FTP.IdleTimeout,
},
"web": map[string]interface{}{
"host": cfg.Web.Host,
"port": cfg.Web.Port,
},
}
s.jsonResponse(w, http.StatusOK, safeCfg)
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,
})
}
case http.MethodPut:
var update map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
s.jsonError(w, "请求格式错误", http.StatusBadRequest)
return
}
s.config.Update(func(cfg *config.Config) {
if ftpCfg, ok := update["ftp"].(map[string]interface{}); ok {
if v, ok := ftpCfg["port"].(float64); ok {
cfg.FTP.Port = int(v)
}
if v, ok := ftpCfg["passive_port_min"].(float64); ok {
cfg.FTP.PassivePortMin = int(v)
}
if v, ok := ftpCfg["passive_port_max"].(float64); ok {
cfg.FTP.PassivePortMax = int(v)
}
if v, ok := ftpCfg["enable_anonymous"].(bool); ok {
cfg.FTP.EnableAnonymous = v
}
if v, ok := ftpCfg["max_connections"].(float64); ok {
cfg.FTP.MaxConnections = int(v)
}
if v, ok := ftpCfg["idle_timeout"].(float64); ok {
cfg.FTP.IdleTimeout = int(v)
}
}
if adminCfg, ok := update["admin"].(map[string]interface{}); ok {
if v, ok := adminCfg["username"].(string); ok && v != "" {
cfg.Admin.Username = v
}
if v, ok := adminCfg["password"].(string); ok && v != "" {
cfg.Admin.Password = v
}
}
})
if err := s.config.Save("config.json"); err != nil {
s.jsonError(w, fmt.Sprintf("保存配置失败: %v", err), http.StatusInternalServerError)
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{"message": "配置已保存,部分设置需要重启生效"})
default:
s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
func (s *Server) 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
}
func (s *Server) jsonResponse(w http.ResponseWriter, status int, data interface{}) {
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")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]interface{}{
"code": status,
"data": data,
})
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) jsonError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]interface{}{
"code": status,
"error": msg,
})
}
// GenerateToken 生成随机令牌(用于JWT密钥)
func GenerateToken() string {
b := make([]byte, 16)
// Session management
func (s *Server) createSession() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
token := hex.EncodeToString(b)
s.mu.Lock()
s.sessions[token] = time.Now().Add(24 * time.Hour)
s.mu.Unlock()
return token
}
// handleIPRules IP规则列表和创建
func (s *Server) handleIPRules(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
ruleType := r.URL.Query().Get("type")
username := r.URL.Query().Get("username")
rules, err := s.db.ListIPRules(ruleType, username)
if err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if rules == nil {
rules = []database.IPAccessRule{}
}
s.jsonResponse(w, http.StatusOK, rules)
case http.MethodPost:
var rule database.IPAccessRule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
s.jsonError(w, "请求格式错误", http.StatusBadRequest)
return
}
if rule.IP == "" {
s.jsonError(w, "IP不能为空", http.StatusBadRequest)
return
}
if rule.Type != "whitelist" && rule.Type != "blacklist" {
rule.Type = "blacklist"
}
rule.Enabled = true
if err := s.db.CreateIPRule(&rule); err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.jsonResponse(w, http.StatusOK, rule)
default:
s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
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)
}
// handleIPRuleOperation 单条IP规则操作
func (s *Server) handleIPRuleOperation(w http.ResponseWriter, r *http.Request) {
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/ip-rules/"), "/")
idStr := pathParts[0]
if idStr == "" {
s.jsonError(w, "ID不能为空", http.StatusBadRequest)
return
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
s.jsonError(w, "无效的ID", http.StatusBadRequest)
return
}
func (s *Server) deleteSession(token string) {
s.mu.Lock()
delete(s.sessions, token)
s.mu.Unlock()
}
switch r.Method {
case http.MethodPut:
var rule database.IPAccessRule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
s.jsonError(w, "请求格式错误", http.StatusBadRequest)
return
}
rule.ID = id
if err := s.db.UpdateIPRule(&rule); err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{"message": "规则已更新"})
func (s *Server) cleanupSessions() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
case http.MethodDelete:
if err := s.db.DeleteIPRule(id); err != nil {
s.jsonError(w, err.Error(), http.StatusInternalServerError)
return
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.jsonResponse(w, http.StatusOK, map[string]string{"message": "规则已删除"})
default:
s.jsonError(w, "方法不允许", http.StatusMethodNotAllowed)
s.mu.Unlock()
}
}
+20
View File
@@ -0,0 +1,20 @@
@echo off
chcp 65001 >nul 2>&1
title FTP Server - 停止
cd /d "%~dp0"
echo.
echo [停止] 正在停止 FTP Server ...
tasklist /FI "IMAGENAME eq ftp-server.exe" 2>nul | find /i "ftp-server.exe" >nul
if %errorlevel%==0 (
taskkill /F /IM "ftp-server.exe" >nul 2>&1
timeout /t 1 /nobreak >nul
echo [成功] FTP Server 已停止
) else (
echo [提示] FTP Server 未在运行
)
echo.
pause
+57
View File
@@ -0,0 +1,57 @@
@echo off
chcp 65001 >nul 2>&1
title FTP Server - 启动
:: 切换到脚本所在目录
cd /d "%~dp0"
if not exist "ftp-server.exe" (
echo.
echo [错误] 未找到 ftp-server.exe
echo 请先编译: go build -o ftp-server.exe ./cmd/
echo.
pause
exit /b 1
)
:: 检查是否已在运行
tasklist /FI "IMAGENAME eq ftp-server.exe" 2>nul | find /i "ftp-server.exe" >nul
if %errorlevel%==0 (
echo.
echo [提示] FTP Server 已经在运行中
echo 如需重启,请先运行 "停止FTP服务.bat"
echo.
pause
exit /b 0
)
echo.
echo [启动] 正在启动 FTP Server ...
echo.
:: 后台启动
start "" /MIN ftp-server.exe -config config.json
:: 等待启动
timeout /t 3 /nobreak >nul
:: 检查是否启动成功
tasklist /FI "IMAGENAME eq ftp-server.exe" 2>nul | find /i "ftp-server.exe" >nul
if %errorlevel%==0 (
echo [成功] FTP Server 已启动
echo.
echo Web 管理面板: http://localhost:8080
echo FTP 端口: 2121
echo 账号: admin / admin123
) else (
echo [失败] FTP Server 启动失败
echo.
echo 可能原因:
echo 1. 端口 2121 或 8080 已被其他程序占用
echo 2. config.json 配置文件有误
echo.
echo 请尝试在命令行运行 ftp-server.exe 查看详细错误信息
)
echo.
pause
+27
View File
@@ -0,0 +1,27 @@
@echo off
chcp 65001 >nul 2>&1
title FTP Server - 查看状态
cd /d "%~dp0"
echo.
echo ================================
echo FTP Server 运行状态
echo ================================
echo.
tasklist /FI "IMAGENAME eq ftp-server.exe" 2>nul | find /i "ftp-server.exe" >nul
if %errorlevel%==0 (
echo 状态: 运行中
for /f "tokens=2" %%a in ('tasklist /FI "IMAGENAME eq ftp-server.exe" /NH 2^>nul') do (
echo PID: %%a
)
) else (
echo 状态: 未运行
)
echo.
echo Web 管理面板: http://localhost:8080
echo FTP 端口: 2121
echo.
pause
+45
View File
@@ -0,0 +1,45 @@
@echo off
chcp 65001 >nul 2>&1
title FTP Server - 重启
cd /d "%~dp0"
echo.
echo [重启] 正在重启 FTP Server ...
:: 先停止
tasklist /FI "IMAGENAME eq ftp-server.exe" 2>nul | find /i "ftp-server.exe" >nul
if %errorlevel%==0 (
taskkill /F /IM "ftp-server.exe" >nul 2>&1
timeout /t 2 /nobreak >nul
echo [停止] FTP Server 已停止
) else (
echo [提示] FTP Server 未在运行
)
:: 再启动
if not exist "ftp-server.exe" (
echo [错误] 未找到 ftp-server.exe
echo.
pause
exit /b 1
)
echo [启动] 正在启动 FTP Server ...
start "" /MIN ftp-server.exe -config config.json
timeout /t 3 /nobreak >nul
tasklist /FI "IMAGENAME eq ftp-server.exe" 2>nul | find /i "ftp-server.exe" >nul
if %errorlevel%==0 (
echo [成功] FTP Server 已重启
echo.
echo Web 管理面板: http://localhost:8080
echo FTP 端口: 2121
echo 账号: admin / admin123
) else (
echo [失败] FTP Server 启动失败
)
echo.
pause