Persist software uploads outside deploy path
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- Node.js 20+,推荐 Node.js 22 LTS 或更高版本
|
- Node.js 20+,推荐 Node.js 22 LTS 或更高版本
|
||||||
- npm
|
- npm
|
||||||
- 可写目录:`server/data/`、`server/uploads/`
|
- 持久化可写目录:推荐 `/var/lib/sumi-work/`
|
||||||
- 对外端口:默认 `3030`,可通过 `PORT` 修改
|
- 对外端口:默认 `3030`,可通过 `PORT` 修改
|
||||||
|
|
||||||
检查:
|
检查:
|
||||||
@@ -55,11 +55,14 @@ export PORT=3030
|
|||||||
export ADMIN_TOKEN="change-this-admin-token"
|
export ADMIN_TOKEN="change-this-admin-token"
|
||||||
export PLAYER_USERNAME="player"
|
export PLAYER_USERNAME="player"
|
||||||
export PLAYER_PASSWORD="change-this-player-password"
|
export PLAYER_PASSWORD="change-this-player-password"
|
||||||
|
export SUMI_STORAGE_DIR="/var/lib/sumi-work"
|
||||||
```
|
```
|
||||||
|
|
||||||
可选变量:
|
可选变量:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
export SUMI_DATA_DIR="/var/lib/sumi-work/data"
|
||||||
|
export SUMI_UPLOAD_DIR="/var/lib/sumi-work/uploads"
|
||||||
export MAX_UPLOAD_BYTES=1073741824
|
export MAX_UPLOAD_BYTES=1073741824
|
||||||
export PLAYER_SESSION_TTL_MS=604800000
|
export PLAYER_SESSION_TTL_MS=604800000
|
||||||
```
|
```
|
||||||
@@ -70,10 +73,30 @@ export PLAYER_SESSION_TTL_MS=604800000
|
|||||||
- `ADMIN_TOKEN`:管理后台调用上传/更新/删除 API 的令牌
|
- `ADMIN_TOKEN`:管理后台调用上传/更新/删除 API 的令牌
|
||||||
- `PLAYER_USERNAME`:玩家登录账号
|
- `PLAYER_USERNAME`:玩家登录账号
|
||||||
- `PLAYER_PASSWORD`:玩家登录密码
|
- `PLAYER_PASSWORD`:玩家登录密码
|
||||||
|
- `SUMI_STORAGE_DIR`:运行时数据根目录,推荐放在部署目录外,例如 `/var/lib/sumi-work`
|
||||||
|
- `SUMI_DATA_DIR`:软件列表和玩家账号数据目录;设置后会覆盖 `SUMI_STORAGE_DIR/data`
|
||||||
|
- `SUMI_UPLOAD_DIR`:上传图标、安装包和 version.json 目录;设置后会覆盖 `SUMI_STORAGE_DIR/uploads`
|
||||||
- `MAX_UPLOAD_BYTES`:最大上传文件大小,默认 1GB
|
- `MAX_UPLOAD_BYTES`:最大上传文件大小,默认 1GB
|
||||||
- `PLAYER_SESSION_TTL_MS`:玩家登录有效期,默认 7 天
|
- `PLAYER_SESSION_TTL_MS`:玩家登录有效期,默认 7 天
|
||||||
|
|
||||||
首次启动时,如果 `server/data/players.json` 不存在,服务会生成默认玩家。设置了 `PLAYER_USERNAME` 和 `PLAYER_PASSWORD` 时,会写入或更新对应玩家账号。
|
首次启动时,如果数据目录里的 `players.json` 不存在,服务会生成默认玩家。设置了 `PLAYER_USERNAME` 和 `PLAYER_PASSWORD` 时,会写入或更新对应玩家账号。
|
||||||
|
|
||||||
|
生产环境不要把上传数据放在 Git 部署目录里。推荐先创建持久化目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /var/lib/sumi-work/data
|
||||||
|
sudo mkdir -p /var/lib/sumi-work/uploads/icons
|
||||||
|
sudo mkdir -p /var/lib/sumi-work/uploads/packages
|
||||||
|
sudo mkdir -p /var/lib/sumi-work/uploads/manifests
|
||||||
|
sudo chown -R "$USER":"$USER" /var/lib/sumi-work
|
||||||
|
```
|
||||||
|
|
||||||
|
如果服务器上已经有旧数据,先迁移到持久化目录再重新部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rsync -a server/data/ /var/lib/sumi-work/data/
|
||||||
|
rsync -a server/uploads/ /var/lib/sumi-work/uploads/
|
||||||
|
```
|
||||||
|
|
||||||
## 5. 启动服务
|
## 5. 启动服务
|
||||||
|
|
||||||
@@ -105,6 +128,7 @@ PORT=3030 \
|
|||||||
ADMIN_TOKEN="change-this-admin-token" \
|
ADMIN_TOKEN="change-this-admin-token" \
|
||||||
PLAYER_USERNAME="player" \
|
PLAYER_USERNAME="player" \
|
||||||
PLAYER_PASSWORD="change-this-player-password" \
|
PLAYER_PASSWORD="change-this-player-password" \
|
||||||
|
SUMI_STORAGE_DIR="/var/lib/sumi-work" \
|
||||||
pm2 start server/server.js --name sumi-work
|
pm2 start server/server.js --name sumi-work
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -136,6 +160,8 @@ npm ci
|
|||||||
pm2 restart sumi-work
|
pm2 restart sumi-work
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果用 PM2 保存环境变量,修改 `SUMI_STORAGE_DIR` 后需要重新执行启动命令或使用 `pm2 restart sumi-work --update-env`。部署更新时只更新代码目录,不要删除 `/var/lib/sumi-work`。
|
||||||
|
|
||||||
## 7. 用 systemd 部署
|
## 7. 用 systemd 部署
|
||||||
|
|
||||||
创建服务文件:
|
创建服务文件:
|
||||||
@@ -162,6 +188,7 @@ Environment=PORT=3030
|
|||||||
Environment=ADMIN_TOKEN=change-this-admin-token
|
Environment=ADMIN_TOKEN=change-this-admin-token
|
||||||
Environment=PLAYER_USERNAME=player
|
Environment=PLAYER_USERNAME=player
|
||||||
Environment=PLAYER_PASSWORD=change-this-player-password
|
Environment=PLAYER_PASSWORD=change-this-player-password
|
||||||
|
Environment=SUMI_STORAGE_DIR=/var/lib/sumi-work
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -192,7 +219,7 @@ sudo systemctl restart sumi-work
|
|||||||
|
|
||||||
## 8. Nginx 反向代理
|
## 8. Nginx 反向代理
|
||||||
|
|
||||||
建议让 Nginx 只反向代理到 Node 服务,不要直接把 `server/uploads/packages` 暴露成静态目录。安装包下载必须走 `/api/software/:id/download`,这样才能验证玩家登录。
|
建议让 Nginx 只反向代理到 Node 服务,不要直接把上传安装包目录暴露成静态目录。安装包下载必须走 `/api/software/:id/download`,这样才能验证玩家登录。
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
|
|
||||||
@@ -370,21 +397,35 @@ curl -i "http://127.0.0.1:3030/api/software/sumi-launcher/check-update?version=1
|
|||||||
|
|
||||||
## 11. 数据与备份
|
## 11. 数据与备份
|
||||||
|
|
||||||
需要持久化和备份:
|
生产环境需要把运行时数据放在 Git 部署目录外。推荐:
|
||||||
|
|
||||||
- `server/data/software.json`:软件元数据
|
- `SUMI_STORAGE_DIR=/var/lib/sumi-work`
|
||||||
- `server/data/players.json`:玩家账号数据
|
- `/var/lib/sumi-work/data/software.json`:软件元数据
|
||||||
- `server/uploads/icons/`:上传的软件图标
|
- `/var/lib/sumi-work/data/players.json`:玩家账号数据
|
||||||
- `server/uploads/packages/`:上传的软件安装包
|
- `/var/lib/sumi-work/uploads/icons/`:上传的软件图标
|
||||||
- `server/uploads/manifests/`:上传的 version.json 文件
|
- `/var/lib/sumi-work/uploads/packages/`:上传的软件安装包
|
||||||
|
- `/var/lib/sumi-work/uploads/manifests/`:上传的 version.json 文件
|
||||||
|
|
||||||
|
未设置 `SUMI_STORAGE_DIR`、`SUMI_DATA_DIR`、`SUMI_UPLOAD_DIR` 时,本地开发会继续使用仓库里的 `server/data/` 和 `server/uploads/`。生产部署不要依赖这个默认路径。
|
||||||
|
|
||||||
建议备份命令:
|
建议备份命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -czf sumi-work-data-$(date +%F).tar.gz server/data server/uploads
|
tar -czf sumi-work-data-$(date +%F).tar.gz -C /var/lib sumi-work
|
||||||
```
|
```
|
||||||
|
|
||||||
恢复时把这两个目录放回仓库根目录,然后重启服务。
|
恢复时把备份内容还原到 `/var/lib/sumi-work`,确认服务环境变量仍然指向这个目录,然后重启服务。
|
||||||
|
|
||||||
|
重新部署代码时只执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/sumi.work
|
||||||
|
git pull origin main
|
||||||
|
npm ci
|
||||||
|
pm2 restart sumi-work --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
不要在部署脚本里执行 `rm -rf /var/lib/sumi-work`,也不要用新代码包覆盖这个目录。Node 服务只读取静态 HTML/CSS/JS 用于显示页面,上传软件包、version.json、玩家数据都写入持久化目录。
|
||||||
|
|
||||||
## 12. 常见问题
|
## 12. 常见问题
|
||||||
|
|
||||||
@@ -400,7 +441,7 @@ lsof -i :3030
|
|||||||
|
|
||||||
- 检查 `client_max_body_size`
|
- 检查 `client_max_body_size`
|
||||||
- 检查 `MAX_UPLOAD_BYTES`
|
- 检查 `MAX_UPLOAD_BYTES`
|
||||||
- 检查 `server/uploads/` 是否可写
|
- 检查 `SUMI_UPLOAD_DIR` 或 `/var/lib/sumi-work/uploads/` 是否可写
|
||||||
|
|
||||||
管理后台 401:
|
管理后台 401:
|
||||||
|
|
||||||
@@ -415,7 +456,7 @@ lsof -i :3030
|
|||||||
|
|
||||||
安装包被绕过下载:
|
安装包被绕过下载:
|
||||||
|
|
||||||
- 不要配置 Nginx 直接静态暴露 `server/uploads/packages/`
|
- 不要配置 Nginx 直接静态暴露上传安装包目录,例如 `/var/lib/sumi-work/uploads/packages/`
|
||||||
- 正确做法是所有请求都反代到 Node 服务
|
- 正确做法是所有请求都反代到 Node 服务
|
||||||
|
|
||||||
## 13. 快速执行清单
|
## 13. 快速执行清单
|
||||||
@@ -424,10 +465,13 @@ lsof -i :3030
|
|||||||
cd /path/to/sumi.work
|
cd /path/to/sumi.work
|
||||||
git pull origin main
|
git pull origin main
|
||||||
npm ci
|
npm ci
|
||||||
|
sudo mkdir -p /var/lib/sumi-work/data /var/lib/sumi-work/uploads/icons /var/lib/sumi-work/uploads/packages /var/lib/sumi-work/uploads/manifests
|
||||||
|
sudo chown -R "$USER":"$USER" /var/lib/sumi-work
|
||||||
export PORT=3030
|
export PORT=3030
|
||||||
export ADMIN_TOKEN="change-this-admin-token"
|
export ADMIN_TOKEN="change-this-admin-token"
|
||||||
export PLAYER_USERNAME="player"
|
export PLAYER_USERNAME="player"
|
||||||
export PLAYER_PASSWORD="change-this-player-password"
|
export PLAYER_PASSWORD="change-this-player-password"
|
||||||
|
export SUMI_STORAGE_DIR="/var/lib/sumi-work"
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+22
-7
@@ -9,8 +9,9 @@ import multer from "multer";
|
|||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const projectRoot = path.resolve(__dirname, "..");
|
const projectRoot = path.resolve(__dirname, "..");
|
||||||
const dataDir = path.join(__dirname, "data");
|
const storageRoot = process.env.SUMI_STORAGE_DIR ? path.resolve(process.env.SUMI_STORAGE_DIR) : "";
|
||||||
const uploadDir = path.join(__dirname, "uploads");
|
const dataDir = path.resolve(process.env.SUMI_DATA_DIR || (storageRoot ? path.join(storageRoot, "data") : path.join(__dirname, "data")));
|
||||||
|
const uploadDir = path.resolve(process.env.SUMI_UPLOAD_DIR || (storageRoot ? path.join(storageRoot, "uploads") : path.join(__dirname, "uploads")));
|
||||||
const packageDir = path.join(uploadDir, "packages");
|
const packageDir = path.join(uploadDir, "packages");
|
||||||
const iconDir = path.join(uploadDir, "icons");
|
const iconDir = path.join(uploadDir, "icons");
|
||||||
const manifestDir = path.join(uploadDir, "manifests");
|
const manifestDir = path.join(uploadDir, "manifests");
|
||||||
@@ -534,12 +535,24 @@ function isPathInside(parent, child) {
|
|||||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePackagePath(item) {
|
function resolveUploadPathFromUrl(publicUrl, prefix, directory) {
|
||||||
if (item.storagePath) return path.resolve(item.storagePath);
|
if (!publicUrl?.startsWith(prefix)) return "";
|
||||||
if (item.downloadUrl?.startsWith("/uploads/packages/")) {
|
try {
|
||||||
return path.resolve(packageDir, path.basename(decodeURIComponent(item.downloadUrl)));
|
return path.resolve(directory, path.basename(decodeURIComponent(publicUrl)));
|
||||||
}
|
} catch {
|
||||||
return "";
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePackagePath(item) {
|
||||||
|
const publicPackagePath = resolveUploadPathFromUrl(item.downloadUrl, "/uploads/packages/", packageDir);
|
||||||
|
if (item.storagePath) {
|
||||||
|
const storagePath = path.resolve(item.storagePath);
|
||||||
|
if (isPathInside(packageDir, storagePath)) return storagePath;
|
||||||
|
if (publicPackagePath) return publicPackagePath;
|
||||||
|
return storagePath;
|
||||||
|
}
|
||||||
|
return publicPackagePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupUploadedFiles(req) {
|
async function cleanupUploadedFiles(req) {
|
||||||
@@ -944,6 +957,8 @@ await ensureStorage();
|
|||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`sumi.work software center running at http://localhost:${port}`);
|
console.log(`sumi.work software center running at http://localhost:${port}`);
|
||||||
|
console.log(`data directory: ${dataDir}`);
|
||||||
|
console.log(`upload directory: ${uploadDir}`);
|
||||||
if (!process.env.ADMIN_TOKEN) {
|
if (!process.env.ADMIN_TOKEN) {
|
||||||
console.log("ADMIN_TOKEN is not set. Using development token: sumi-admin");
|
console.log("ADMIN_TOKEN is not set. Using development token: sumi-admin");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user