Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45ec38a023 | |||
| c7319a4686 | |||
| 8cfa25f0f3 | |||
| 2459122c0e | |||
| e94c730def | |||
| d82d2e6d53 | |||
| 799d814503 |
+10
-14
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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">📁</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 ? '📁' : 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': '📄', 'docx': '📄', 'pdf': '📄', 'txt': '📄',
|
||||
'xls': '📊', 'xlsx': '📊', 'csv': '📊',
|
||||
'jpg': '🌄', 'jpeg': '🌄', 'png': '🌄', 'gif': '🌄', 'bmp': '🌄',
|
||||
'mp3': '🎵', 'wav': '🎵', 'flac': '🎵',
|
||||
'mp4': '🎬', 'avi': '🎬', 'mkv': '🎬', 'mov': '🎬',
|
||||
'zip': '📦', 'rar': '📦', '7z': '📦', 'tar': '📦', 'gz': '📦',
|
||||
'js': '💻', 'ts': '💻', 'py': '💻', 'go': '💻', 'java': '💻', 'c': '💻', 'cpp': '💻', 'html': '💻', 'css': '💻', 'json': '💻', 'xml': '💻', 'yml': '💻', 'yaml': '💻'
|
||||
};
|
||||
return icons[ext] || '📄';
|
||||
}
|
||||
|
||||
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>
|
||||
`
|
||||
@@ -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("服务器已停止")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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">📁</span>FTP Server 管理面板</h1>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="loginUser" placeholder="请输入管理员用户名" value="admin">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="loginPass" placeholder="请输入密码" value="">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="doLogin()">登 录</button>
|
||||
<div class="error-msg" id="loginError"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div class="app-container" id="appContainer">
|
||||
<div class="header">
|
||||
<h1>📁 FTP Server 管理面板</h1>
|
||||
<div class="user-info">
|
||||
<span id="welcomeText">欢迎, Admin</span>
|
||||
<button class="btn btn-sm btn-danger" onclick="doLogout()">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<a href="#" class="active" onclick="showSection('dashboard', this)">📊 仪表盘</a>
|
||||
<a href="#" onclick="showSection('users', this)">👤 用户管理</a>
|
||||
<a href="#" onclick="showSection('settings', this)">⚙ 系统设置</a>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- Dashboard Section -->
|
||||
<div class="section active" id="section-dashboard">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">服务状态</div>
|
||||
<div class="value green">运行中</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">FTP 端口</div>
|
||||
<div class="value purple" id="statFtpPort">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Web 端口</div>
|
||||
<div class="value blue" id="statWebPort">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">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>
|
||||
`
|
||||
@@ -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">■</span> 仪表盘
|
||||
</li>
|
||||
<li data-page="users">
|
||||
<span class="icon">☯</span> 用户管理
|
||||
</li>
|
||||
<li data-page="files">
|
||||
<span class="icon">📁</span> 文件管理
|
||||
</li>
|
||||
<li data-page="logs">
|
||||
<span class="icon">📄</span> 操作日志
|
||||
</li>
|
||||
<li data-page="online">
|
||||
<span class="icon">🔗</span> 在线用户
|
||||
</li>
|
||||
<li data-page="ip-rules">
|
||||
<span class="icon">🛡</span> IP白/黑名单
|
||||
</li>
|
||||
<li data-page="settings">
|
||||
<span class="icon">⚙</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()">×</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()">×</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()">×</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>
|
||||
@@ -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">📁</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">📄</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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user