Initial commit: lzwlab.cn server project (docs, nginx, gitea, rustdesk, file transfer app)
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
# ===== 机密文件:绝不提交 =====
|
||||
# 服务器凭据(明文密码)
|
||||
Authentication.md
|
||||
|
||||
# TLS 私钥 / 证书目录
|
||||
lzwlab.cn_nginx/
|
||||
|
||||
# ===== AI 助手本地数据 / 工具状态 =====
|
||||
.dsh/
|
||||
.dsh-meow/
|
||||
|
||||
# ===== 常见运行时产物 =====
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.log
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
本项目的所有重要变更均记录于此。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- 新增 Gitea Git 服务(https://lzwlab.cn/git/):Docker Compose 部署 Gitea 1.27.2(SQLite),管理员私有单实例,首页欢迎页新增“🐙 进入 Git 服务”入口。
|
||||
- 新增 gitea/docker-compose.yml 部署配置:Web 仅监听 127.0.0.1:3000,SSH 映射 2222→22,关闭开放注册并锁定安装器。
|
||||
- setup.md 新增第 10 节:Gitea 部署过程、子路径反代配置、管理员账号创建与验证结果。
|
||||
- 新增 RustDesk 自建远程桌面服务器:Docker Compose 部署 hbbs(ID/注册服务器)+ hbbr(中继服务器),host 网络监听 21115/21116(TCP+UDP)/21117/21118/21119,客户端以域名 lzwlab.cn 接入。
|
||||
- 新增 rustdesk/docker-compose.yml 部署配置;服务器安装 Docker(docker.io 29.1.3 + docker-compose-v2)并配置 daocloud 镜像加速(腾讯云访问 Docker Hub 被拒)。
|
||||
- setup.md 新增第 9 节:RustDesk 部署过程、验证结果与客户端配置说明。
|
||||
- 新增“文件快传”在线文件暂存工具,入口 https://lzwlab.cn/transfer/ ,首页欢迎页增加“进入文件快传”链接。
|
||||
- 文件快传支持指定用户登录鉴权、PC/手机响应式界面、多文件队列上传与下载进度显示、缓存文件列表、手动删除与手动清理。
|
||||
- 上传时可按 1 小时~30 天设置文件有效期(默认 7 天),过期文件由 systemd 定时器每 10 分钟自动删除。
|
||||
- 新增应用源码目录 transfer/ (Flask + gunicorn + SQLite)与幂等部署脚本 deploy_transfer.py 。
|
||||
- 新增 systemd 单元 lzwlab-transfer.service 与 lzwlab-transfer-cleanup.{service,timer} 。
|
||||
|
||||
### Changed
|
||||
|
||||
- 更新首页 index.html :欢迎卡片增加“进入 Git 服务”入口链接(/git/)。
|
||||
- 更新 lzwlab.nginx.conf :按 Gitea 官方子路径(sub-path)方案新增 /git/ 反向代理到本机 3000 端口。
|
||||
- 更新 README.md 与 setup.md :补充 Gitea Git 服务部署记录与使用说明。
|
||||
- 更新首页 index.html :欢迎卡片下方增加文件快传入口链接。
|
||||
- 更新 lzwlab.nginx.conf :新增 /transfer/ 反向代理到本机 8090 端口,上传/下载超时调整为 3600 秒,上传体积上限 5120 MB。
|
||||
- 更新 README.md 与 setup.md :补充 RustDesk 自建服务器部署记录与客户端配置说明。
|
||||
|
||||
### Removed
|
||||
|
||||
- 移除首页欢迎卡中的“网站搭建成功!这是部署在个人云服务器上的第一个页面。”提示信息,首页仅保留标题、Powered by Nginx 徽标与“文件快传”“Git 服务”两个入口链接。
|
||||
|
||||
### Security
|
||||
|
||||
- Gitea 关闭开放注册并锁定安装器;管理员凭据仅记录于 Authentication.md。
|
||||
- Gitea SSH 克隆端口 2222 需在腾讯云安全组放行 TCP 2222 后方可从公网使用(HTTPS 克隆不受影响)。
|
||||
- RustDesk 服务使用 hbbs 首次启动自动生成的自建密钥(服务器 /opt/rustdesk/data/id_ed25519.pub),客户端必须配置该 Key 才能连接服务器。
|
||||
- 应用登录密码仅以 scrypt 哈希保存在 /etc/lzwlab-transfer/config.json (root:lzwlab 640),不在代码或仓库中保存明文。
|
||||
- 会话 Cookie 启用 Secure/HttpOnly/SameSite=Lax,登录接口按 IP 限流,写接口校验 X-Requested-With 头。
|
||||
|
||||
## [1.0.0] - 2026-08-24
|
||||
|
||||
初始版本:lzwlab.cn 静态站点上线。
|
||||
|
||||
### Added
|
||||
|
||||
- 新增欢迎页 `index.html`(中文标题“欢迎访问 lzwlab.cn”)。
|
||||
- 新增 nginx 站点配置 `lzwlab.nginx.conf`:80 端口 301 跳转 443,启用 TLS 1.2/1.3 与 HTTP/2。
|
||||
- 新增 TLS 证书与私钥(TrustAsia DV,SAN:lzwlab.cn、www.lzwlab.cn),存放于 `lzwlab.cn_nginx/`。
|
||||
- 新增远程运维脚本 `remote.py`(Paramiko):支持远程执行命令(可 sudo)与文件上传。
|
||||
- 新增部署记录 `setup.md`,覆盖安装 nginx、上传站点、HTTPS 配置与验证全过程。
|
||||
- 新增文档维护技能 `.dsh/skills/maintain-docs/`,用于项目修改后自动更新 README 与 CHANGELOG。
|
||||
@@ -0,0 +1,121 @@
|
||||
# lzwlab.cn 站点部署仓库
|
||||
|
||||
lzwlab.cn 个人云服务器上的站点部署项目:包含站点欢迎页、nginx 站点配置、TLS 证书、远程运维脚本,以及从首页链接进入的“文件快传”在线文件暂存工具。上线效果:http 请求自动 301 跳转到 https,`https://lzwlab.cn` 与 `https://www.lzwlab.cn` 均正常展示欢迎页,`https://lzwlab.cn/transfer/` 提供登录鉴权的文件上传/下载服务。服务器上还以 Docker 部署了 RustDesk 自建远程桌面服务(hbbs/hbbr,客户端以域名 `lzwlab.cn` 接入)与 Gitea Git 服务(入口 `https://lzwlab.cn/git/`,首页提供“🐙 进入 Git 服务”按钮)。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 静态欢迎页:`index.html`,响应式渐变卡片,中文标题“欢迎访问 lzwlab.cn”,并提供“📦 进入文件快传”“🐙 进入 Git 服务”两个入口。
|
||||
- HTTPS 全站:`lzwlab.nginx.conf` 将 80 端口请求 301 跳转到 443;443 端口启用 TLS 1.2/1.3 与 HTTP/2,证书链由 TrustAsia 签发。
|
||||
- 文件快传(`/transfer/`):指定用户登录后才能使用,PC/手机自适应;支持多文件上传与下载进度、有效期设置(默认 7 天,最短 1 小时,最长 30 天)、缓存列表与手动删除;过期文件由 systemd 定时器每 10 分钟自动清理。
|
||||
- 远程运维脚本:`remote.py` 基于 Paramiko,支持执行远程命令(可 sudo)与上传文件,凭据自动从 `Authentication.md` 解析,无需在命令行传密码。
|
||||
- RustDesk 自建服务器:Docker Compose 部署 hbbs(ID/注册服务器)+ hbbr(中继服务器),host 网络监听 21115-21119,客户端以域名 `lzwlab.cn` 接入,客户端配置说明见 [setup.md](setup.md) 第 9 节。
|
||||
- Gitea Git 服务(`https://lzwlab.cn/git/`):Docker 部署 Gitea 1.27.2(SQLite),管理员私有单实例,关闭开放注册;支持 HTTPS 克隆与 SSH 克隆(端口 2222),部署记录见 [setup.md](setup.md) 第 10 节。
|
||||
- 完整部署记录:`setup.md` 覆盖从环境检查、安装 nginx、上传站点、HTTPS 配置到验证的全过程,并附踩坑记录。
|
||||
- 文档维护技能:`.dsh/skills/maintain-docs/`,用于在项目修改后自动更新本 README 与 CHANGELOG.md。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
.
|
||||
├── Authentication.md # 服务器凭据(机密,勿公开/勿提交)
|
||||
├── index.html # 站点欢迎页
|
||||
├── lzwlab.nginx.conf # nginx 站点配置(HTTP→HTTPS、TLS、HTTP/2)
|
||||
├── lzwlab.cn_nginx/ # TLS 证书与私钥(私钥勿公开/勿提交)
|
||||
│ ├── lzwlab.cn.csr
|
||||
│ ├── lzwlab.cn.key
|
||||
│ ├── lzwlab.cn_bundle.crt
|
||||
│ └── lzwlab.cn_bundle.pem
|
||||
├── remote.py # Paramiko 远程执行/上传脚本
|
||||
├── deploy_transfer.py # 文件快传一键部署脚本(幂等,可重复执行)
|
||||
├── transfer/ # 文件快传应用源码
|
||||
│ ├── app.py # Flask 应用入口(gunicorn 启动)
|
||||
│ ├── auth.py # 配置加载、scrypt 密码哈希、登录限流
|
||||
│ ├── storage.py # SQLite 元数据 + 磁盘缓存存储层
|
||||
│ ├── cleanup.py # 过期文件/孤儿缓存清理 CLI
|
||||
│ ├── templates/index.html # 响应式单页前端(登录/上传队列/下载进度/文件列表)
|
||||
│ └── systemd/ # systemd 服务与清理定时器单元
|
||||
│ ├── lzwlab-transfer.service
|
||||
│ ├── lzwlab-transfer-cleanup.service
|
||||
│ └── lzwlab-transfer-cleanup.timer
|
||||
├── rustdesk/ # RustDesk 自建服务器 Compose 配置(hbbs/hbbr)
|
||||
│ └── docker-compose.yml
|
||||
├── gitea/ # Gitea Git 服务 Compose 配置(容器 gitea,SQLite)
|
||||
│ └── docker-compose.yml
|
||||
├── setup.md # 完整部署记录
|
||||
└── .dsh/skills/maintain-docs/ # 文档维护技能(README/CHANGELOG)
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- 本地(运维机):Python 3 + Paramiko(`pip install paramiko`)。
|
||||
- 服务器:Ubuntu 24.04 LTS、nginx 1.24(systemd 管理)、Python 3.12、Flask 3.0.2 与 gunicorn 20.1(apt 安装)。
|
||||
- 服务器:Docker 29.1.3(apt docker.io + docker-compose-v2,已配置 daocloud 镜像加速)运行 RustDesk hbbs/hbbr 与 Gitea Git 服务。
|
||||
- 域名 `lzwlab.cn` / `www.lzwlab.cn` 的 DNS 已解析到服务器。
|
||||
|
||||
## 安装
|
||||
|
||||
本项目无需本地构建。站点文件与 nginx 配置通过 `remote.py` 上传到服务器后安装;文件快传应用通过 `deploy_transfer.py` 一键部署,完整步骤见下文“部署说明”。
|
||||
|
||||
## 使用说明
|
||||
|
||||
`remote.py` 用法:
|
||||
|
||||
```bash
|
||||
# 执行远程命令(sudo 命令需加 --sudo)
|
||||
python remote.py "sudo nginx -t" --sudo
|
||||
python remote.py "sudo systemctl reload nginx" --sudo
|
||||
|
||||
# 上传文件(LOCAL 为本地路径,REMOTE 为远端路径)
|
||||
python remote.py --put index.html /tmp/lzwlab-index.html
|
||||
```
|
||||
|
||||
- 凭据由脚本从 `Authentication.md` 解析,无需在命令行传密码。
|
||||
- Windows Git Bash 提示:MSYS 会把以 `/` 开头的参数转换成 Windows 路径,上传远端路径时请加 `MSYS_NO_PATHCONV=1`。
|
||||
|
||||
部署文件快传(应用登录密码经环境变量传入,仅密码哈希会写入服务器配置):
|
||||
|
||||
```bash
|
||||
LZWLAB_APP_PASSWORD='$PASS' python deploy_transfer.py --username admin
|
||||
```
|
||||
|
||||
验证站点:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/ # 200
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/transfer/ # 200(登录页)
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/git/ # 200(Git 服务登录页)
|
||||
```
|
||||
|
||||
## 部署说明
|
||||
|
||||
完整步骤见 [setup.md](setup.md)(环境检查 → 安装 nginx → 上传站点 → 配置 HTTP/HTTPS → 验证)。要点:
|
||||
|
||||
- 站点根目录:`/var/www/lzwlab.cn`;
|
||||
- nginx 配置安装到 `/etc/nginx/sites-available/lzwlab.cn` 并软链到 `sites-enabled/`,改配置后执行 `sudo systemctl reload nginx`;
|
||||
- 证书安装到 `/etc/nginx/ssl/`(私钥权限 600、root:root);
|
||||
- 文件快传:应用代码安装到 `/opt/lzwlab-transfer`,数据目录 `/var/lib/lzwlab-transfer`,配置 `/etc/lzwlab-transfer/config.json`(root:lzwlab 640);gunicorn 监听 `127.0.0.1:8090`,由 nginx 将 `/transfer/` 反向代理到该端口,systemd 定时器每 10 分钟执行一次过期清理。
|
||||
- RustDesk:Compose 文件安装在 `/opt/rustdesk`(hbbs+hbbr,host 网络,数据目录 `/opt/rustdesk/data`);客户端 ID 服务器/中继/Key 配置见 [setup.md](setup.md) 第 9 节。
|
||||
- Gitea:Compose 文件安装在 `/opt/gitea`(容器 `gitea`,SQLite 数据卷 `/opt/gitea/gitea`,Web 仅监听 `127.0.0.1:3000`,SSH 映射 `2222→22`);nginx 按 Gitea 官方 sub-path 方案将 `/git/` 反代到 3000 端口;管理员账号与密码记录在 `Authentication.md`,注册已关闭、安装器已锁定。详见 [setup.md](setup.md) 第 10 节。
|
||||
|
||||
## 安全与凭据说明
|
||||
|
||||
- `Authentication.md` 含服务器凭据,属机密:不要提交到公开仓库、不要写入任何对外文档;文档中密码一律以 `$PASS` 代替。
|
||||
- `lzwlab.cn_nginx/lzwlab.cn.key` 为私钥,上传到服务器后权限应为 600(root:root)。
|
||||
- 站点已启用 HTTPS 并强制 301 跳转,nginx 配置限定 TLS 1.2/1.3。
|
||||
- 文件快传登录密码只以 scrypt 哈希形式保存在 `/etc/lzwlab-transfer/config.json`,应用不保存明文密码;会话 Cookie 带 `Secure`、`HttpOnly`、`SameSite=Lax`,登录接口带按 IP 限流,写接口要求 `X-Requested-With` 头。
|
||||
- 文件快传缓存文件按有效期自动删除,请在服务器上自行控制磁盘空间。
|
||||
- RustDesk 使用 hbbs 首次启动自动生成的自建密钥(服务器 `/opt/rustdesk/data/id_ed25519.pub`),客户端必须配置该 Key 才能连接;Key 需分发给每个客户端使用,本身不属于机密凭据。
|
||||
- Gitea 关闭开放注册并锁定安装器;管理员密码只记录在 `Authentication.md`,对外文档一律用 `$PASS` 代替。
|
||||
- Gitea SSH 克隆端口为 2222,需在腾讯云安全组放行 TCP 2222 后才能从公网使用;HTTPS 克隆与网页访问不受影响。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [setup.md](setup.md):完整部署记录与踩坑记录
|
||||
- [CHANGELOG.md](CHANGELOG.md):变更日志(遵循 Keep a Changelog 与 SemVer)
|
||||
- [index.html](index.html):站点页面源码
|
||||
- [deploy_transfer.py](deploy_transfer.py):文件快传部署脚本
|
||||
- [rustdesk/docker-compose.yml](rustdesk/docker-compose.yml):RustDesk 自建服务器 Compose 配置
|
||||
- [gitea/docker-compose.yml](gitea/docker-compose.yml):Gitea Git 服务 Compose 配置
|
||||
- [Gitea 官方文档](https://docs.gitea.com/):安装与反向代理(sub-path)参考
|
||||
- [transfer/](transfer/):文件快传应用源码
|
||||
@@ -0,0 +1,212 @@
|
||||
"""将 lzwlab.cn 文件快传部署到远程服务器(幂等,可重复执行)。
|
||||
|
||||
用法(凭据从 Authentication.md 解析;应用登录密码通过环境变量传入,不落盘):
|
||||
|
||||
LZWLAB_APP_PASSWORD='你的应用登录密码' python deploy_transfer.py --username admin
|
||||
|
||||
服务器端安装:
|
||||
- apt 安装 python3-flask、gunicorn
|
||||
- 应用代码安装到 /opt/lzwlab-transfer(root 所有)
|
||||
- 运行用户 lzwlab(systemd 服务,监听 127.0.0.1:8090)
|
||||
- 数据目录 /var/lib/lzwlab-transfer,配置 /etc/lzwlab-transfer/config.json(密码仅存 scrypt 哈希)
|
||||
- systemd 定时器每 10 分钟清理过期文件
|
||||
- 更新 nginx 反代配置与首页链接
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import paramiko
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(ROOT, "transfer"))
|
||||
import auth # noqa: E402
|
||||
|
||||
REMOTE_TMP = "/tmp/lzwlab-transfer-deploy"
|
||||
APP_DIR = "/opt/lzwlab-transfer"
|
||||
DATA_DIR = "/var/lib/lzwlab-transfer"
|
||||
CONFIG_PATH = "/etc/lzwlab-transfer/config.json"
|
||||
LOG_DIR = "/var/log/lzwlab-transfer"
|
||||
SERVICE_USER = "lzwlab"
|
||||
PORT = 8090
|
||||
|
||||
LOCAL_FILES = [
|
||||
("transfer/app.py", "app.py"),
|
||||
("transfer/auth.py", "auth.py"),
|
||||
("transfer/storage.py", "storage.py"),
|
||||
("transfer/cleanup.py", "cleanup.py"),
|
||||
("transfer/templates/index.html", "templates/index.html"),
|
||||
]
|
||||
SYSTEMD_FILES = [
|
||||
("transfer/systemd/lzwlab-transfer.service", "lzwlab-transfer.service"),
|
||||
("transfer/systemd/lzwlab-transfer-cleanup.service", "lzwlab-transfer-cleanup.service"),
|
||||
("transfer/systemd/lzwlab-transfer-cleanup.timer", "lzwlab-transfer-cleanup.timer"),
|
||||
]
|
||||
|
||||
|
||||
def parse_auth() -> tuple[str, str, str]:
|
||||
text = open(os.path.join(ROOT, "Authentication.md"), encoding="utf-8").read()
|
||||
def find(label: str) -> str:
|
||||
m = re.search(label + r"\s*[::]\s*(\S+)", text)
|
||||
if not m:
|
||||
sys.exit("无法解析 Authentication.md 中的 " + label)
|
||||
return m.group(1)
|
||||
return find("IP地址"), find("用户名"), find("密码")
|
||||
|
||||
|
||||
def connect(ip: str, user: str, pw: str) -> paramiko.SSHClient:
|
||||
cli = paramiko.SSHClient()
|
||||
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
cli.connect(ip, port=22, username=user, password=pw, timeout=20,
|
||||
look_for_keys=False, allow_agent=False, banner_timeout=30)
|
||||
return cli
|
||||
|
||||
|
||||
def run(ssh: paramiko.SSHClient, cmd: str, timeout: int = 600) -> tuple[int, str, str]:
|
||||
print(" $ " + cmd)
|
||||
_in, out, err = ssh.exec_command(cmd, timeout=timeout)
|
||||
out_s = out.read().decode("utf-8", "replace")
|
||||
err_s = err.read().decode("utf-8", "replace")
|
||||
code = out.channel.recv_exit_status()
|
||||
if out_s.strip():
|
||||
print(out_s.strip())
|
||||
if err_s.strip():
|
||||
print("[stderr]", err_s.strip())
|
||||
return code, out_s, err_s
|
||||
|
||||
|
||||
def must(ssh: paramiko.SSHClient, cmd: str, timeout: int = 600) -> str:
|
||||
code, out, err = run(ssh, cmd, timeout)
|
||||
if code != 0:
|
||||
sys.exit("命令失败(exit=%d):%s" % (code, cmd))
|
||||
return out
|
||||
|
||||
|
||||
def sftp_mkdir_p(sftp: paramiko.SFTPClient, remote_dir: str) -> None:
|
||||
try:
|
||||
sftp.stat(remote_dir)
|
||||
except IOError:
|
||||
parent = os.path.dirname(remote_dir.rstrip("/"))
|
||||
if parent and parent != remote_dir:
|
||||
sftp_mkdir_p(sftp, parent)
|
||||
sftp.mkdir(remote_dir)
|
||||
|
||||
|
||||
def upload(ssh: paramiko.SSHClient, local: str, remote: str) -> None:
|
||||
print(" [upload] %s -> %s" % (local, remote))
|
||||
sftp = ssh.open_sftp()
|
||||
try:
|
||||
sftp_mkdir_p(sftp, os.path.dirname(remote))
|
||||
sftp.put(os.path.join(ROOT, local), remote)
|
||||
finally:
|
||||
sftp.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--username", default="admin", help="应用登录用户名(默认 admin)")
|
||||
ap.add_argument("--password-env", default="LZWLAB_APP_PASSWORD",
|
||||
help="应用登录密码所在的环境变量名")
|
||||
ap.add_argument("--skip-apt", action="store_true", help="跳过 apt 安装依赖")
|
||||
args = ap.parse_args()
|
||||
|
||||
password = os.environ.get(args.password_env, "")
|
||||
if len(password) < 8:
|
||||
sys.exit("请通过环境变量 %s 提供至少 8 位的应用登录密码" % args.password_env)
|
||||
|
||||
ip, ssh_user, ssh_pw = parse_auth()
|
||||
print("目标服务器:%s@%s" % (ssh_user, ip))
|
||||
ssh = connect(ip, ssh_user, ssh_pw)
|
||||
try:
|
||||
# 1. 依赖
|
||||
if not args.skip_apt:
|
||||
code, _, err = run(ssh, "sudo -n env DEBIAN_FRONTEND=noninteractive "
|
||||
"apt-get install -y python3-flask gunicorn", timeout=900)
|
||||
if code != 0:
|
||||
print("直接安装失败,尝试先 apt-get update …")
|
||||
must(ssh, "sudo -n apt-get update -qq", timeout=900)
|
||||
must(ssh, "sudo -n env DEBIAN_FRONTEND=noninteractive "
|
||||
"apt-get install -y python3-flask gunicorn", timeout=900)
|
||||
|
||||
# 2. 目录、运行用户与数据目录
|
||||
must(ssh, "sudo -n mkdir -p %s %s/templates %s/files %s /etc/lzwlab-transfer"
|
||||
% (APP_DIR, APP_DIR, DATA_DIR, LOG_DIR))
|
||||
code, _, _ = run(ssh, "id -u %s >/dev/null 2>&1" % SERVICE_USER)
|
||||
if code != 0:
|
||||
must(ssh, "sudo -n useradd --system --home-dir %s --shell /usr/sbin/nologin %s"
|
||||
% (APP_DIR, SERVICE_USER))
|
||||
must(ssh, "sudo -n chown -R %s:%s %s %s" % (SERVICE_USER, SERVICE_USER, DATA_DIR, LOG_DIR))
|
||||
must(ssh, "sudo -n chmod 750 %s" % LOG_DIR)
|
||||
must(ssh, "sudo -n rm -rf %s && mkdir -p %s" % (REMOTE_TMP, REMOTE_TMP))
|
||||
|
||||
# 3. 上传应用文件
|
||||
for local, name in LOCAL_FILES:
|
||||
upload(ssh, local, "%s/%s" % (REMOTE_TMP, name))
|
||||
for local, name in SYSTEMD_FILES:
|
||||
upload(ssh, local, "%s/systemd/%s" % (REMOTE_TMP, name))
|
||||
upload(ssh, "index.html", "%s/index.html" % REMOTE_TMP)
|
||||
upload(ssh, "lzwlab.nginx.conf", "%s/lzwlab.cn.conf" % REMOTE_TMP)
|
||||
|
||||
# 4. 生成配置(仅包含密码哈希,不保存明文)
|
||||
cfg = {
|
||||
"secret_key": auth.random_secret(),
|
||||
"cookie_secure": True,
|
||||
"max_content_length": 5 * 1024 ** 3,
|
||||
"data_dir": DATA_DIR,
|
||||
"database_path": DATA_DIR + "/files.db", # 远端为 Linux 路径,不能用 Windows 的 os.path.join
|
||||
"session_days": 7,
|
||||
"users": {
|
||||
args.username: {"password_hash": auth.hash_password(password)}
|
||||
},
|
||||
}
|
||||
print(" [generate] 应用配置(用户:%s)" % args.username)
|
||||
sftp = ssh.open_sftp()
|
||||
try:
|
||||
with sftp.open("%s/config.json" % REMOTE_TMP, "w") as fh:
|
||||
fh.write(json.dumps(cfg, ensure_ascii=False, indent=2))
|
||||
finally:
|
||||
sftp.close()
|
||||
|
||||
# 5. 安装应用、配置与 systemd
|
||||
for _, name in LOCAL_FILES:
|
||||
must(ssh, "sudo -n install -m 644 -o root -g root %s/%s %s/%s"
|
||||
% (REMOTE_TMP, name, APP_DIR, name))
|
||||
must(ssh, "sudo -n install -m 640 -o root -g %s %s/config.json %s"
|
||||
% (SERVICE_USER, REMOTE_TMP, CONFIG_PATH))
|
||||
for _, name in SYSTEMD_FILES:
|
||||
must(ssh, "sudo -n install -m 644 -o root -g root %s/systemd/%s /etc/systemd/system/%s"
|
||||
% (REMOTE_TMP, name, name))
|
||||
must(ssh, "sudo -n install -m 644 -o root -g root %s/index.html /var/www/lzwlab.cn/index.html"
|
||||
% REMOTE_TMP)
|
||||
must(ssh, "sudo -n install -m 644 -o root -g root %s/lzwlab.cn.conf "
|
||||
"/etc/nginx/sites-available/lzwlab.cn" % REMOTE_TMP)
|
||||
|
||||
# 6. 启动服务与定时器
|
||||
must(ssh, "sudo -n systemctl daemon-reload")
|
||||
must(ssh, "sudo -n systemctl enable --now lzwlab-transfer.service lzwlab-transfer-cleanup.timer")
|
||||
must(ssh, "sudo -n systemctl restart lzwlab-transfer.service")
|
||||
must(ssh, "sudo -n systemctl enable lzwlab-transfer-cleanup.timer && "
|
||||
"sudo -n systemctl start lzwlab-transfer-cleanup.timer")
|
||||
time.sleep(1)
|
||||
|
||||
# 7. 校验并重载 nginx
|
||||
must(ssh, "sudo -n nginx -t")
|
||||
must(ssh, "sudo -n systemctl reload nginx")
|
||||
must(ssh, "sudo -n rm -rf %s" % REMOTE_TMP)
|
||||
|
||||
print()
|
||||
print("== 部署完成,服务状态 ==")
|
||||
run(ssh, "systemctl is-active lzwlab-transfer.service lzwlab-transfer-cleanup.timer")
|
||||
run(ssh, "curl -s -o /dev/null -w 'local https /transfer/ => %{http_code}\n' "
|
||||
"-k https://127.0.0.1/transfer/ -H 'Host: lzwlab.cn'")
|
||||
print("应用入口:https://lzwlab.cn/transfer/")
|
||||
print("登录用户:%s(密码为环境变量 %s 中提供的值)" % (args.username, args.password_env))
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
server:
|
||||
image: docker.gitea.com/gitea:1.27.2
|
||||
container_name: gitea
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
# 站点设置:子路径 https://lzwlab.cn/git/(服务器无 git.lzwlab.cn 子域名,按官方文档使用 sub-path)
|
||||
- GITEA__server__ROOT_URL=https://lzwlab.cn/git/
|
||||
- GITEA__server__DOMAIN=lzwlab.cn
|
||||
- GITEA__server__SSH_DOMAIN=lzwlab.cn
|
||||
- GITEA__server__SSH_PORT=2222
|
||||
- GITEA__server__SSH_LISTEN_PORT=22
|
||||
- GITEA__server__HTTP_PORT=3000
|
||||
# 数据库:SQLite(轻量单实例,数据卷持久化)
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
# 安装锁 + 关闭开放注册(仅管理员可用)
|
||||
- GITEA__security__INSTALL_LOCK=true
|
||||
- GITEA__service__DISABLE_REGISTRATION=true
|
||||
restart: always
|
||||
networks:
|
||||
- gitea
|
||||
volumes:
|
||||
- ./gitea:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
# Web 仅监听本机,由 nginx 通过 https://lzwlab.cn/git/ 反代
|
||||
- "127.0.0.1:3000:3000"
|
||||
# SSH 端口 22 与主机 sshd 冲突,按官方文档映射到 2222
|
||||
- "2222:22"
|
||||
networks:
|
||||
gitea:
|
||||
external: false
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>欢迎访问 lzwlab.cn</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
}
|
||||
.card {
|
||||
background: rgba(255, 255, 255, .95);
|
||||
border-radius: 16px;
|
||||
padding: 48px 56px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, .25);
|
||||
max-width: 90vw;
|
||||
}
|
||||
h1 { font-size: 2.2rem; color: #333; margin-bottom: 12px; }
|
||||
p { color: #666; line-height: 1.8; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
font-size: .85rem;
|
||||
}
|
||||
.tool-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 12px 28px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #667eea;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, .35);
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
}
|
||||
.tool-link:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(102, 126, 234, .45); }
|
||||
.tool-link:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🎉 欢迎访问 lzwlab.cn</h1>
|
||||
<span class="badge">Powered by Nginx</span>
|
||||
<br>
|
||||
<a class="tool-link" href="/transfer/">📦 进入文件快传</a>
|
||||
<br>
|
||||
<a class="tool-link" href="/git/">🐙 进入 Git 服务</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name lzwlab.cn www.lzwlab.cn;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name lzwlab.cn www.lzwlab.cn;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/lzwlab.cn_bundle.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/lzwlab.cn.key;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
root /var/www/lzwlab.cn;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# 文件快传应用:/transfer/ 反代到本机 8090 端口(Flask + gunicorn)
|
||||
location = /transfer {
|
||||
return 301 /transfer/;
|
||||
}
|
||||
location /transfer/ {
|
||||
proxy_pass http://127.0.0.1:8090/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
client_max_body_size 5120m;
|
||||
}
|
||||
|
||||
# Gitea Git 服务:/git/ 反代到本机 3000 端口(Docker 容器)
|
||||
# 子路径方案参考 https://docs.gitea.com/administration/reverse-proxies/ 的 "Nginx with a sub-path"
|
||||
location ~ ^/(git)($|/) {
|
||||
client_max_body_size 512M;
|
||||
# 保持 "%2F" 原样,去掉 "/git" 前缀后转发给 Gitea
|
||||
rewrite ^ $request_uri;
|
||||
rewrite ^/(git($|/))?(.*) /$3 break;
|
||||
proxy_pass http://127.0.0.1:3000$uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection $http_connection;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import argparse, re, sys, time
|
||||
import paramiko
|
||||
|
||||
def parse_auth():
|
||||
text = open("Authentication.md", encoding="utf-8").read()
|
||||
def find(label):
|
||||
m = re.search(label + r"\s*[::]\s*(\S+)", text)
|
||||
if not m:
|
||||
sys.exit("cannot parse " + label)
|
||||
return m.group(1)
|
||||
ip = find("IP地址")
|
||||
user = find("用户名")
|
||||
pw = find("密码")
|
||||
root_pw = find("root密码")
|
||||
return ip, user, pw, root_pw
|
||||
|
||||
IP, USER, PW, ROOT_PW = parse_auth()
|
||||
|
||||
def connect():
|
||||
cli = paramiko.SSHClient()
|
||||
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
cli.connect(IP, port=22, username=USER, password=PW, timeout=20,
|
||||
look_for_keys=False, allow_agent=False, banner_timeout=30)
|
||||
return cli
|
||||
|
||||
def run(cmd, sudo=False, timeout=300):
|
||||
cli = connect()
|
||||
chan = cli.get_transport().open_session()
|
||||
chan.settimeout(timeout)
|
||||
chan.exec_command(cmd)
|
||||
if sudo:
|
||||
chan.sendall(PW + "\n")
|
||||
try:
|
||||
while True:
|
||||
if chan.recv_ready():
|
||||
chunk = chan.recv(65536)
|
||||
if not chunk: break
|
||||
sys.stdout.buffer.write(chunk); sys.stdout.flush()
|
||||
if chan.recv_stderr_ready():
|
||||
sys.stderr.buffer.write(chan.recv_stderr(65536)); sys.stderr.flush()
|
||||
if chan.exit_status_ready():
|
||||
while chan.recv_ready():
|
||||
chunk = chan.recv(65536)
|
||||
sys.stdout.buffer.write(chunk); sys.stdout.flush()
|
||||
while chan.recv_stderr_ready():
|
||||
sys.stderr.buffer.write(chan.recv_stderr(65536)); sys.stderr.flush()
|
||||
break
|
||||
time.sleep(0.05)
|
||||
except Exception as e:
|
||||
print("\n[read-exception]", e)
|
||||
rc = chan.recv_exit_status() if chan.exit_status_ready() else -1
|
||||
cli.close()
|
||||
print(f"\n[exit={rc}]")
|
||||
return rc
|
||||
|
||||
def put(local, remote):
|
||||
cli = connect()
|
||||
sftp = cli.open_sftp()
|
||||
sftp.put(local, remote)
|
||||
sftp.close()
|
||||
cli.close()
|
||||
print(f"[uploaded {local} -> {USER}@{IP}:{remote}]")
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("command", nargs=argparse.REMAINDER)
|
||||
ap.add_argument("--sudo", action="store_true")
|
||||
ap.add_argument("--timeout", type=int, default=300)
|
||||
ap.add_argument("--put", nargs=2, metavar=("LOCAL", "REMOTE"))
|
||||
a = ap.parse_args()
|
||||
if a.put:
|
||||
put(a.put[0], a.put[1])
|
||||
else:
|
||||
run(" ".join(a.command), sudo=a.sudo, timeout=a.timeout)
|
||||
@@ -0,0 +1,28 @@
|
||||
# RustDesk 自建服务器(OSS 版)Docker Compose 配置
|
||||
# 参考:https://rustdesk.com/docs/zh-cn/self-host/rustdesk-server-oss/docker/
|
||||
# 使用 host 网络,监听端口(防火墙需放行):
|
||||
# hbbs:21114/TCP(仅 Pro 需要)、21115/TCP(NAT 类型测试)、21116/TCP+UDP(ID 注册/心跳与打洞)
|
||||
# hbbr:21117/TCP(中继)、21119/TCP(网页客户端)
|
||||
# 网页客户端支持端口:21118/TCP(hbbs)、21119/TCP(hbbr)
|
||||
# 密钥与数据保存在 ./data(容器内 /root):id_ed25519、id_ed25519.pub
|
||||
services:
|
||||
hbbs:
|
||||
container_name: hbbs
|
||||
image: rustdesk/rustdesk-server:latest
|
||||
command: hbbs
|
||||
volumes:
|
||||
- ./data:/root
|
||||
network_mode: "host"
|
||||
|
||||
depends_on:
|
||||
- hbbr
|
||||
restart: unless-stopped
|
||||
|
||||
hbbr:
|
||||
container_name: hbbr
|
||||
image: rustdesk/rustdesk-server:latest
|
||||
command: hbbr
|
||||
volumes:
|
||||
- ./data:/root
|
||||
network_mode: "host"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,510 @@
|
||||
# 服务器部署记录:nginx 欢迎页(lzwlab.cn)
|
||||
|
||||
> 说明:服务器凭据见 `Authentication.md`,本文档中密码一律以 `$PASS` 代替。
|
||||
> 执行方式:本地通过 Paramiko 以 SSH 密码方式登录远程执行(远程用户 ubuntu,sudo 组免密)。
|
||||
|
||||
## 0. 环境信息
|
||||
|
||||
| 项目 | 值 |
|
||||
| --- | --- |
|
||||
| 服务器 IP | 1.15.225.173 |
|
||||
| 登录用户 | ubuntu(sudo 组,`sudo -n` 可免密) |
|
||||
| 系统 | Ubuntu 24.04.4 LTS(kernel 6.8.0-124-generic,腾讯云 VM) |
|
||||
| 域名 | lzwlab.cn / www.lzwlab.cn(DNS 已解析到 1.15.225.173) |
|
||||
| 防火墙 | ufw inactive(无需额外放行) |
|
||||
| 磁盘 | 50G,已用 5.3G |
|
||||
|
||||
## 1. 连接与初始状态检查
|
||||
|
||||
```bash
|
||||
# 本地连接(密码 $PASS,取自 Authentication.md)
|
||||
ssh ubuntu@1.15.225.173
|
||||
|
||||
# 登录后检查系统
|
||||
whoami && id && hostname && uname -a
|
||||
cat /etc/os-release | head -3
|
||||
|
||||
# 检查 sudo 免密
|
||||
sudo -n true && echo sudo-ok
|
||||
|
||||
# 检查 nginx(结果:未安装)
|
||||
command -v nginx && nginx -v
|
||||
|
||||
# 检查 80/443 端口占用(结果:空闲)
|
||||
ss -tlnp | grep -E ":(80|443)" || echo no-80-443
|
||||
|
||||
# 检查防火墙(结果:inactive)
|
||||
sudo -n ufw status
|
||||
|
||||
# 检查域名解析(结果:均解析到 1.15.225.173)
|
||||
getent hosts lzwlab.cn www.lzwlab.cn
|
||||
```
|
||||
|
||||
本机验证 DNS(结果:Address: 1.15.225.173):
|
||||
|
||||
```bash
|
||||
nslookup lzwlab.cn
|
||||
nslookup www.lzwlab.cn
|
||||
```
|
||||
|
||||
## 2. 安装 nginx
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y nginx
|
||||
```
|
||||
|
||||
结果:安装 nginx 1.24.0-2ubuntu7.17(nginx + nginx-common)。
|
||||
安装完成后 systemd 已自动启动 nginx 并注册开机自启(默认站点为 /var/www/html 的欢迎页)。
|
||||
|
||||
## 3. 创建站点内容
|
||||
|
||||
本地创建 `index.html`(欢迎页,中文标题“欢迎访问 lzwlab.cn”,1219 字节),
|
||||
通过 SFTP 上传到服务器 /tmp,再用 root 安装到站点目录:
|
||||
|
||||
```bash
|
||||
# 本地:上传文件
|
||||
# (Paramiko SFTP: index.html -> /tmp/lzwlab-index.html)
|
||||
|
||||
# 远程:
|
||||
sudo mkdir -p /var/www/lzwlab.cn
|
||||
sudo install -m 644 -o root -g root /tmp/lzwlab-index.html /var/www/lzwlab.cn/index.html
|
||||
```
|
||||
|
||||
页面内容要点(详见仓库内 `index.html`):
|
||||
|
||||
```html
|
||||
<h1>🎉 欢迎访问 lzwlab.cn</h1>
|
||||
<span class="badge">Powered by Nginx</span>
|
||||
```
|
||||
|
||||
> 后续变更:2026-08-26 按需求移除了页面中的 `<p>网站搭建成功!这是部署在个人云服务器上的第一个页面。</p>` 提示信息(详见 CHANGELOG)。
|
||||
|
||||
## 4. 配置 nginx 站点
|
||||
|
||||
创建站点配置 `/etc/nginx/sites-available/lzwlab.cn`(内容同仓库内 `lzwlab.nginx.conf`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name lzwlab.cn www.lzwlab.cn;
|
||||
|
||||
root /var/www/lzwlab.cn;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
启用站点并移除默认站点:
|
||||
|
||||
```bash
|
||||
# 上传配置
|
||||
# (Paramiko SFTP: lzwlab.nginx.conf -> /tmp/lzwlab.conf)
|
||||
|
||||
# 远程:
|
||||
sudo install -m 644 -o root -g root /tmp/lzwlab.conf /etc/nginx/sites-available/lzwlab.cn
|
||||
sudo ln -sf /etc/nginx/sites-available/lzwlab.cn /etc/nginx/sites-enabled/lzwlab.cn
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo rm -f /tmp/lzwlab-index.html /tmp/lzwlab.conf
|
||||
|
||||
# 校验配置语法
|
||||
sudo nginx -t
|
||||
# 结果:syntax is ok / test is successful
|
||||
```
|
||||
|
||||
## 5. 启动 / 加载配置
|
||||
|
||||
```bash
|
||||
sudo systemctl enable nginx
|
||||
sudo systemctl start nginx
|
||||
# 注意:apt 安装时 nginx 已启动并加载了默认配置,
|
||||
# 所以修改配置后必须 reload 才会生效:
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
检查监听状态(结果:0.0.0.0:80 与 [::]:80 均已监听):
|
||||
|
||||
```bash
|
||||
sudo systemctl is-active nginx # active
|
||||
ss -tlnp | grep :80
|
||||
```
|
||||
|
||||
## 6. 验证
|
||||
|
||||
服务器本机验证:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "status=%{http_code} size=%{size_download}\n" http://127.0.0.1/ -H "Host: lzwlab.cn"
|
||||
# status=200 size=1219
|
||||
curl -s http://127.0.0.1/ -H "Host: lzwlab.cn" | grep -o "<title>.*</title>"
|
||||
# <title>欢迎访问 lzwlab.cn</title>
|
||||
```
|
||||
|
||||
本地(公网域名)验证:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "status=%{http_code} size=%{size_download}\n" http://lzwlab.cn/
|
||||
# status=200 size=1219
|
||||
curl -s http://lzwlab.cn/ | grep -o "<title>.*</title>"
|
||||
# <title>欢迎访问 lzwlab.cn</title>
|
||||
curl -s -o /dev/null -w "status=%{http_code}\n" http://www.lzwlab.cn/
|
||||
# status=200
|
||||
```
|
||||
|
||||
✅ 部署完成:http://lzwlab.cn 与 http://www.lzwlab.cn 均已正常展示欢迎页。
|
||||
|
||||
## 7. 部署 HTTPS 证书并配置 443 自动跳转
|
||||
|
||||
### 7.1 证书信息
|
||||
|
||||
本地证书目录 `lzwlab.cn_nginx/`:
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| `lzwlab.cn.key` | RSA 私钥(1700 字节) |
|
||||
| `lzwlab.cn_bundle.crt` / `.pem` | 证书 + 证书链(两者内容相同) |
|
||||
| `lzwlab.cn.csr` | 证书签名请求(部署用不到) |
|
||||
|
||||
证书校验(本机 openssl):
|
||||
|
||||
```bash
|
||||
openssl x509 -in lzwlab.cn_nginx/lzwlab.cn_bundle.crt -noout -subject -issuer -dates
|
||||
# subject=CN=lzwlab.cn
|
||||
# issuer=TrustAsia DV TLS RSA CA 2024
|
||||
# 有效期:2026-08-24 ~ 2026-11-22
|
||||
openssl rsa -in lzwlab.cn_nginx/lzwlab.cn.key -check -noout # RSA key ok
|
||||
```
|
||||
|
||||
SAN 包含:`DNS:lzwlab.cn` 和 `DNS:www.lzwlab.cn`。
|
||||
|
||||
### 7.2 上传证书与配置到服务器
|
||||
|
||||
```bash
|
||||
# 本地:SFTP 上传到 /tmp
|
||||
# lzwlab.cn_nginx/lzwlab.cn.key -> /tmp/lzwlab.cn.key
|
||||
# lzwlab.cn_nginx/lzwlab.cn_bundle.crt -> /tmp/lzwlab.cn_bundle.crt
|
||||
# lzwlab.nginx.conf -> /tmp/lzwlab.conf
|
||||
|
||||
# 远程:安装证书/私钥并更新站点配置
|
||||
sudo mkdir -p /etc/nginx/ssl
|
||||
sudo install -m 600 -o root -g root /tmp/lzwlab.cn.key /etc/nginx/ssl/lzwlab.cn.key
|
||||
sudo install -m 644 -o root -g root /tmp/lzwlab.cn_bundle.crt /etc/nginx/ssl/lzwlab.cn_bundle.crt
|
||||
sudo cp /etc/nginx/sites-available/lzwlab.cn /etc/nginx/sites-available/lzwlab.cn.bak
|
||||
sudo install -m 644 -o root -g root /tmp/lzwlab.conf /etc/nginx/sites-available/lzwlab.cn
|
||||
sudo rm -f /tmp/lzwlab.cn.key /tmp/lzwlab.cn_bundle.crt /tmp/lzwlab.conf
|
||||
```
|
||||
|
||||
私钥权限 600、root:root;证书 644。旧配置备份为 `lzwlab.cn.bak`。
|
||||
|
||||
### 7.3 更新后的站点配置(`/etc/nginx/sites-available/lzwlab.cn`)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name lzwlab.cn www.lzwlab.cn;
|
||||
return 301 https://$host$request_uri; # HTTP 全部 301 跳转到 HTTPS
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name lzwlab.cn www.lzwlab.cn;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/lzwlab.cn_bundle.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/lzwlab.cn.key;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
root /var/www/lzwlab.cn;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 校验并加载
|
||||
|
||||
```bash
|
||||
sudo nginx -t # syntax is ok / test is successful
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 7.5 验证结果
|
||||
|
||||
服务器本机:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "http status=%{http_code} redirect=%{redirect_url}\n" http://127.0.0.1/ -H "Host: lzwlab.cn"
|
||||
# status=301 redirect=https://lzwlab.cn/
|
||||
curl -s https://127.0.0.1/ -k -H "Host: lzwlab.cn" | grep -o "<title>.*</title>"
|
||||
# <title>欢迎访问 lzwlab.cn</title>
|
||||
```
|
||||
|
||||
本地公网:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "status=%{http_code} -> %{redirect_url}\n" http://lzwlab.cn/
|
||||
# status=301 -> https://lzwlab.cn/
|
||||
curl -s -o /dev/null -w "status=%{http_code} ssl_verify=%{ssl_verify_result}\n" https://lzwlab.cn/
|
||||
# status=200 ssl_verify=0(证书链受信任)
|
||||
curl -s -o /dev/null -w "status=%{http_code}\n" https://www.lzwlab.cn/
|
||||
# status=200
|
||||
echo | openssl s_client -connect lzwlab.cn:443 -servername lzwlab.cn -alpn h2,http/1.1 2>/dev/null | grep "ALPN"
|
||||
# ALPN protocol: h2(HTTP/2 已启用)
|
||||
```
|
||||
|
||||
✅ https://lzwlab.cn 与 https://www.lzwlab.cn 已启用 HTTPS;http 访问会自动 301 跳转到 https。
|
||||
|
||||
## 8. 部署“文件快传”工具(/transfer/)
|
||||
|
||||
### 8.1 方案
|
||||
|
||||
- 后端:Flask 3.0.2 + gunicorn 20.1(apt 安装),监听 `127.0.0.1:8090`,systemd 服务 `lzwlab-transfer.service`。
|
||||
- 前端:单页 HTML(内嵌 CSS/JS),PC 与手机自适应,登录后可用。
|
||||
- 存储:上传文件保存在 `/var/lib/lzwlab-transfer/files/`,元数据在 SQLite `/var/lib/lzwlab-transfer/files.db`。
|
||||
- 配置:`/etc/lzwlab-transfer/config.json`(root:lzwlab 640),登录密码只保存 scrypt 哈希,明文经部署环境变量传入。
|
||||
- 清理:`lzwlab-transfer-cleanup.timer` 每 10 分钟执行 `cleanup.py` 删除过期文件;访问列表/手动清理时也会触发检查。
|
||||
- nginx:`/transfer/` 反向代理到 8090,`proxy_request_buffering off` + `proxy_buffering off` 保证大文件上传下载流式传输,`client_max_body_size 5120m`。
|
||||
|
||||
### 8.2 部署命令(本地运维机)
|
||||
|
||||
```bash
|
||||
# 依赖脚本见仓库 deploy_transfer.py;密码经环境变量传入,不会写入仓库
|
||||
LZWLAB_APP_PASSWORD='$PASS' python deploy_transfer.py --username admin
|
||||
```
|
||||
|
||||
脚本会依次完成:apt 安装依赖 → 创建系统用户 `lzwlab` 与目录 → 上传/安装应用、配置、systemd 单元 → 启动服务与清理定时器 → 校验并 reload nginx。重复执行幂等。
|
||||
|
||||
### 8.3 验证结果
|
||||
|
||||
```bash
|
||||
systemctl is-active lzwlab-transfer.service lzwlab-transfer-cleanup.timer # active active
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/transfer/ # 200(登录页)
|
||||
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://lzwlab.cn/transfer/ # 301 -> https
|
||||
```
|
||||
|
||||
公网 API 实测:登录成功返回会话 Cookie;未登录访问 `/api/files` 返回 401;上传 25 字节测试文件后列表可查,下载返回 200 且支持 `Range`(206);删除后列表为空;将测试文件过期时间强制置为过去并启动清理服务后,日志输出“清理完成:过期文件 1 个”,列表随即清空。
|
||||
|
||||
### 8.4 踩坑
|
||||
|
||||
- `deploy_transfer.py` 在 Windows 本地生成远端路径时不能使用 `os.path.join`(会把 `/` 变成 `\`),SQLite 路径必须手工拼接 Linux 路径,否则服务报 `unable to open database file`。
|
||||
- gunicorn 手动排障时不要在 SSH 会话里前台运行(会话断开会留下占端口的孤儿进程),应直接通过 `systemctl` 管理;排障后可 `sudo pkill -u lzwlab -f gunicorn && sudo systemctl restart lzwlab-transfer.service`。
|
||||
- Windows 下 curl 的 `-F 'file=@...;filename=中文名'` 对非 ASCII 文件名支持不佳,测试时用 ASCII 文件名,浏览器上传不受影响。
|
||||
|
||||
## 9. 部署 RustDesk 自建服务器(Docker)
|
||||
|
||||
### 9.1 方案
|
||||
|
||||
- 组件:RustDesk Server OSS 版两个容器——hbbs(ID/注册服务器)与 hbbr(中继服务器),镜像 `rustdesk/rustdesk-server:latest`。
|
||||
- 网络:`network_mode: host`(官方推荐,无需 `-p` 映射),数据卷 `./data` 挂载到容器 `/root`(密钥、SQLite 库)。
|
||||
- 端口(防火墙需放行):
|
||||
- hbbs:21115/TCP(NAT 类型测试)、21116/TCP+UDP(UDP 用于 ID 注册与心跳,TCP 用于打洞与连接服务)、21118/TCP(网页客户端);
|
||||
- hbbr:21117/TCP(中继服务)、21119/TCP(网页客户端);
|
||||
- 21114/TCP 网页控制台仅 Pro 版需要,OSS 版不监听。
|
||||
- 参考文档:https://rustdesk.com/docs/zh-cn/self-host/rustdesk-server-oss/docker/ ;客户端配置:https://rustdesk.com/docs/zh-cn/self-host/client-configuration/
|
||||
|
||||
### 9.2 安装 Docker 并配置镜像加速
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io docker-compose-v2
|
||||
sudo systemctl enable --now docker
|
||||
docker --version # Docker version 29.1.3(apt docker.io 自带守护进程服务)
|
||||
```
|
||||
|
||||
踩坑:腾讯云访问 Docker Hub 被拒(`registry-1.docker.io` connection refused),必须配置镜像加速:
|
||||
|
||||
```bash
|
||||
echo '{"registry-mirrors":["https://docker.m.daocloud.io"]}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
### 9.3 部署 hbbs/hbbr
|
||||
|
||||
Compose 文件为仓库内 `rustdesk/docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
hbbs:
|
||||
container_name: hbbs
|
||||
image: rustdesk/rustdesk-server:latest
|
||||
command: hbbs
|
||||
volumes:
|
||||
- ./data:/root
|
||||
network_mode: "host"
|
||||
depends_on:
|
||||
- hbbr
|
||||
restart: unless-stopped
|
||||
hbbr:
|
||||
container_name: hbbr
|
||||
image: rustdesk/rustdesk-server:latest
|
||||
command: hbbr
|
||||
volumes:
|
||||
- ./data:/root
|
||||
network_mode: "host"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
部署命令:
|
||||
|
||||
```bash
|
||||
# 本地:SFTP 上传 rustdesk/docker-compose.yml 到 /tmp
|
||||
sudo mkdir -p /opt/rustdesk
|
||||
sudo install -m 644 -o root -g root /tmp/rustdesk-compose.yml /opt/rustdesk/compose.yml
|
||||
cd /opt/rustdesk && sudo docker compose up -d
|
||||
```
|
||||
|
||||
验证结果:
|
||||
|
||||
```bash
|
||||
sudo docker ps # hbbs、hbbr 均 Up
|
||||
sudo ss -tlnup | grep -E '2111[4-9]'
|
||||
# tcp 21115/21116、udp 21116、tcp 21118 归 hbbs;tcp 21117/21119 归 hbbr
|
||||
sudo docker logs --tail 5 hbbs
|
||||
# Key: UB1fNGcVYA9NTIEA7ra3Nm0R6o2m64lNQE24L7CIAkY=
|
||||
# Listening on tcp/udp :21116 / Listening on tcp :21115 / Listening on websocket :21118
|
||||
```
|
||||
|
||||
### 9.4 客户端配置
|
||||
|
||||
RustDesk 客户端(设置 → 网络,点击“解锁”后可编辑)填写:
|
||||
|
||||
| 项目 | 值 |
|
||||
| --- | --- |
|
||||
| ID 服务器 | `lzwlab.cn`(hbbs,端口 21116) |
|
||||
| 中继服务器 | 留空自动推导;或填 `lzwlab.cn`(hbbr,端口 21117) |
|
||||
| Key | `UB1fNGcVYA9NTIEA7ra3Nm0R6o2m64lNQE24L7CIAkY=` |
|
||||
| API 服务器 | 留空(仅 Pro 版需要) |
|
||||
|
||||
- Key 由 hbbs 首次启动自动生成,保存在服务器 `/opt/rustdesk/data/id_ed25519.pub`(容器内 `/root/id_ed25519.pub`),重建容器会复用;Key 需分发给每个客户端。
|
||||
- 连通性:本地已验证 21115-21119 TCP 公网可达;hbbs 日志可见外部客户端注册记录。UDP 21116 用于 ID 注册与心跳,若腾讯云控制台防火墙未放行 UDP 21116,客户端将无法注册。
|
||||
|
||||
### 9.5 运维
|
||||
|
||||
```bash
|
||||
# 更新镜像并重建
|
||||
cd /opt/rustdesk && sudo docker compose pull && sudo docker compose up -d
|
||||
# 查看日志
|
||||
sudo docker logs -f hbbs
|
||||
sudo docker logs -f hbbr
|
||||
# 查看密钥
|
||||
sudo cat /opt/rustdesk/data/id_ed25519.pub
|
||||
```
|
||||
|
||||
## 10. 部署 Gitea Git 服务(/git/)
|
||||
|
||||
### 10.1 方案
|
||||
|
||||
- 组件:Gitea 1.27.2 官方镜像 `docker.gitea.com/gitea:1.27.2`(Docker Compose,容器名 `gitea`),数据库 SQLite,数据卷 `./gitea:/data`(持久化在服务器 `/opt/gitea/gitea/`)。
|
||||
- 入口:服务器没有 `git.lzwlab.cn` 子域名解析,按 Gitea 官方反向代理文档的 sub-path 方案部署在 `https://lzwlab.cn/git/`(`ROOT_URL=https://lzwlab.cn/git/`)。
|
||||
- 端口:Web 仅监听 `127.0.0.1:3000`(不对公网直接暴露),由 nginx 将 `/git/` 反代到 3000;SSH 端口与主机 sshd 冲突,容器内 22 映射到主机 2222。
|
||||
- 账号:管理员私有单实例——关闭开放注册(`DISABLE_REGISTRATION=true`)、锁定安装器(`INSTALL_LOCK=true`),管理员 `admin`(密码见 Authentication.md)。
|
||||
- 参考文档:https://docs.gitea.com/installation/install-with-docker/ 与 https://docs.gitea.com/administration/reverse-proxies/ (“Nginx with a sub-path”)。
|
||||
|
||||
### 10.2 部署命令
|
||||
|
||||
Compose 文件为仓库内 `gitea/docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
# 本地:SFTP 上传 gitea/docker-compose.yml 到 /tmp
|
||||
sudo mkdir -p /opt/gitea
|
||||
sudo install -m 644 -o root -g root /tmp/gitea-compose.yml /opt/gitea/compose.yml
|
||||
sudo rm -f /tmp/gitea-compose.yml
|
||||
cd /opt/gitea && sudo docker compose up -d
|
||||
```
|
||||
|
||||
创建管理员并设置站点名:
|
||||
|
||||
```bash
|
||||
# 管理员(密码经变量传入,不写入仓库;首次启动完成后执行)
|
||||
sudo docker exec -u git gitea gitea admin user create \
|
||||
--username admin --password '$PASS' --email admin@lzwlab.cn \
|
||||
--admin --must-change-password=false
|
||||
# 站点名写入 app.ini 的 [DEFAULT] 段后重建容器
|
||||
sudo sed -i '1s/.*/APP_NAME = lzwlab Git 服务/' /opt/gitea/gitea/gitea/conf/app.ini
|
||||
cd /opt/gitea && sudo docker compose up -d
|
||||
```
|
||||
|
||||
### 10.3 nginx 反代(/git/)
|
||||
|
||||
按官方 “Nginx with a sub-path” 方案(保持 `%2F` 原样并剥离 `/git` 前缀),在 443 server 块新增:
|
||||
|
||||
```nginx
|
||||
location ~ ^/(git)($|/) {
|
||||
client_max_body_size 512M;
|
||||
rewrite ^ $request_uri;
|
||||
rewrite ^/(git($|/))?(.*) /$3 break;
|
||||
proxy_pass http://127.0.0.1:3000$uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection $http_connection;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
```
|
||||
|
||||
该配置已并入仓库内 `lzwlab.nginx.conf`,服务器 `/etc/nginx/sites-available/lzwlab.cn` 同步更新(改前自动备份 `lzwlab.cn.bak-YYYYMMDD-HHMMSS`),`sudo nginx -t` 通过后 reload。
|
||||
|
||||
### 10.4 验证结果
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/git/ # 200
|
||||
curl -s https://lzwlab.cn/git/api/v1/version # {"version":"1.27.2"}
|
||||
curl -s https://lzwlab.cn/git/ | grep -o '<title>.*</title>' # <title>lzwlab Git 服务</title>
|
||||
curl -s https://lzwlab.cn/ | grep -o '进入 Git 服务' # 进入 Git 服务(首页按钮已生效)
|
||||
# git 智能 HTTP 协议(创建测试仓库 hello 后):
|
||||
curl -s 'https://lzwlab.cn/git/admin/hello.git/info/refs?service=git-upload-pack'
|
||||
# 返回 service=git-upload-pack 与 HEAD 引用;测试仓库与临时令牌验证后已删除
|
||||
```
|
||||
|
||||
关键配置项(`/opt/gitea/gitea/gitea/conf/app.ini`):`ROOT_URL=https://lzwlab.cn/git/`、`DOMAIN/SSH_DOMAIN=lzwlab.cn`、`SSH_PORT=2222`、`SSH_LISTEN_PORT=22`(容器内)、`INSTALL_LOCK=true`、`DISABLE_REGISTRATION=true`、`APP_NAME=lzwlab Git 服务`。
|
||||
|
||||
### 10.5 使用与运维
|
||||
|
||||
- 克隆地址:
|
||||
- HTTPS(公网可用):`https://lzwlab.cn/git/<用户名>/<仓库>.git`;
|
||||
- SSH(需先放行安全组,见 10.6):`ssh://git@lzwlab.cn:2222/<用户名>/<仓库>.git`。
|
||||
- 运维命令:
|
||||
|
||||
```bash
|
||||
sudo docker compose -f /opt/gitea/compose.yml ps # 容器状态
|
||||
sudo docker logs -f gitea # 日志
|
||||
cd /opt/gitea && sudo docker compose pull && sudo docker compose up -d # 升级镜像
|
||||
sudo docker exec -u git gitea gitea admin user list # 用户列表
|
||||
```
|
||||
|
||||
- 备份:SQLite 库、仓库与配置全部在数据卷内,直接备份 `/opt/gitea/gitea/` 即可。
|
||||
|
||||
### 10.6 踩坑
|
||||
|
||||
- `GITEA__APP_NAME` 环境变量传入容器后未生效(站点名仍显示默认值),站点名需直接写入 `app.ini` 的 `[DEFAULT]` 段(`APP_NAME = ...`)并重建容器。
|
||||
- 腾讯云安全组只放行 22/443/21115-21119,主机映射的 2222 端口公网不可达(本机探测 TIMEOUT,服务器本地可达):SSH 克隆需在腾讯云控制台为 TCP 2222 添加入站规则;HTTPS 克隆与网页访问不受影响。
|
||||
- Gitea 子路径部署必须同时满足两点:`ROOT_URL` 带 `/git/` 后缀,且 nginx 使用官方 rewrite 方案剥离前缀(直接 `proxy_pass http://127.0.0.1:3000/;` 会破坏前缀与 `%2F` 转义)。
|
||||
- 镜像使用官方新仓库 `docker.gitea.com/gitea:1.27.2`(腾讯云可直连,无需镜像加速);若改用 Docker Hub 的 `gitea/gitea` 则会走 daocloud 加速。
|
||||
|
||||
## 附:踩坑记录
|
||||
|
||||
|
||||
1. 本地 Git Bash 调用 Windows Python 时,MSYS 会把以 `/` 开头的命令行参数(如 `/tmp/...`)转换成 Windows 路径,
|
||||
导致 SFTP 上传远端路径错误(ENOENT)。解决:调用前加 `MSYS_NO_PATHCONV=1`,或把路径写进脚本内。
|
||||
2. apt 安装 nginx 会自动启动服务并加载默认站点配置;改完站点配置后 `systemctl start` 不会重新加载,
|
||||
必须 `systemctl reload nginx`(或 `restart`),否则看到的仍是默认欢迎页。
|
||||
3. 该服务器上的 nginx 1.24(Ubuntu 构建)不识别独立指令 `http2 on;`,`nginx -t` 报 unknown directive "http2";
|
||||
启用 HTTP/2 应写成 `listen 443 ssl http2;`。
|
||||
4. 腾讯云服务器访问 Docker Hub 被拒(pull 报 connection refused),通过 `/etc/docker/daemon.json` 配置
|
||||
`registry-mirrors`(docker.m.daocloud.io)后解决。
|
||||
5. `remote.py` 的 `--timeout` 参数必须放在远程命令之前(`python remote.py --timeout 500 "命令"`);
|
||||
放在命令之后会被 argparse 当作远程命令的一部分传给服务器执行。
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
"""lzwlab.cn 文件快传 —— Flask 应用入口。
|
||||
|
||||
生产环境由 gunicorn 启动:
|
||||
gunicorn --workers 1 --threads 4 --bind 127.0.0.1:8090 app:app
|
||||
"""
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import (Flask, jsonify, redirect, render_template, request,
|
||||
send_file, session)
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
|
||||
import auth
|
||||
import storage
|
||||
|
||||
cfg = auth.load_config()
|
||||
FILES_DIR = cfg["files_dir"]
|
||||
DB_PATH = cfg["database_path"]
|
||||
USERS = cfg["users"]
|
||||
MIN_EXPIRES_SECONDS = 3600 # 最短 1 小时
|
||||
MAX_EXPIRES_SECONDS = 30 * 24 * 3600 # 最长 30 天
|
||||
DEFAULT_EXPIRES_HOURS = 168 # 默认 7 天
|
||||
|
||||
os.makedirs(FILES_DIR, exist_ok=True)
|
||||
storage.init_db(DB_PATH)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.update(
|
||||
SECRET_KEY=cfg["secret_key"],
|
||||
MAX_CONTENT_LENGTH=cfg["max_content_length"],
|
||||
SESSION_COOKIE_NAME="lzwlab_transfer_session",
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
SESSION_COOKIE_SECURE=cfg["cookie_secure"],
|
||||
SESSION_COOKIE_PATH=cfg.get("cookie_path", "/transfer/"),
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(days=cfg["session_days"]),
|
||||
)
|
||||
|
||||
login_limiter = auth.RateLimiter(max_failures=10, window_seconds=900)
|
||||
|
||||
|
||||
def client_ip() -> str:
|
||||
fwd = request.headers.get("X-Forwarded-For", "")
|
||||
if fwd:
|
||||
return fwd.split(",")[0].strip()
|
||||
return request.remote_addr or "unknown"
|
||||
|
||||
|
||||
def require_xhr():
|
||||
if request.headers.get("X-Requested-With", "").lower() != "xmlhttprequest":
|
||||
return jsonify(ok=False, error="非法请求:缺少 X-Requested-With"), 400
|
||||
|
||||
|
||||
def login_required(fn):
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = session.get("user")
|
||||
if not user or user not in USERS:
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify(ok=False, error="未登录或会话已过期"), 401
|
||||
return redirect("/")
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
@app.after_request
|
||||
def harden(response):
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
if request.path.startswith("/api/"):
|
||||
response.headers.setdefault("Cache-Control", "no-store")
|
||||
if request.endpoint == "index":
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self' 'unsafe-inline'; connect-src 'self'; "
|
||||
"font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.errorhandler(RequestEntityTooLarge)
|
||||
def too_large(_err):
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify(ok=False, error="文件大小超过允许上限"), 413
|
||||
return "文件过大", 413
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(err):
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify(ok=False, error="资源不存在"), 404
|
||||
return err
|
||||
|
||||
|
||||
def _parse_expires_hours(value) -> int:
|
||||
try:
|
||||
hours = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("有效期格式不正确")
|
||||
if not (1 <= hours <= 720):
|
||||
raise ValueError("有效期最短 1 小时,最长 30 天(720 小时)")
|
||||
seconds = int(round(hours * 3600))
|
||||
if not (MIN_EXPIRES_SECONDS <= seconds <= MAX_EXPIRES_SECONDS):
|
||||
raise ValueError("有效期超出允许范围")
|
||||
return seconds
|
||||
|
||||
|
||||
def _clean_name(raw_name) -> str:
|
||||
name = str(raw_name or "").strip() or "未命名文件"
|
||||
name = name.replace("\\", "/").split("/")[-1]
|
||||
name = re.sub(r"[\x00-\x1f\x7f]", "", name)
|
||||
return (name[:200] or "未命名文件")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@app.get("/api/session")
|
||||
def api_session():
|
||||
user = session.get("user")
|
||||
if user and user in USERS:
|
||||
return jsonify(logged_in=True, user=user)
|
||||
return jsonify(logged_in=False)
|
||||
|
||||
|
||||
@app.post("/api/login")
|
||||
def api_login():
|
||||
ip = client_ip()
|
||||
if login_limiter.is_blocked(ip):
|
||||
return jsonify(ok=False, error="尝试次数过多,请 15 分钟后再试"), 429
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = str(data.get("username", "")).strip()
|
||||
password = str(data.get("password", ""))
|
||||
user = USERS.get(username)
|
||||
ok = bool(user) and auth.verify_password(password, user.get("password_hash", ""))
|
||||
if not ok:
|
||||
login_limiter.record_failure(ip)
|
||||
time.sleep(0.3)
|
||||
return jsonify(ok=False, error="用户名或密码错误"), 401
|
||||
login_limiter.reset(ip)
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session["user"] = username
|
||||
return jsonify(ok=True, user=username)
|
||||
|
||||
|
||||
@app.post("/api/logout")
|
||||
def api_logout():
|
||||
session.clear()
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@app.post("/api/upload")
|
||||
@login_required
|
||||
def api_upload():
|
||||
bad = require_xhr()
|
||||
if bad:
|
||||
return bad
|
||||
if "file" not in request.files:
|
||||
return jsonify(ok=False, error="未选择文件"), 400
|
||||
upload = request.files["file"]
|
||||
if upload is None or upload.filename is None:
|
||||
return jsonify(ok=False, error="未选择文件"), 400
|
||||
try:
|
||||
expires_seconds = _parse_expires_hours(request.form.get("expires_hours", DEFAULT_EXPIRES_HOURS))
|
||||
except ValueError as exc:
|
||||
return jsonify(ok=False, error=str(exc)), 400
|
||||
|
||||
file_id = secrets.token_hex(8)
|
||||
stored_path = os.path.join(FILES_DIR, file_id)
|
||||
name = _clean_name(upload.filename)
|
||||
try:
|
||||
upload.save(stored_path)
|
||||
except RequestEntityTooLarge:
|
||||
raise
|
||||
except Exception:
|
||||
app.logger.exception("upload save failed: %s", file_id)
|
||||
try:
|
||||
os.remove(stored_path)
|
||||
except OSError:
|
||||
pass
|
||||
return jsonify(ok=False, error="上传失败,请重试"), 500
|
||||
|
||||
size = os.path.getsize(stored_path)
|
||||
now = int(time.time())
|
||||
storage.insert_file(DB_PATH, file_id, name, stored_path, size, now + expires_seconds, now)
|
||||
return jsonify(ok=True, file={
|
||||
"id": file_id,
|
||||
"name": name,
|
||||
"size": size,
|
||||
"created_at": storage.iso(now),
|
||||
"expires_at": storage.iso(now + expires_seconds),
|
||||
"remaining_seconds": expires_seconds,
|
||||
"downloads": 0,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/files")
|
||||
@login_required
|
||||
def api_files():
|
||||
now = int(time.time())
|
||||
storage.cleanup_expired(DB_PATH, FILES_DIR, now=now)
|
||||
return jsonify(files=storage.list_files(DB_PATH, now))
|
||||
|
||||
|
||||
@app.get("/api/download/<file_id>")
|
||||
@login_required
|
||||
def api_download(file_id):
|
||||
now = int(time.time())
|
||||
row = storage.get_file(DB_PATH, file_id)
|
||||
if row is None:
|
||||
return jsonify(ok=False, error="文件不存在或已过期"), 404
|
||||
if row["expires_at"] <= now:
|
||||
storage.cleanup_expired(DB_PATH, FILES_DIR, now=now)
|
||||
return jsonify(ok=False, error="文件已过期"), 404
|
||||
if not os.path.isfile(row["stored_path"]):
|
||||
storage.delete_file(DB_PATH, FILES_DIR, file_id)
|
||||
return jsonify(ok=False, error="缓存文件已丢失"), 404
|
||||
storage.increment_downloads(DB_PATH, file_id)
|
||||
return send_file(
|
||||
row["stored_path"],
|
||||
as_attachment=True,
|
||||
download_name=row["name"],
|
||||
conditional=True,
|
||||
etag=True,
|
||||
max_age=0,
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/files/<file_id>")
|
||||
@login_required
|
||||
def api_delete_file(file_id):
|
||||
bad = require_xhr()
|
||||
if bad:
|
||||
return bad
|
||||
ok = storage.delete_file(DB_PATH, FILES_DIR, file_id)
|
||||
if not ok:
|
||||
return jsonify(ok=False, error="文件不存在"), 404
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@app.post("/api/cleanup")
|
||||
@login_required
|
||||
def api_cleanup():
|
||||
bad = require_xhr()
|
||||
if bad:
|
||||
return bad
|
||||
now = int(time.time())
|
||||
removed = storage.cleanup_expired(DB_PATH, FILES_DIR, now=now)
|
||||
orphans = storage.cleanup_orphans(DB_PATH, FILES_DIR, now=now)
|
||||
return jsonify(ok=True, removed=removed, orphans=orphans)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=8090, debug=False)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""认证、配置加载与登录限流工具(仅依赖标准库)。"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
||||
DEFAULT_CONFIG_PATH = os.environ.get("LZWLAB_TRANSFER_CONFIG", "/etc/lzwlab-transfer/config.json")
|
||||
|
||||
_SCRYPT_N = 2 ** 14
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
|
||||
|
||||
def _b64encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64decode(text: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""返回 scrypt 密码哈希,格式:scrypt$N$r$p$salt_b64$hash_b64"""
|
||||
if not isinstance(password, str) or not password:
|
||||
raise ValueError("密码不能为空")
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.scrypt(
|
||||
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32
|
||||
)
|
||||
return "scrypt$%d$%d$%d$%s$%s" % (
|
||||
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _b64encode(salt), _b64encode(digest)
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
parts = stored.split("$")
|
||||
if len(parts) != 6 or parts[0] != "scrypt":
|
||||
return False
|
||||
_, n_s, r_s, p_s, salt_s, digest_s = parts
|
||||
n, r, p = int(n_s), int(r_s), int(p_s)
|
||||
digest = hashlib.scrypt(
|
||||
password.encode("utf-8"), salt=_b64decode(salt_s), n=n, r=r, p=p, dklen=32
|
||||
)
|
||||
return hmac.compare_digest(digest, _b64decode(digest_s))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def load_config(path: str | None = None) -> dict:
|
||||
path = path or DEFAULT_CONFIG_PATH
|
||||
if not os.path.exists(path):
|
||||
raise RuntimeError("配置文件不存在:%s" % path)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
cfg = json.load(fh)
|
||||
if not isinstance(cfg.get("users"), dict) or not cfg["users"]:
|
||||
raise RuntimeError("配置缺少 users")
|
||||
if not cfg.get("secret_key") or len(str(cfg["secret_key"])) < 32:
|
||||
raise RuntimeError("配置缺少 secret_key(至少 32 字符)")
|
||||
data_dir = cfg.get("data_dir", "/var/lib/lzwlab-transfer")
|
||||
cfg["files_dir"] = os.path.join(data_dir, "files")
|
||||
cfg["database_path"] = cfg.get("database_path", os.path.join(data_dir, "files.db"))
|
||||
cfg["max_content_length"] = int(cfg.get("max_content_length", 5 * 1024 ** 3))
|
||||
cfg["cookie_secure"] = bool(cfg.get("cookie_secure", True))
|
||||
cfg["session_days"] = int(cfg.get("session_days", 7))
|
||||
return cfg
|
||||
|
||||
|
||||
def random_secret() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""基于内存的按 IP 登录失败限流。"""
|
||||
|
||||
def __init__(self, max_failures: int = 10, window_seconds: int = 900):
|
||||
self.max_failures = max_failures
|
||||
self.window_seconds = window_seconds
|
||||
self._lock = threading.Lock()
|
||||
self._failures: dict[str, list[float]] = {}
|
||||
|
||||
def _prune(self, ip: str, now: float) -> None:
|
||||
self._failures[ip] = [t for t in self._failures.get(ip, []) if now - t < self.window_seconds]
|
||||
|
||||
def is_blocked(self, ip: str) -> bool:
|
||||
with self._lock:
|
||||
self._prune(ip, time.monotonic())
|
||||
return len(self._failures.get(ip, [])) >= self.max_failures
|
||||
|
||||
def record_failure(self, ip: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.setdefault(ip, []).append(time.monotonic())
|
||||
self._prune(ip, time.monotonic())
|
||||
|
||||
def reset(self, ip: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.pop(ip, None)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""手动/定时清理过期文件与孤儿缓存文件。"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
import auth
|
||||
import storage
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("lzwlab-cleanup")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cfg = auth.load_config()
|
||||
files_dir = cfg["files_dir"]
|
||||
db_path = cfg["database_path"]
|
||||
storage.init_db(db_path)
|
||||
now = int(time.time())
|
||||
removed = storage.cleanup_expired(db_path, files_dir, now=now)
|
||||
orphans = storage.cleanup_orphans(db_path, files_dir, now=now)
|
||||
log.info("清理完成:过期文件 %d 个,孤儿缓存 %d 个", removed, orphans)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,174 @@
|
||||
"""文件元数据与磁盘缓存的存储层(SQLite + 文件目录,仅标准库)。"""
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_DB_LOCK = threading.Lock()
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
downloads INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def connect(db_path: str) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(db_path, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(db_path: str) -> None:
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def iso(ts: int) -> str:
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def file_to_dict(row: sqlite3.Row, now: int) -> dict:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"size": row["size"],
|
||||
"created_at": iso(row["created_at"]),
|
||||
"expires_at": iso(row["expires_at"]),
|
||||
"remaining_seconds": max(0, row["expires_at"] - now),
|
||||
"downloads": row["downloads"],
|
||||
}
|
||||
|
||||
|
||||
def insert_file(db_path: str, file_id: str, name: str, stored_path: str,
|
||||
size: int, expires_at: int, created_at: int) -> None:
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO files (id, name, stored_path, size, created_at, expires_at, downloads) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, 0)",
|
||||
(file_id, name, stored_path, size, created_at, expires_at),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_file(db_path: str, file_id: str):
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
return conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_files(db_path: str, now: int) -> list[dict]:
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM files WHERE expires_at > ? ORDER BY created_at DESC", (now,)
|
||||
).fetchall()
|
||||
return [file_to_dict(r, now) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def increment_downloads(db_path: str, file_id: str) -> None:
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
conn.execute("UPDATE files SET downloads = downloads + 1 WHERE id = ?", (file_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_file(db_path: str, files_dir: str, file_id: str) -> bool:
|
||||
"""删除数据库记录并移除磁盘文件,返回是否删除过数据库记录。"""
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id, stored_path FROM files WHERE id = ?", (file_id,)).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
os.remove(row["stored_path"])
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def cleanup_expired(db_path: str, files_dir: str, now: int | None = None) -> int:
|
||||
now = now if now is not None else int(__import__("time").time())
|
||||
removed = 0
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, stored_path FROM files WHERE expires_at <= ?", (now,)
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
os.remove(row["stored_path"])
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
continue # 磁盘文件暂时删不掉时保留记录,下次重试
|
||||
conn.execute("DELETE FROM files WHERE id = ?", (row["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return removed
|
||||
|
||||
|
||||
def cleanup_orphans(db_path: str, files_dir: str, now: int | None = None,
|
||||
min_age_seconds: int = 172800) -> int:
|
||||
"""清理磁盘上不在数据库且超过 48 小时未修改的孤儿文件。"""
|
||||
import time as _time
|
||||
now = now if now is not None else int(_time.time())
|
||||
removed = 0
|
||||
with _DB_LOCK:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
ids = {r["id"] for r in conn.execute("SELECT id FROM files").fetchall()}
|
||||
finally:
|
||||
conn.close()
|
||||
if not os.path.isdir(files_dir):
|
||||
return 0
|
||||
for name in os.listdir(files_dir):
|
||||
if name in ids:
|
||||
continue
|
||||
path = os.path.join(files_dir, name)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
if now - os.path.getmtime(path) >= min_age_seconds:
|
||||
os.remove(path)
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=lzwlab.cn file transfer cleanup (expired files)
|
||||
After=local-fs.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=lzwlab
|
||||
Group=lzwlab
|
||||
Environment=LZWLAB_TRANSFER_CONFIG=/etc/lzwlab-transfer/config.json
|
||||
ExecStart=/usr/bin/python3 /opt/lzwlab-transfer/cleanup.py
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Cleanup expired files every 10 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=1min
|
||||
OnUnitActiveSec=10min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=lzwlab.cn file transfer web app
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=lzwlab
|
||||
Group=lzwlab
|
||||
WorkingDirectory=/opt/lzwlab-transfer
|
||||
Environment=LZWLAB_TRANSFER_CONFIG=/etc/lzwlab-transfer/config.json
|
||||
ExecStart=/usr/bin/gunicorn --workers 1 --threads 4 --timeout 3600 --graceful-timeout 30 --bind 127.0.0.1:8090 --access-logfile /var/log/lzwlab-transfer/access.log --error-logfile /var/log/lzwlab-transfer/error.log app:app
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,917 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#667eea">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>文件快传 · lzwlab.cn</title>
|
||||
<style>
|
||||
:root{
|
||||
--primary:#4f6ef7;
|
||||
--primary-strong:#3b57e0;
|
||||
--grad:linear-gradient(135deg,#667eea 0%,#764ba2 100%);
|
||||
--bg:#f5f6fa;
|
||||
--card:#ffffff;
|
||||
--text:#1f2430;
|
||||
--muted:#6b7280;
|
||||
--border:#e5e7eb;
|
||||
--border-strong:#d1d5db;
|
||||
--success:#16a34a;
|
||||
--danger:#dc2626;
|
||||
--warning:#d97706;
|
||||
--shadow:0 4px 16px rgba(31,36,48,.06);
|
||||
--shadow-lg:0 12px 32px rgba(31,36,48,.12);
|
||||
--focus:0 0 0 3px rgba(79,110,247,.28);
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root{
|
||||
--bg:#12141a;
|
||||
--card:#1c1f27;
|
||||
--text:#eef0f5;
|
||||
--muted:#9aa1ad;
|
||||
--border:#2c3039;
|
||||
--border-strong:#3a3f4b;
|
||||
--shadow:0 4px 16px rgba(0,0,0,.35);
|
||||
--shadow-lg:0 12px 32px rgba(0,0,0,.5);
|
||||
}
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{min-height:100%}
|
||||
body{
|
||||
background:var(--bg);
|
||||
color:var(--text);
|
||||
font-family:"PingFang SC","Microsoft YaHei","Segoe UI",system-ui,-apple-system,sans-serif;
|
||||
font-size:16px;
|
||||
line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.hidden{display:none !important}
|
||||
button,input,select{font:inherit;color:inherit}
|
||||
button{-webkit-tap-highlight-color:transparent}
|
||||
button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:none;box-shadow:var(--focus)}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",Menlo,monospace;font-variant-numeric:tabular-nums}
|
||||
|
||||
/* ---------- 登录页 ---------- */
|
||||
.auth-wrap{
|
||||
min-height:100vh;min-height:100dvh;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
padding:16px;
|
||||
background:var(--grad);
|
||||
}
|
||||
.auth-card{
|
||||
width:100%;max-width:400px;
|
||||
background:var(--card);
|
||||
border-radius:16px;
|
||||
box-shadow:var(--shadow-lg);
|
||||
padding:32px 28px;
|
||||
text-align:center;
|
||||
}
|
||||
.logo{
|
||||
width:64px;height:64px;margin:0 auto 14px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:32px;
|
||||
border-radius:18px;
|
||||
background:var(--grad);
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
.auth-card h1{font-size:24px;font-weight:700;letter-spacing:.5px}
|
||||
.auth-card .sub{color:var(--muted);font-size:14px;margin:6px 0 24px}
|
||||
.field{display:block;text-align:left;margin-bottom:14px}
|
||||
.field span{display:block;font-size:14px;color:var(--muted);margin-bottom:6px}
|
||||
.field input,select{
|
||||
width:100%;min-height:44px;
|
||||
padding:10px 12px;
|
||||
border:1px solid var(--border);
|
||||
border-radius:10px;
|
||||
background:var(--card);
|
||||
transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.field input:focus,select:focus{border-color:var(--primary);outline:none;box-shadow:var(--focus)}
|
||||
.form-error{color:var(--danger);font-size:14px;min-height:20px;margin:2px 0 10px;text-align:left}
|
||||
.btn{
|
||||
display:inline-flex;align-items:center;justify-content:center;gap:8px;
|
||||
min-height:44px;padding:10px 18px;
|
||||
border:1px solid transparent;border-radius:10px;
|
||||
background:transparent;cursor:pointer;
|
||||
font-size:15px;font-weight:600;line-height:1.2;
|
||||
transition:filter .15s, transform .05s, background .15s, border-color .15s, color .15s;
|
||||
text-decoration:none;
|
||||
}
|
||||
.btn:active{transform:translateY(1px)}
|
||||
.btn:disabled{opacity:.55;cursor:not-allowed;filter:none}
|
||||
.btn-primary{background:var(--grad);color:#fff;box-shadow:var(--shadow)}
|
||||
.btn-primary:not(:disabled):hover{filter:brightness(1.06)}
|
||||
.btn-ghost{border-color:var(--border);color:var(--text);background:var(--card)}
|
||||
.btn-ghost:not(:disabled):hover{border-color:var(--border-strong)}
|
||||
.btn-danger{background:var(--danger);color:#fff}
|
||||
.btn-danger-ghost{border-color:var(--danger);color:var(--danger);background:transparent}
|
||||
.btn-danger-ghost:not(:disabled):hover{background:rgba(220,38,38,.08)}
|
||||
.btn-sm{min-height:36px;padding:7px 12px;font-size:14px;border-radius:8px}
|
||||
.btn-block{width:100%}
|
||||
|
||||
/* ---------- 主界面 ---------- */
|
||||
.topbar{
|
||||
position:sticky;top:0;z-index:20;
|
||||
background:var(--card);
|
||||
border-bottom:1px solid var(--border);
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
.topbar-inner{
|
||||
max-width:980px;margin:0 auto;padding:12px 16px;
|
||||
display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;
|
||||
}
|
||||
.brand{display:flex;align-items:center;gap:10px;font-size:18px;font-weight:700}
|
||||
.brand .logo-sm{
|
||||
width:38px;height:38px;border-radius:10px;background:var(--grad);
|
||||
display:flex;align-items:center;justify-content:center;font-size:20px;
|
||||
}
|
||||
.brand-sub{font-size:12px;font-weight:400;color:var(--muted)}
|
||||
.user-box{display:flex;align-items:center;gap:10px}
|
||||
.user-badge{display:inline-flex;align-items:center;gap:6px;font-size:14px;color:var(--muted);padding:6px 12px;border-radius:999px;background:var(--bg);border:1px solid var(--border)}
|
||||
.user-badge b{color:var(--text);font-weight:600}
|
||||
.container{max-width:980px;margin:0 auto;padding:20px 16px calc(32px + env(safe-area-inset-bottom));display:grid;gap:16px}
|
||||
.card{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:12px;
|
||||
box-shadow:var(--shadow);padding:20px;
|
||||
}
|
||||
.card h2{font-size:18px;font-weight:700;margin-bottom:14px}
|
||||
|
||||
/* 上传区 */
|
||||
.drop-zone{
|
||||
border:2px dashed var(--border-strong);border-radius:12px;
|
||||
padding:32px 16px;text-align:center;cursor:pointer;
|
||||
transition:border-color .15s, background .15s;
|
||||
}
|
||||
.drop-zone:hover,.drop-zone.dragover{border-color:var(--primary);background:rgba(79,110,247,.05)}
|
||||
.dz-icon{font-size:34px;line-height:1;margin-bottom:8px}
|
||||
.drop-zone p{color:var(--text)}
|
||||
.drop-zone .hint{color:var(--muted);font-size:13px;margin-top:4px}
|
||||
.upload-options{
|
||||
display:flex;align-items:center;gap:16px;flex-wrap:wrap;
|
||||
margin-top:14px;padding-top:14px;border-top:1px solid var(--border);
|
||||
}
|
||||
.field-inline{display:flex;align-items:center;gap:10px;font-size:14px;color:var(--muted);white-space:nowrap}
|
||||
.field-inline select{width:auto;min-width:120px}
|
||||
.upload-options .hint{font-size:12px;color:var(--muted)}
|
||||
|
||||
.queue{list-style:none;margin:14px 0 0;display:grid;gap:10px}
|
||||
.queue-item{
|
||||
display:flex;align-items:center;gap:12px;flex-wrap:wrap;
|
||||
border:1px solid var(--border);border-radius:10px;padding:12px;
|
||||
}
|
||||
.q-info{flex:1 1 240px;min-width:0}
|
||||
.q-name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.q-meta{font-size:12px;color:var(--muted);margin:3px 0 6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.q-side{display:flex;align-items:center;gap:10px}
|
||||
.q-pct{min-width:44px;text-align:right;font-size:13px;color:var(--muted)}
|
||||
.q-status{font-size:13px;font-weight:600}
|
||||
.q-status.ok{color:var(--success)}
|
||||
.q-status.err{color:var(--danger)}
|
||||
.q-status.cancel{color:var(--muted)}
|
||||
.progress{
|
||||
width:100%;height:8px;border-radius:999px;background:var(--border);
|
||||
overflow:hidden;
|
||||
}
|
||||
.progress-bar{
|
||||
height:100%;width:0;border-radius:999px;
|
||||
background:linear-gradient(90deg,#667eea,#8b5cf6);
|
||||
transition:width .15s linear;
|
||||
}
|
||||
.q-progress{width:100%;flex-basis:100%;display:flex;align-items:center;gap:10px}
|
||||
.q-progress .progress{flex:1}
|
||||
.q-progress .mono{font-size:12px;color:var(--muted);min-width:40px;text-align:right}
|
||||
|
||||
/* 文件列表 */
|
||||
.section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:12px}
|
||||
.section-head h2{margin-bottom:0}
|
||||
.count{font-size:13px;color:var(--muted);font-weight:400;margin-left:6px}
|
||||
.head-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.file-list{list-style:none;display:grid;gap:10px}
|
||||
.file-row{
|
||||
display:grid;grid-template-columns:auto minmax(0,1fr) auto;
|
||||
gap:12px;align-items:center;
|
||||
border:1px solid var(--border);border-radius:12px;padding:14px;
|
||||
transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.file-row:hover{border-color:var(--border-strong);box-shadow:var(--shadow)}
|
||||
.file-icon{
|
||||
width:44px;height:44px;border-radius:10px;background:var(--bg);
|
||||
display:flex;align-items:center;justify-content:center;font-size:22px;
|
||||
border:1px solid var(--border);
|
||||
}
|
||||
.file-main{min-width:0}
|
||||
.file-name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.file-meta{display:flex;flex-wrap:wrap;gap:6px 16px;color:var(--muted);font-size:13px;margin-top:4px}
|
||||
.file-meta .sep{display:none}
|
||||
.expire{font-weight:600}
|
||||
.expire.ok{color:var(--success)}
|
||||
.expire.warn{color:var(--warning)}
|
||||
.expire.danger{color:var(--danger)}
|
||||
.file-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
|
||||
.dl-progress{grid-column:1/-1;display:flex;align-items:center;gap:10px;margin-top:2px}
|
||||
.dl-progress .progress{flex:1}
|
||||
.dl-progress .mono{font-size:12px;color:var(--muted);min-width:44px;text-align:right}
|
||||
.empty{
|
||||
text-align:center;padding:40px 16px;color:var(--muted);
|
||||
border:1px dashed var(--border-strong);border-radius:12px;
|
||||
}
|
||||
.empty .empty-icon{font-size:36px;display:block;margin-bottom:8px}
|
||||
|
||||
/* Toast 与确认框 */
|
||||
.toasts{
|
||||
position:fixed;top:16px;right:16px;z-index:1000;
|
||||
display:flex;flex-direction:column;gap:8px;
|
||||
max-width:min(360px,calc(100vw - 32px));
|
||||
}
|
||||
.toast{
|
||||
background:var(--card);border:1px solid var(--border);border-radius:10px;
|
||||
box-shadow:var(--shadow-lg);padding:12px 14px;font-size:14px;
|
||||
display:flex;align-items:center;gap:8px;
|
||||
animation:toast-in .2s ease-out;
|
||||
}
|
||||
.toast.info{border-left:4px solid var(--primary)}
|
||||
.toast.success{border-left:4px solid var(--success)}
|
||||
.toast.error{border-left:4px solid var(--danger)}
|
||||
@keyframes toast-in{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
|
||||
.modal-root{
|
||||
position:fixed;inset:0;z-index:900;background:rgba(15,18,26,.5);
|
||||
display:flex;align-items:center;justify-content:center;padding:16px;
|
||||
}
|
||||
.modal-card{
|
||||
background:var(--card);border-radius:12px;box-shadow:var(--shadow-lg);
|
||||
width:100%;max-width:400px;padding:20px;
|
||||
}
|
||||
.modal-card h3{font-size:17px;margin-bottom:8px}
|
||||
.modal-card p{color:var(--muted);font-size:14px;margin-bottom:18px;word-break:break-all}
|
||||
.modal-actions{display:flex;justify-content:flex-end;gap:10px}
|
||||
|
||||
@media (max-width:640px){
|
||||
.card{padding:16px}
|
||||
.file-row{grid-template-columns:auto minmax(0,1fr);padding:12px}
|
||||
.file-actions{grid-column:1/-1;justify-content:stretch}
|
||||
.file-actions .btn{flex:1}
|
||||
.file-meta{font-size:12px;gap:4px 10px}
|
||||
.topbar-inner{padding:10px 12px}
|
||||
.container{padding:14px 12px calc(28px + env(safe-area-inset-bottom));gap:12px}
|
||||
.toasts{left:16px;right:16px;top:12px;align-items:stretch}
|
||||
.q-side{width:100%;justify-content:flex-end}
|
||||
.q-pct{flex:1;text-align:right}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
*{transition:none !important;animation:none !important}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<section id="loginView" class="auth-wrap hidden" aria-label="登录">
|
||||
<form id="loginForm" class="auth-card" autocomplete="on">
|
||||
<div class="logo" aria-hidden="true">📦</div>
|
||||
<h1>文件快传</h1>
|
||||
<p class="sub">lzwlab.cn 私人在线文件暂存</p>
|
||||
<label class="field">
|
||||
<span>用户名</span>
|
||||
<input id="username" name="username" type="text" autocomplete="username" required maxlength="64">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>密码</span>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<p id="loginError" class="form-error hidden" role="alert"></p>
|
||||
<button id="loginBtn" class="btn btn-primary btn-block" type="submit">登 录</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="appView" class="hidden">
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<div class="brand"><span class="logo-sm" aria-hidden="true">📦</span>文件快传<span class="brand-sub">lzwlab.cn</span></div>
|
||||
<div class="user-box">
|
||||
<span class="user-badge">👤 <b id="userName"></b></span>
|
||||
<button id="logoutBtn" class="btn btn-ghost btn-sm" type="button">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="card" aria-label="上传文件">
|
||||
<h2>上传文件</h2>
|
||||
<div id="dropZone" class="drop-zone" role="button" tabindex="0" aria-label="选择或拖拽上传文件">
|
||||
<div class="dz-icon" aria-hidden="true">⬆️</div>
|
||||
<p>点击选择文件,或将文件拖到此处</p>
|
||||
<p class="hint">可一次选择多个文件,单个文件最大 5 GB</p>
|
||||
</div>
|
||||
<input type="file" id="fileInput" multiple hidden>
|
||||
<div class="upload-options">
|
||||
<label class="field-inline">文件有效期
|
||||
<select id="expiresSelect" aria-label="文件有效期">
|
||||
<option value="1">1 小时</option>
|
||||
<option value="24">1 天</option>
|
||||
<option value="72">3 天</option>
|
||||
<option value="168" selected>7 天(默认)</option>
|
||||
<option value="720">30 天</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="hint">到期后文件自动删除;最短 1 小时,最长 30 天</p>
|
||||
</div>
|
||||
<ul id="queue" class="queue" aria-live="polite"></ul>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-label="我的暂存文件">
|
||||
<div class="section-head">
|
||||
<h2>我的暂存文件<span id="fileCount" class="count"></span></h2>
|
||||
<div class="head-actions">
|
||||
<button id="cleanupBtn" class="btn btn-ghost btn-sm" type="button">立即清理过期</button>
|
||||
<button id="refreshBtn" class="btn btn-ghost btn-sm" type="button">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul id="fileList" class="file-list" aria-live="polite"></ul>
|
||||
<div id="emptyState" class="empty hidden">
|
||||
<span class="empty-icon" aria-hidden="true">📭</span>
|
||||
<p>还没有暂存文件,先上传一个吧</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toasts" class="toasts" aria-live="polite" aria-atomic="false"></div>
|
||||
|
||||
<div id="modalRoot" class="modal-root hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||
<div class="modal-card">
|
||||
<h3 id="modalTitle">确认</h3>
|
||||
<p id="modalMessage"></p>
|
||||
<div class="modal-actions">
|
||||
<button id="modalCancel" class="btn btn-ghost" type="button">取消</button>
|
||||
<button id="modalConfirm" class="btn btn-primary" type="button">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var $ = function (sel, root) { return (root || document).querySelector(sel); };
|
||||
var show = function (el) { el.classList.remove('hidden'); };
|
||||
var hide = function (el) { el.classList.add('hidden'); };
|
||||
|
||||
var loginView = $('#loginView'), appView = $('#appView');
|
||||
var loginForm = $('#loginForm'), loginError = $('#loginError'), loginBtn = $('#loginBtn');
|
||||
var usernameInput = $('#username'), passwordInput = $('#password');
|
||||
var userName = $('#userName'), logoutBtn = $('#logoutBtn');
|
||||
var dropZone = $('#dropZone'), fileInput = $('#fileInput'), expiresSelect = $('#expiresSelect');
|
||||
var queueEl = $('#queue'), fileListEl = $('#fileList'), emptyState = $('#emptyState');
|
||||
var fileCountEl = $('#fileCount'), cleanupBtn = $('#cleanupBtn'), refreshBtn = $('#refreshBtn');
|
||||
var toastsEl = $('#toasts');
|
||||
var modalRoot = $('#modalRoot'), modalTitle = $('#modalTitle'), modalMessage = $('#modalMessage');
|
||||
var modalCancel = $('#modalCancel'), modalConfirm = $('#modalConfirm');
|
||||
|
||||
var currentUser = null;
|
||||
var files = [];
|
||||
var queue = [];
|
||||
var queueSeq = 0;
|
||||
var uploadBusy = false;
|
||||
var modalResolver = null;
|
||||
|
||||
/* ---------- 工具函数 ---------- */
|
||||
function fmtSize(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '-';
|
||||
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var v = bytes, i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return (i === 0 ? v.toFixed(0) : v.toFixed(1)) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function fmtTime(iso) {
|
||||
var d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '-';
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
function fmtRemaining(seconds) {
|
||||
if (seconds <= 0) return '已过期';
|
||||
var d = Math.floor(seconds / 86400);
|
||||
var h = Math.floor((seconds % 86400) / 3600);
|
||||
var m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return '剩 ' + d + ' 天 ' + h + ' 小时';
|
||||
if (h > 0) return '剩 ' + h + ' 小时 ' + m + ' 分';
|
||||
if (m > 0) return '剩 ' + m + ' 分钟';
|
||||
return '即将过期';
|
||||
}
|
||||
|
||||
function toast(message, type) {
|
||||
type = type || 'info';
|
||||
var el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
var icon = type === 'success' ? '✅' : type === 'error' ? '⚠️' : 'ℹ️';
|
||||
el.innerHTML = '<span aria-hidden="true">' + icon + '</span><span></span>';
|
||||
el.lastChild.textContent = message;
|
||||
toastsEl.appendChild(el);
|
||||
setTimeout(function () { el.remove(); }, 4000);
|
||||
}
|
||||
|
||||
function openModal(title, message, danger, confirmText) {
|
||||
modalTitle.textContent = title;
|
||||
modalMessage.textContent = message;
|
||||
modalConfirm.className = 'btn ' + (danger ? 'btn-danger' : 'btn-primary');
|
||||
modalConfirm.textContent = confirmText || (danger ? '删除' : '确定');
|
||||
show(modalRoot);
|
||||
modalConfirm.focus();
|
||||
return new Promise(function (resolve) { modalResolver = resolve; });
|
||||
}
|
||||
function closeModal(result) {
|
||||
hide(modalRoot);
|
||||
if (modalResolver) { modalResolver(result); modalResolver = null; }
|
||||
}
|
||||
modalCancel.addEventListener('click', function () { closeModal(false); });
|
||||
modalConfirm.addEventListener('click', function () { closeModal(true); });
|
||||
modalRoot.addEventListener('click', function (e) {
|
||||
if (e.target === modalRoot) closeModal(false);
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !modalRoot.classList.contains('hidden')) closeModal(false);
|
||||
});
|
||||
|
||||
function parseFilename(cd) {
|
||||
if (!cd) return null;
|
||||
var star = /filename*=UTF-8''([^;]+)/i.exec(cd);
|
||||
if (star) { try { return decodeURIComponent(star[1]); } catch (e) {} }
|
||||
var plain = /filename="?([^";]+)"?/i.exec(cd);
|
||||
return plain ? plain[1] : null;
|
||||
}
|
||||
|
||||
function fileIcon(name) {
|
||||
var ext = (name.split('.').pop() || '').toLowerCase();
|
||||
if (/(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/.test(ext)) return '🖼️';
|
||||
if (/(mp4|mkv|mov|avi|wmv|flv|webm)$/.test(ext)) return '🎬';
|
||||
if (/(mp3|wav|flac|aac|ogg|m4a)$/.test(ext)) return '🎵';
|
||||
if (/(zip|rar|7z|tar|gz|bz2|xz)$/.test(ext)) return '🗜️';
|
||||
if (ext === 'pdf') return '📕';
|
||||
if (/(docx?|xlsx?|pptx?|odt|ods)$/.test(ext)) return '📄';
|
||||
if (/(js|ts|py|java|c|cpp|go|rs|html|css|json|xml|sh|sql)$/.test(ext)) return '🧩';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
/* ---------- 会话 / 登录 ---------- */
|
||||
function showLogin() {
|
||||
currentUser = null;
|
||||
hide(appView); show(loginView);
|
||||
passwordInput.value = '';
|
||||
hide(loginError);
|
||||
loginBtn.disabled = false; loginBtn.textContent = '登 录';
|
||||
}
|
||||
|
||||
function showApp(user) {
|
||||
currentUser = user;
|
||||
hide(loginView); show(appView);
|
||||
userName.textContent = user;
|
||||
loadFiles(true);
|
||||
}
|
||||
|
||||
function handle401() {
|
||||
toast('登录已过期,请重新登录', 'error');
|
||||
showLogin();
|
||||
}
|
||||
|
||||
async function checkSession() {
|
||||
try {
|
||||
var resp = await fetch('api/session', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
if (resp.status === 401) { showLogin(); return; }
|
||||
var data = await resp.json();
|
||||
if (data.logged_in) showApp(data.user); else showLogin();
|
||||
} catch (e) {
|
||||
showLogin();
|
||||
toast('无法连接服务器', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
loginForm.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
hide(loginError);
|
||||
loginBtn.disabled = true; loginBtn.textContent = '登录中…';
|
||||
try {
|
||||
var resp = await fetch('api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: JSON.stringify({ username: usernameInput.value.trim(), password: passwordInput.value })
|
||||
});
|
||||
var data = await resp.json().catch(function () { return { ok: false, error: '服务器错误' }; });
|
||||
if (resp.ok && data.ok) {
|
||||
toast('欢迎回来,' + data.user, 'success');
|
||||
showApp(data.user);
|
||||
} else {
|
||||
loginError.textContent = data.error || '用户名或密码错误';
|
||||
show(loginError);
|
||||
}
|
||||
} catch (e) {
|
||||
loginError.textContent = '网络错误,请重试';
|
||||
show(loginError);
|
||||
}
|
||||
loginBtn.disabled = false; loginBtn.textContent = '登 录';
|
||||
});
|
||||
|
||||
logoutBtn.addEventListener('click', async function () {
|
||||
logoutBtn.disabled = true;
|
||||
try {
|
||||
await fetch('api/logout', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
} catch (e) {}
|
||||
logoutBtn.disabled = false;
|
||||
showLogin();
|
||||
});
|
||||
|
||||
/* ---------- 文件列表 ---------- */
|
||||
async function loadFiles(showErrors) {
|
||||
try {
|
||||
var resp = await fetch('api/files', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
if (resp.status === 401) { handle401(); return; }
|
||||
var data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
if (showErrors) toast(data.error || '加载文件列表失败', 'error');
|
||||
return;
|
||||
}
|
||||
files = data.files || [];
|
||||
renderFiles();
|
||||
} catch (e) {
|
||||
if (showErrors) toast('加载文件列表失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
fileListEl.innerHTML = '';
|
||||
if (!files.length) { show(emptyState); fileCountEl.textContent = ''; return; }
|
||||
hide(emptyState);
|
||||
fileCountEl.textContent = '(' + files.length + ' 个)';
|
||||
files.forEach(function (f) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'file-row';
|
||||
li.dataset.id = f.id;
|
||||
|
||||
var icon = document.createElement('div');
|
||||
icon.className = 'file-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = fileIcon(f.name);
|
||||
|
||||
var main = document.createElement('div');
|
||||
main.className = 'file-main';
|
||||
var name = document.createElement('div');
|
||||
name.className = 'file-name';
|
||||
name.title = f.name;
|
||||
name.textContent = f.name;
|
||||
main.appendChild(name);
|
||||
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'file-meta';
|
||||
var s1 = document.createElement('span'); s1.className = 'mono'; s1.textContent = fmtSize(f.size);
|
||||
var s2 = document.createElement('span'); s2.textContent = '上传于 ' + fmtTime(f.created_at);
|
||||
var s3 = document.createElement('span'); s3.className = 'expire';
|
||||
if (f.remaining_seconds <= 0) { s3.classList.add('danger'); }
|
||||
else if (f.remaining_seconds < 24 * 3600) { s3.classList.add('warn'); }
|
||||
else { s3.classList.add('ok'); }
|
||||
s3.textContent = fmtRemaining(f.remaining_seconds);
|
||||
var s4 = document.createElement('span'); s4.textContent = '下载 ' + f.downloads + ' 次';
|
||||
meta.appendChild(s1); meta.appendChild(s2); meta.appendChild(s3); meta.appendChild(s4);
|
||||
main.appendChild(meta);
|
||||
|
||||
var dl = document.createElement('div');
|
||||
dl.className = 'dl-progress hidden';
|
||||
dl.setAttribute('role', 'progressbar');
|
||||
dl.setAttribute('aria-valuemin', '0'); dl.setAttribute('aria-valuemax', '100'); dl.setAttribute('aria-valuenow', '0');
|
||||
var dlTrack = document.createElement('div'); dlTrack.className = 'progress';
|
||||
var dlBar = document.createElement('div'); dlBar.className = 'progress-bar';
|
||||
dlTrack.appendChild(dlBar);
|
||||
var dlPct = document.createElement('span'); dlPct.className = 'mono'; dlPct.textContent = '0%';
|
||||
dl.appendChild(dlTrack); dl.appendChild(dlPct);
|
||||
main.appendChild(dl);
|
||||
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'file-actions';
|
||||
var btnDl = document.createElement('button');
|
||||
btnDl.type = 'button'; btnDl.className = 'btn btn-primary btn-sm'; btnDl.dataset.id = f.id;
|
||||
btnDl.textContent = '下载';
|
||||
var linkNative = document.createElement('a');
|
||||
linkNative.className = 'btn btn-ghost btn-sm'; linkNative.href = 'api/download/' + f.id + '?dl=1';
|
||||
linkNative.dataset.id = f.id; linkNative.textContent = '系统下载';
|
||||
var btnDel = document.createElement('button');
|
||||
btnDel.type = 'button'; btnDel.className = 'btn btn-danger-ghost btn-sm'; btnDel.dataset.id = f.id;
|
||||
btnDel.textContent = '删除';
|
||||
actions.appendChild(btnDl); actions.appendChild(linkNative); actions.appendChild(btnDel);
|
||||
|
||||
li.appendChild(icon); li.appendChild(main); li.appendChild(actions);
|
||||
fileListEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
fileListEl.addEventListener('click', async function (e) {
|
||||
var btn = e.target.closest('button[data-id], a[data-id]');
|
||||
if (!btn) return;
|
||||
var id = btn.dataset.id;
|
||||
var f = files.find(function (x) { return x.id === id; });
|
||||
if (!f) return;
|
||||
if (btn.classList.contains('btn-danger-ghost')) {
|
||||
var ok = await openModal('删除文件', '确定删除「' + f.name + '」吗?此操作不可恢复。', true);
|
||||
if (!ok) return;
|
||||
btn.disabled = true; btn.textContent = '删除中…';
|
||||
try {
|
||||
var resp = await fetch('api/files/' + id, { method: 'DELETE', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
var data = await resp.json().catch(function () { return { ok: false }; });
|
||||
if (resp.ok && data.ok) {
|
||||
toast('已删除:' + f.name, 'success');
|
||||
loadFiles(true);
|
||||
} else if (resp.status === 401) { handle401(); }
|
||||
else { toast(data.error || '删除失败', 'error'); btn.disabled = false; btn.textContent = '删除'; }
|
||||
} catch (err) {
|
||||
toast('网络错误', 'error'); btn.disabled = false; btn.textContent = '删除';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (btn.classList.contains('btn-primary')) {
|
||||
startDownload(f, btn);
|
||||
}
|
||||
});
|
||||
|
||||
function startDownload(f, btn) {
|
||||
var li = fileListEl.querySelector('[data-id="' + f.id + '"]');
|
||||
if (!li) return;
|
||||
var dl = li.querySelector('.dl-progress');
|
||||
var bar = li.querySelector('.dl-progress .progress-bar');
|
||||
var pct = li.querySelector('.dl-progress .mono');
|
||||
show(dl);
|
||||
dl.setAttribute('aria-valuenow', '0');
|
||||
bar.style.width = '0%'; pct.textContent = '0%';
|
||||
btn.disabled = true; btn.textContent = '下载中…';
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', 'api/download/' + f.id + '?dl=1', true);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.onprogress = function (e) {
|
||||
if (e.lengthComputable) {
|
||||
var p = Math.round(e.loaded / e.total * 100);
|
||||
bar.style.width = p + '%';
|
||||
pct.textContent = p + '%';
|
||||
dl.setAttribute('aria-valuenow', String(p));
|
||||
}
|
||||
};
|
||||
xhr.onload = async function () {
|
||||
if (xhr.status === 200) {
|
||||
var name = parseFilename(xhr.getResponseHeader('Content-Disposition')) || f.name;
|
||||
var url = URL.createObjectURL(xhr.response);
|
||||
var a = document.createElement('a');
|
||||
a.href = url; a.download = name;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
|
||||
bar.style.width = '100%'; pct.textContent = '100%';
|
||||
dl.setAttribute('aria-valuenow', '100');
|
||||
toast('下载完成:' + name, 'success');
|
||||
loadFiles(true);
|
||||
} else if (xhr.status === 401) {
|
||||
handle401();
|
||||
} else {
|
||||
var msg = '下载失败';
|
||||
try {
|
||||
if (xhr.response && xhr.response instanceof Blob) {
|
||||
var text = await xhr.response.text();
|
||||
var parsed = JSON.parse(text);
|
||||
if (parsed && parsed.error) msg = parsed.error;
|
||||
}
|
||||
} catch (e) {}
|
||||
toast(msg, 'error');
|
||||
hide(dl);
|
||||
btn.disabled = false; btn.textContent = '下载';
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
toast('网络错误,下载中断', 'error');
|
||||
hide(dl);
|
||||
btn.disabled = false; btn.textContent = '下载';
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
/* ---------- 清理 / 刷新 ---------- */
|
||||
cleanupBtn.addEventListener('click', async function () {
|
||||
cleanupBtn.disabled = true;
|
||||
try {
|
||||
var resp = await fetch('api/cleanup', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
var data = await resp.json().catch(function () { return { ok: false }; });
|
||||
if (resp.ok && data.ok) {
|
||||
toast('清理完成:移除 ' + data.removed + ' 个过期文件', 'success');
|
||||
} else if (resp.status === 401) { handle401(); }
|
||||
else { toast(data.error || '清理失败', 'error'); }
|
||||
loadFiles(true);
|
||||
} catch (e) {
|
||||
toast('网络错误', 'error');
|
||||
}
|
||||
cleanupBtn.disabled = false;
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener('click', function () { loadFiles(true); });
|
||||
|
||||
/* ---------- 上传队列 ---------- */
|
||||
dropZone.addEventListener('click', function () { fileInput.click(); });
|
||||
dropZone.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
|
||||
});
|
||||
['dragenter', 'dragover'].forEach(function (ev) {
|
||||
dropZone.addEventListener(ev, function (e) { e.preventDefault(); dropZone.classList.add('dragover'); });
|
||||
});
|
||||
['dragleave', 'drop'].forEach(function (ev) {
|
||||
dropZone.addEventListener(ev, function (e) { e.preventDefault(); dropZone.classList.remove('dragover'); });
|
||||
});
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
var list = e.dataTransfer && e.dataTransfer.files;
|
||||
if (list && list.length) enqueueFiles(Array.prototype.slice.call(list));
|
||||
});
|
||||
fileInput.addEventListener('change', function () {
|
||||
if (fileInput.files.length) enqueueFiles(Array.prototype.slice.call(fileInput.files));
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
function enqueueFiles(fileArr) {
|
||||
var hours = Number(expiresSelect.value) || 168;
|
||||
fileArr.forEach(function (file) {
|
||||
queue.push({
|
||||
id: ++queueSeq,
|
||||
file: file,
|
||||
expiresHours: hours,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
loaded: 0,
|
||||
total: file.size || 0,
|
||||
error: '',
|
||||
xhr: null
|
||||
});
|
||||
});
|
||||
renderQueue();
|
||||
pumpQueue();
|
||||
}
|
||||
|
||||
function renderQueue() {
|
||||
queueEl.innerHTML = '';
|
||||
queue.forEach(function (item) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'queue-item';
|
||||
li.dataset.qid = item.id;
|
||||
|
||||
var info = document.createElement('div');
|
||||
info.className = 'q-info';
|
||||
var name = document.createElement('div');
|
||||
name.className = 'q-name'; name.title = item.file.name; name.textContent = item.file.name;
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'q-meta mono';
|
||||
info.appendChild(name); info.appendChild(meta);
|
||||
var progress = document.createElement('div');
|
||||
progress.className = 'q-progress';
|
||||
var track = document.createElement('div'); track.className = 'progress';
|
||||
var bar = document.createElement('div'); bar.className = 'progress-bar';
|
||||
track.appendChild(bar);
|
||||
var pct = document.createElement('span'); pct.className = 'mono';
|
||||
progress.appendChild(track); progress.appendChild(pct);
|
||||
|
||||
var side = document.createElement('div');
|
||||
side.className = 'q-side';
|
||||
var status = document.createElement('span'); status.className = 'q-status';
|
||||
var cancel = document.createElement('button');
|
||||
cancel.type = 'button'; cancel.className = 'btn btn-ghost btn-sm'; cancel.textContent = '取消';
|
||||
cancel.addEventListener('click', function () { cancelItem(item); });
|
||||
side.appendChild(status); side.appendChild(cancel);
|
||||
|
||||
li.appendChild(info); li.appendChild(progress); li.appendChild(side);
|
||||
item.root = li; item.refs = { meta: meta, bar: bar, pct: pct, status: status, cancel: cancel };
|
||||
queueEl.appendChild(li);
|
||||
updateQueueItem(item);
|
||||
});
|
||||
}
|
||||
|
||||
function statusText(item) {
|
||||
if (item.status === 'pending') return '等待上传';
|
||||
if (item.status === 'uploading') return '上传中';
|
||||
if (item.status === 'done') return '完成';
|
||||
if (item.status === 'error') return item.error || '上传失败';
|
||||
if (item.status === 'canceled') return '已取消';
|
||||
return '';
|
||||
}
|
||||
|
||||
function updateQueueItem(item) {
|
||||
if (!item.root || !item.root.isConnected) { renderQueue(); return; }
|
||||
var refs = item.refs;
|
||||
refs.meta.textContent = fmtSize(item.loaded) + ' / ' + fmtSize(item.total) + ' · 有效期 ' + item.expiresHours + ' 小时';
|
||||
refs.bar.style.width = item.progress + '%';
|
||||
refs.pct.textContent = item.progress + '%';
|
||||
refs.status.textContent = statusText(item);
|
||||
refs.status.className = 'q-status ' +
|
||||
(item.status === 'done' ? 'ok' : item.status === 'error' ? 'err' : item.status === 'canceled' ? 'cancel' : '');
|
||||
if (item.status === 'done') {
|
||||
refs.cancel.textContent = '移除';
|
||||
refs.cancel.onclick = function () { removeQueueItem(item); };
|
||||
} else if (item.status === 'error') {
|
||||
refs.cancel.textContent = '重试';
|
||||
refs.cancel.onclick = function () { retryItem(item); };
|
||||
} else {
|
||||
refs.cancel.textContent = '取消';
|
||||
refs.cancel.onclick = function () { cancelItem(item); };
|
||||
}
|
||||
}
|
||||
|
||||
function removeQueueItem(item) {
|
||||
queue = queue.filter(function (x) { return x.id !== item.id; });
|
||||
renderQueue();
|
||||
}
|
||||
function retryItem(item) {
|
||||
item.status = 'pending'; item.progress = 0; item.loaded = 0; item.error = ''; item.xhr = null;
|
||||
updateQueueItem(item);
|
||||
pumpQueue();
|
||||
}
|
||||
function cancelItem(item) {
|
||||
if (item.status === 'uploading' && item.xhr) { item.xhr.abort(); }
|
||||
else { item.status = 'canceled'; updateQueueItem(item); }
|
||||
}
|
||||
|
||||
function pumpQueue() {
|
||||
if (uploadBusy) return;
|
||||
uploadBusy = true;
|
||||
(async function loop() {
|
||||
while (true) {
|
||||
var item = queue.find(function (x) { return x.status === 'pending'; });
|
||||
if (!item) break;
|
||||
await uploadOne(item);
|
||||
}
|
||||
uploadBusy = false;
|
||||
})();
|
||||
}
|
||||
|
||||
function uploadOne(item) {
|
||||
return new Promise(function (resolve) {
|
||||
item.status = 'uploading';
|
||||
item.progress = 0; item.loaded = 0; item.error = '';
|
||||
updateQueueItem(item);
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('file', item.file, item.file.name);
|
||||
fd.append('expires_hours', String(item.expiresHours));
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
item.xhr = xhr;
|
||||
xhr.open('POST', 'api/upload', true);
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.upload.onprogress = function (e) {
|
||||
if (e.lengthComputable) {
|
||||
item.total = e.total;
|
||||
item.loaded = e.loaded;
|
||||
item.progress = e.total ? Math.round(e.loaded / e.total * 100) : 0;
|
||||
updateQueueItem(item);
|
||||
}
|
||||
};
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
item.progress = 100;
|
||||
item.status = 'done';
|
||||
updateQueueItem(item);
|
||||
toast('上传完成:' + item.file.name, 'success');
|
||||
loadFiles(true);
|
||||
} else if (xhr.status === 401) {
|
||||
item.status = 'error'; item.error = '登录已过期';
|
||||
updateQueueItem(item);
|
||||
handle401();
|
||||
} else {
|
||||
var msg = '上传失败';
|
||||
try {
|
||||
var parsed = JSON.parse(xhr.responseText);
|
||||
if (parsed && parsed.error) msg = parsed.error;
|
||||
} catch (e) {}
|
||||
item.status = 'error'; item.error = msg;
|
||||
updateQueueItem(item);
|
||||
toast(msg, 'error');
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
if (item.status !== 'canceled') {
|
||||
item.status = 'error'; item.error = '网络错误';
|
||||
updateQueueItem(item);
|
||||
toast('上传失败:网络错误', 'error');
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
xhr.onabort = function () {
|
||||
item.status = 'canceled';
|
||||
updateQueueItem(item);
|
||||
resolve();
|
||||
};
|
||||
xhr.send(fd);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
setInterval(function () {
|
||||
if (currentUser) loadFiles(false);
|
||||
}, 30000);
|
||||
checkSession();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user