refactor: simplify theme system to filesystem-only with self-contained portal
主题系统从带数据库/事务/repo 层/远程下载/配置管理/主题市场的重型架构, 彻底简化为纯文件系统操作: 后端: - 主题数据全部存放在 data/themes/ 目录,不经过数据库 - 删除主题配置功能(managed/raw/redirect schema、配置存取 API、 ThemeConfiguration model、theme_configurations 表、repo 层) - 删除远程下载(URL 安装、GitHub Release 更新、SSRF 防护、SHA-256) - 删除版本范围检查(semver 解析/比较/匹配、pikaVersion 校验) - 删除主题市场 - ThemeService 不再持有 db,合并为 theme.go + theme_util.go 两个文件 - 主题类型从 models 包移到 service 包(非数据库模型) - 保留:ZIP 上传解压(含目录穿越防护)、目录扫描列表、启用/删除、 覆盖安装、默认主题回退、启动恢复、入口 HTML 渲染缓存 前端 portal(官方默认主题): - 从 47 个碎片文件精简到 15 个,按标准目录组织 - 删除 theme-sdk 包,API 客户端和类型内联回 portal,完全自包含 - 按职责拆分:types.ts / api.ts / constants.ts / lib/utils.ts / contexts/ColorMode.tsx / hooks/index.ts / components.tsx / pages/ - 每个页面一个文件,图表和子组件内联,fork 二开只需看一个文件
No files matched your search
@@ -48,17 +48,6 @@ jobs:
|
||||
make build-agents
|
||||
make build-servers
|
||||
|
||||
# 验证 index.html 模板代码
|
||||
- name: 验证 index.html 模板代码
|
||||
run: |
|
||||
if grep -Fq "__PIKA_CUSTOM_JS__" web/dist/index.html && grep -Fq "__PIKA_CUSTOM_CSS__" web/dist/index.html && grep -Fq "[[.SystemNameZh]]" web/dist/index.html; then
|
||||
cp web/dist/index.html web/dist/index.html.tmpl
|
||||
echo "✓ index.html 模板已准备完成"
|
||||
else
|
||||
echo "✗ index.html 模板代码未找到"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 列出编译产物
|
||||
- name: 列出编译产物
|
||||
run: |
|
||||
|
||||
@@ -54,17 +54,6 @@ jobs:
|
||||
run: |
|
||||
make build-release
|
||||
|
||||
# 验证 index.html 模板代码
|
||||
- name: 验证 index.html 模板代码
|
||||
run: |
|
||||
if grep -Fq "__PIKA_CUSTOM_JS__" web/dist/index.html && grep -Fq "__PIKA_CUSTOM_CSS__" web/dist/index.html && grep -Fq "[[.SystemNameZh]]" web/dist/index.html; then
|
||||
cp web/dist/index.html web/dist/index.html.tmpl
|
||||
echo "✓ index.html 模板已准备完成"
|
||||
else
|
||||
echo "✗ index.html 模板代码未找到"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 列出编译产物
|
||||
- name: 列出编译产物
|
||||
run: |
|
||||
|
||||
@@ -17,7 +17,6 @@ ARG TARGETARCH
|
||||
COPY ./bin/pika-linux-${TARGETARCH} ./pika
|
||||
COPY ./bin/agents ./bin/agents
|
||||
COPY ./web/dist ./web/dist
|
||||
COPY ./web/public/logo.png ./web/public/logo.png
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -19,7 +19,12 @@ GOFLAGS=CGO_ENABLED=0
|
||||
|
||||
# 构建前端
|
||||
build-web:
|
||||
cd web && yarn && yarn build
|
||||
npm ci --prefix web/admin
|
||||
npm run build --prefix web/admin
|
||||
npm ci --prefix web/portal
|
||||
npm run build --prefix web/portal
|
||||
node web/scripts/assemble-web.mjs
|
||||
node web/scripts/verify-dist.mjs
|
||||
|
||||
# 构建服务端(开发)
|
||||
build-server:
|
||||
@@ -72,6 +77,7 @@ build-release:
|
||||
clean:
|
||||
rm -rf bin/*
|
||||
rm -rf web/dist
|
||||
rm -rf web/admin/dist web/portal/dist
|
||||
|
||||
# 运行测试
|
||||
test:
|
||||
@@ -80,7 +86,6 @@ test:
|
||||
# 代码格式化
|
||||
fmt:
|
||||
go fmt ./...
|
||||
cd web && yarn format
|
||||
|
||||
# 代码检查
|
||||
lint:
|
||||
|
||||
@@ -19,6 +19,10 @@ server:
|
||||
- "0.0.0.0/0"
|
||||
|
||||
App:
|
||||
# 主题文件存储在数据库之外,Docker 部署必须持久化 /app/data。
|
||||
Theme:
|
||||
Dir: "./data/themes"
|
||||
|
||||
JWT:
|
||||
Secret: "" # 替换为任意 UUID 字符串
|
||||
ExpiresHours: 168 # 7天
|
||||
@@ -35,7 +39,7 @@ App:
|
||||
Issuer: "https://your-oidc-provider.com"
|
||||
ClientID: "your-client-id"
|
||||
ClientSecret: "your-client-secret"
|
||||
RedirectURL: "http://localhost:8080/oidc/callback" # 前端回调页面
|
||||
RedirectURL: "http://localhost:8080/admin/oidc/callback" # 前端回调页面
|
||||
|
||||
# GitHub OAuth 认证配置(可选)
|
||||
# 创建 GitHub OAuth App: https://github.com/settings/developers
|
||||
@@ -43,7 +47,7 @@ App:
|
||||
Enabled: false
|
||||
ClientID: "your-github-client-id"
|
||||
ClientSecret: "your-github-client-secret"
|
||||
RedirectURL: "http://localhost:8080/github/callback" # 前端回调页面
|
||||
RedirectURL: "http://localhost:8080/admin/github/callback" # 前端回调页面
|
||||
AllowedUsers: # 允许登录的 GitHub 用户名白名单,不配置或留空则允许所有用户
|
||||
- "your-github-username"
|
||||
- "another-username"
|
||||
|
||||
@@ -15,6 +15,10 @@ server:
|
||||
- "0.0.0.0/0"
|
||||
|
||||
App:
|
||||
# 可安装公开主题。PIKA_THEME_DIR 非空时会覆盖 Dir。
|
||||
Theme:
|
||||
Dir: "./data/themes"
|
||||
|
||||
JWT:
|
||||
Secret: "" # 替换为任意 UUID 字符串
|
||||
ExpiresHours: 168 # 7天
|
||||
@@ -31,7 +35,7 @@ App:
|
||||
Issuer: "https://your-oidc-provider.com"
|
||||
ClientID: "your-client-id"
|
||||
ClientSecret: "your-client-secret"
|
||||
RedirectURL: "http://localhost:8080/oidc/callback" # 前端回调页面
|
||||
RedirectURL: "http://localhost:8080/admin/oidc/callback" # 前端回调页面
|
||||
|
||||
# GitHub OAuth 认证配置(可选)
|
||||
# 创建 GitHub OAuth App: https://github.com/settings/developers
|
||||
@@ -39,7 +43,7 @@ App:
|
||||
Enabled: false
|
||||
ClientID: "your-github-client-id"
|
||||
ClientSecret: "your-github-client-secret"
|
||||
RedirectURL: "http://localhost:8080/github/callback" # 前端回调页面
|
||||
RedirectURL: "http://localhost:8080/admin/github/callback" # 前端回调页面
|
||||
AllowedUsers: # 允许登录的 GitHub 用户名白名单,不配置或留空则允许所有用户
|
||||
- "your-github-username"
|
||||
- "another-username"
|
||||
@@ -52,4 +56,3 @@ App:
|
||||
RetentionDays: 7 # 数据保留时长
|
||||
WriteTimeout: 60 # 写超时时间(秒)
|
||||
QueryTimeout: 60 # 读超时时间(秒)
|
||||
|
||||
@@ -37,6 +37,7 @@ services:
|
||||
container_name: pika-server
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml
|
||||
- ./data/pika:/app/data
|
||||
# 开启 Geo 配置后打开下面的配置
|
||||
# - ./GeoLite2-City.mmdb:/app/GeoLite2-City.mmdb
|
||||
ports:
|
||||
@@ -53,4 +54,3 @@ services:
|
||||
networks:
|
||||
pika-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Pika 主题使用与开发指南
|
||||
|
||||
Pika 的第三方主题是一个完整的公开页面 SPA。它可以替换服务器列表、服务器详情、监控列表和监控详情,但不能替换登录页、OAuth/OIDC 回调或管理后台。
|
||||
|
||||
## 管理员使用
|
||||
|
||||
进入“管理后台 → 系统设置 → 主题管理”后,可以:
|
||||
|
||||
- 上传本地 ZIP 安装主题;
|
||||
- 查看预览、兼容状态、作者和版本;
|
||||
- 启用、配置或删除主题。
|
||||
|
||||
默认主题 `default` 随 Pika 构建发布,不允许覆盖或删除。当前主题目录、清单或入口损坏时,Pika 会整体回退到默认主题并修正活动主题配置。当前启用的第三方主题必须先切换到其他主题才能删除。
|
||||
|
||||
主题切换不会刷新管理后台。打开或刷新公开页面即可看到新主题。系统名称、自定义 CSS/JS 等公开配置保存后同样在公开页面刷新时生效。
|
||||
|
||||
### 服务端配置
|
||||
|
||||
```yaml
|
||||
App:
|
||||
Theme:
|
||||
Dir: "./data/themes"
|
||||
```
|
||||
|
||||
- `PIKA_THEME_DIR` 非空时覆盖 `Dir`;
|
||||
- SQLite 与 PostgreSQL Docker 部署都必须持久化 `/app/data`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
第三方主题包含 JavaScript,并与 Pika 在同一 origin 执行。它是“受信任插件”,不是 CSS 皮肤,也不是安全沙箱。主题可以读取浏览器同源存储并访问同源 API,因此只应安装完全信任、能审查源码和发布流程的主题。
|
||||
|
||||
Pika 会校验 ZIP 路径、符号链接、文件数量、解压大小、清单、入口和预览图,但这些检查不能证明主题 JavaScript 没有恶意行为。
|
||||
|
||||
登录页和管理后台始终从官方 `/admin/assets/*` 加载;第三方主题只从 `/theme-assets/*` 加载。管理员设置的 `customCSS` 和 `customJS` 也只注入公开主题入口。
|
||||
|
||||
## 主题包格式
|
||||
|
||||
ZIP 根目录必须直接包含以下内容,不能额外套一层目录:
|
||||
|
||||
```text
|
||||
pika-theme.json
|
||||
preview.png
|
||||
dist/
|
||||
├── index.html
|
||||
└── assets/
|
||||
├── index-<content-hash>.js
|
||||
└── index-<content-hash>.css
|
||||
```
|
||||
|
||||
`dist/index.html` 是固定入口。所有构建资源必须使用 `/theme-assets/` 绝对前缀,并建议使用内容 hash 文件名。
|
||||
|
||||
Vite 示例:
|
||||
|
||||
```ts
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(({command}) => ({
|
||||
base: command === 'serve' ? '/' : '/theme-assets/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
入口 HTML 建议保留注入点:
|
||||
|
||||
```html
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- pika:head -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- pika:body -->
|
||||
</body>
|
||||
```
|
||||
|
||||
服务端会在响应入口时注入 `window.PikaRuntime`,并设置站点标题、description 和 `/api/logo` favicon。没有注入标记时,会在 `</head>` 和 `</body>` 前注入。
|
||||
|
||||
## 清单示例
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "pika-minimal",
|
||||
"name": "Pika Minimal",
|
||||
"description": "简洁状态页",
|
||||
"version": "1.2.0",
|
||||
"author": "example",
|
||||
"homepage": "https://github.com/example/pika-minimal",
|
||||
"license": "MIT",
|
||||
"preview": "preview.png",
|
||||
"entry": "dist/index.html",
|
||||
"apiVersion": "v1",
|
||||
"capabilities": [
|
||||
"server-list",
|
||||
"server-detail",
|
||||
"monitor-list",
|
||||
"monitor-detail"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`id` 只能包含字母、数字、下划线和连字符,最长 64 字符;`default`、`admin`、`official`、`system`、`api`、`assets` 和 `theme-assets` 是保留名称。四项核心 capability 缺一时主题不可启用。
|
||||
|
||||
## 公开 API 与 SDK
|
||||
|
||||
主题启动时可读取:
|
||||
|
||||
```ts
|
||||
window.PikaRuntime
|
||||
```
|
||||
|
||||
也可以请求:
|
||||
|
||||
```http
|
||||
GET /api/config
|
||||
```
|
||||
|
||||
该接口只返回公开字段,不返回 Logo Base64、自定义 CSS/JS 原文、JWT/OAuth Secret、API Key、通知或 DNS 凭据。
|
||||
|
||||
稳定 TypeScript 契约位于 `web/theme-sdk`。官方默认主题 `web/portal` 也使用同一个 SDK,通过 `pika` 客户端取得公开数据,通过 `getRuntimeConfig()` 读取注入配置。第三方主题不得引用 `web/admin` 或 `web/portal` 源码,也不应复制管理 API 或自行维护第二套请求封装。站内链接使用以 `/` 开头的绝对路径,以保证直接刷新 SPA 路由时仍由 Pika 正确回退到主题入口。
|
||||
|
||||
## 打包前检查
|
||||
|
||||
发布 ZIP 前至少确认:
|
||||
|
||||
1. `pika-theme.json` 位于 ZIP 根目录,ID 与主题一致;
|
||||
2. `dist/index.html` 和预览图片存在;
|
||||
3. JS/CSS 引用 `/theme-assets/` 且文件名包含内容 hash;
|
||||
4. `/`、`/servers/:id`、`/monitors`、`/monitors/:id` 可直接刷新;
|
||||
5. 不依赖管理后台源码或未公开 API;
|
||||
6. 在 light、dark、system 和移动宽度下测试。
|
||||
@@ -0,0 +1,301 @@
|
||||
# Pika 可安装主题系统
|
||||
|
||||
Pika 的「主题」是一套可安装的公开页面 SPA。它可以替换公开首页、服务器详情、监控列表和监控详情,但**不能**替换登录页、OAuth/OIDC 回调或管理后台。
|
||||
|
||||
本文描述当前已落地的实现形态,是代码改动的验收基线。
|
||||
|
||||
## 1. 设计取舍与安全定位
|
||||
|
||||
### 1.1 当前形态:单 origin,无主题市场
|
||||
|
||||
Pika 公开主题和管理后台运行在**同一 origin**,由单个 Pika 进程通过路由前缀区分:
|
||||
|
||||
```text
|
||||
/api/*、/ws/* → Pika Go API / WebSocket
|
||||
/admin/* → 官方管理 SPA(web/dist/admin),不允许主题覆盖
|
||||
/admin/assets/* → 官方管理静态资源
|
||||
/theme-assets/* → 当前公开主题静态资源
|
||||
其他公开 GET 路径 → 当前公开主题入口(运行时渲染注入)
|
||||
```
|
||||
|
||||
主题来源**只有一种**:管理员本地上传 ZIP。Pika 不从远程地址下载主题,也不提供主题市场。
|
||||
|
||||
**没有主题市场、没有远程下载、没有自动更新**。曾考虑过可配置的远程主题索引(市场)和 URL/GitHub Release 安装,但由于下面的安全约束,这些能力在单 origin 模型下会引入不可接受的攻击面,因此全部移除。
|
||||
|
||||
### 1.2 同源执行风险(必须理解的前提)
|
||||
|
||||
第三方主题包含 JavaScript,并在 Pika 公开站点的**同一 origin** 执行。它本质上是「受信任插件」,不是无权限皮肤,也不是安全沙箱:
|
||||
|
||||
- 主题可以读取浏览器同源存储(包括 localStorage 中的登录 token);
|
||||
- 主题可以带着浏览器自动附加的凭证发起同源 API 请求;
|
||||
- 即使把登录态从 localStorage 换成 HttpOnly Cookie,HttpOnly 只阻止「JS 读取 Cookie 值」,**不阻止**「JS 发起带凭证的认证请求」。
|
||||
|
||||
Pika 是单用户系统,登录 token 即全站最高权限。因此**恶意主题 = 管理后台被完全接管**,进而等于所有被监控服务器的信息暴露。
|
||||
|
||||
正因如此,当前的安全策略是:
|
||||
|
||||
1. **不提供公开主题市场,不做远程下载**,避免「任何人都能投毒主题」的入口;
|
||||
2. 主题来源限于「管理员本地上传 ZIP」,安全责任收敛到「管理员是否信任该来源」;
|
||||
3. 安装、启用时强制展示「主题是同源可信代码、非沙箱」的警告并二次确认;
|
||||
4. 管理后台、登录页、认证回调**始终**从官方 `/admin/assets/*` 加载,第三方主题只从 `/theme-assets/*` 加载。
|
||||
|
||||
### 1.3 未来若要开放无审核市场的前置条件
|
||||
|
||||
如果未来确实需要开放「任何人可提交」的主题市场,**必须先完成 origin 隔离**,例如:
|
||||
|
||||
```text
|
||||
status.example.com # 公开主题(低信任)
|
||||
admin.example.com # 管理后台 + 认证(高信任)
|
||||
```
|
||||
|
||||
不同 origin 后,公开主题的 JS 物理上无法读取 admin origin 的存储或发起带凭证的 admin API 请求。
|
||||
|
||||
实现方式推荐「单进程 + Host 分流」(仍是单个 Pika 二进制,按 Host 头选择返回 admin SPA 还是公开主题),而非双进程。当前代码的主题管理逻辑(安装/ZIP 安全/清单校验/渲染缓存/文件系统回滚)完全不关心 origin,origin 隔离只需要调整路由层,绝大多数代码可原样复用。
|
||||
|
||||
**在完成 origin 隔离之前,不应重新引入主题市场。**
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
### 2.1 源码目录
|
||||
|
||||
```text
|
||||
web/
|
||||
├── admin/ # 独立的官方管理 React/Vite 项目(base: /admin/)
|
||||
├── portal/ # 独立的官方默认主题 React/Vite 项目(生产 base: /theme-assets/)
|
||||
├── theme-sdk/ # 第三方主题开发契约和 TypeScript 类型
|
||||
└── scripts/ # 组装并校验服务端 Web 产物
|
||||
```
|
||||
|
||||
`admin`、`portal`、`theme-sdk` 分别维护依赖、锁文件和构建产物,根 `web` 不是 npm workspace。admin 与 portal 不共享源码,也不能相互 import;admin 不依赖 theme-sdk,portal 通过 `file:../theme-sdk` 依赖 theme-sdk。第三方主题只能依赖 `theme-sdk`。
|
||||
|
||||
### 2.2 构建产物
|
||||
|
||||
```text
|
||||
web/dist/
|
||||
├── admin/
|
||||
│ ├── index.html
|
||||
│ └── assets/
|
||||
└── default-theme/
|
||||
├── pika-theme.json
|
||||
├── preview.png(或 dist/logo.png)
|
||||
└── dist/
|
||||
├── index.html
|
||||
└── assets/
|
||||
```
|
||||
|
||||
`web/scripts/assemble-web.mjs` 把 admin 和 portal 各自的构建产物组装到上述结构;`web/scripts/verify-dist.mjs` 校验 admin 的 HTML 含 `/admin/assets/` 且不含 `/theme-assets/`,portal 的 HTML 含 `/theme-assets/` 且不含 `/admin/assets/`——这是防止主题与后台资源前缀串台的硬护栏。
|
||||
|
||||
### 2.3 运行时安装目录
|
||||
|
||||
```text
|
||||
data/themes/
|
||||
├── pika-minimal/
|
||||
│ ├── pika-theme.json
|
||||
│ ├── preview.png
|
||||
│ └── dist/
|
||||
└── ...
|
||||
```
|
||||
|
||||
`default` 主题不复制到 `data/themes`,始终从 `web/dist/default-theme` 读取,且不可删除或覆盖。
|
||||
|
||||
## 3. 后端模块结构
|
||||
|
||||
主题后端逻辑按职责拆分在 `internal/service/` 下,共享状态集中在 `ThemeService` 结构体:
|
||||
|
||||
```text
|
||||
internal/service/
|
||||
├── theme_service.go # 常量/错误/正则、ThemeService 结构体、构造函数
|
||||
├── theme_manager.go # 列表/安装/启用/删除/活动主题回退
|
||||
├── theme_manifest.go # 清单解析与字段校验
|
||||
├── theme_archive.go # ZIP 安全解压
|
||||
├── theme_filesystem.go # 默认主题访问、路径校验、启动恢复
|
||||
├── theme_renderer.go # 入口 HTML 运行时注入与缓存
|
||||
└── theme_service_test.go # 上述能力的单元测试
|
||||
|
||||
internal/models/theme.go # 清单与外观的数据模型
|
||||
internal/handler/theme_handler.go # 主题管理 HTTP handler
|
||||
internal/handler/web_handler.go # 公开配置 / 静态资源 / SPA fallback
|
||||
```
|
||||
|
||||
主题数据全部存放在文件系统,**不经过数据库**。`ThemeService` 不持有 `*gorm.DB`,所有操作(列表/安装/启用/删除)都是对 `data/themes` 目录的扫描与文件操作。唯一需要持久化的运行时状态是「当前启用哪个主题」,通过 `appearance_config` Property 存储。
|
||||
|
||||
## 4. 主题包协议
|
||||
|
||||
### 4.1 ZIP 结构
|
||||
|
||||
ZIP 根目录必须直接包含清单,不允许额外套一层目录:
|
||||
|
||||
```text
|
||||
pika-minimal.zip
|
||||
├── pika-theme.json
|
||||
├── preview.png
|
||||
└── dist/
|
||||
├── index.html
|
||||
└── assets/
|
||||
```
|
||||
|
||||
### 4.2 `pika-theme.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "pika-minimal",
|
||||
"name": "Pika Minimal",
|
||||
"description": "简约服务器状态主题",
|
||||
"version": "1.2.0",
|
||||
"author": "example",
|
||||
"homepage": "https://github.com/example/pika-minimal",
|
||||
"license": "MIT",
|
||||
"preview": "preview.png",
|
||||
"entry": "dist/index.html",
|
||||
"apiVersion": "v1",
|
||||
"capabilities": ["server-list", "server-detail", "monitor-list", "monitor-detail"]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 字段约束
|
||||
|
||||
- `schemaVersion`:必须为 `1`。
|
||||
- `id`:正则 `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`,保留名 `default`/`admin`/`official`/`system`/`api`/`assets`/`theme-assets`,大小写不敏感。
|
||||
- `version`:主题版本号,仅作展示。
|
||||
- `entry`:必须是 `dist/index.html`。
|
||||
- `apiVersion`:必须为 `v1`。
|
||||
- `capabilities`:四项核心能力 `server-list`/`server-detail`/`monitor-list`/`monitor-detail` 缺一不可。
|
||||
- `preview`:包内相对路径,最大 5 MiB。
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
主题数据全部存放在文件系统,不使用数据库表。`pika-theme.json` 是主题元数据的唯一事实来源,运行时直接扫描目录读取。唯一需要持久化的是「当前启用哪个主题」:
|
||||
|
||||
```go
|
||||
const PropertyIDAppearanceConfig = "appearance_config"
|
||||
|
||||
type AppearanceConfig struct {
|
||||
ActiveTheme string `json:"activeTheme"` // 默认 "default"
|
||||
DefaultColorMode string `json:"defaultColorMode"` // 默认 "system"
|
||||
}
|
||||
```
|
||||
|
||||
浏览器用户的个人明暗偏好保存在浏览器本地;`defaultColorMode` 只在用户没有个人偏好时生效。
|
||||
|
||||
## 6. HTTP API
|
||||
|
||||
### 6.1 公开配置
|
||||
|
||||
```http
|
||||
GET /api/config
|
||||
```
|
||||
|
||||
只返回公开字段(系统名称、Logo 路径、ICP、版本、默认视图、默认明暗模式、当前主题 id/version、功能开关)。不返回 Logo Base64 原文、customJS/customCSS 原文、JWT/OAuth Secret、API Key、通知渠道、DNS 凭据。
|
||||
|
||||
### 6.2 管理接口(均在 `/api/admin/themes` 下,需管理员认证)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/` | 列出所有主题(含默认) |
|
||||
| POST | `/upload` | 上传 ZIP 安装(multipart `file`) |
|
||||
| PUT | `/:id/activate` | 启用主题(重新校验清单/入口/兼容性) |
|
||||
| DELETE | `/:id` | 删除主题(default 和当前启用主题不可删) |
|
||||
| GET | `/:id/preview` | 读取预览图 |
|
||||
|
||||
上传新主题时 id 已存在返回 `409`;当前启用主题不可删除返回 `409`;默认主题不可删除/修改返回 `400`。
|
||||
|
||||
## 7. 静态资源与 SPA 路由
|
||||
|
||||
路由优先级(由具体前缀到通配):
|
||||
|
||||
1. `/api/*`、`/ws/*`:API 与 WebSocket。
|
||||
2. `/admin/assets/*`:官方管理静态资源,禁止目录浏览/穿越/HTML5 fallback。
|
||||
3. `/theme-assets/*`:当前主题 `dist/*`,缺失文件返回 404,不逐文件回退默认主题。
|
||||
4. `/admin`、`/admin/*`:官方管理 SPA,返回管理 `index.html`(含登录与认证回调)。
|
||||
5. 公开 GET 请求:路径对应主题真实文件则返回文件;带扩展名但文件不存在返回 404;其余返回渲染后的当前主题 `index.html`;当前主题入口损坏则整体回退默认主题。
|
||||
6. 非 GET 或未知 `/api/*`:JSON 404,绝不返回 SPA HTML。
|
||||
|
||||
全局静态中间件已删除,改为显式 handler。官方和主题静态文件统一设置 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy: strict-origin-when-cross-origin`;HTML 入口 `Cache-Control: no-cache`,hash 资源 `immutable`。
|
||||
|
||||
## 8. 入口 HTML 运行时渲染
|
||||
|
||||
服务端读取主题 `index.html`,在内存注入 `window.PikaRuntime`(只含公开配置),并设置 `<title>`、`<meta description>`、`<link icon>`。主题可在 HTML 中放 `<!-- pika:head -->`/`<!-- pika:body -->` 标记控制注入点,否则注入到 `</head>`/`</body>` 前。`customCSS`/`customJS` 单独注入到公开入口(不注入管理入口)。渲染结果按 `theme id + version + index 内容 + SystemConfig/Appearance UpdatedAt + pika version` 缓存,主题切换后主动清缓存。
|
||||
|
||||
HTML 转义使用 `html/template` 和 `encoding/json`,禁止字符串拼接生成 `<script>` 内 JSON。
|
||||
|
||||
## 9. 安装、更新与回滚
|
||||
|
||||
### 9.1 安装流程
|
||||
|
||||
```text
|
||||
接收 ZIP → 写入 staging → 校验大小/文件数 → 安全解压 → 校验清单/入口/预览 → 检查 id 冲突 → rename 到正式目录 → 健康检查 → 清理 staging
|
||||
```
|
||||
|
||||
任一步失败删除 staging,不触碰已安装主题。
|
||||
|
||||
### 9.2 覆盖安装
|
||||
|
||||
上传的 ZIP 与已安装主题 id 相同时,走覆盖安装(`installArchiveLocked` 的 replace 模式):
|
||||
|
||||
```text
|
||||
取写锁 → 校验且新包 id 与目标一致 → 原目录 rename 为 .backup-* → 新目录 rename 为正式 → 健康检查 → 删 backup
|
||||
```
|
||||
|
||||
健康检查失败时移走新目录、backup rename 回正式、继续用旧主题、记录日志。
|
||||
|
||||
### 9.3 启动恢复
|
||||
|
||||
进程可能在原子替换期间被杀,启动时执行:清理 24h 以上的 staging;唯一有效 `.backup-*` 恢复;正式与备份并存时以校验通过的正式为准;状态不明则保留全部并告警,活动主题回退默认。
|
||||
|
||||
### 9.4 活动主题回退
|
||||
|
||||
`appearance_config.activeTheme` 为空、目录缺失、清单损坏、入口缺失时,自动回退默认主题并修正配置(用 `fallbackMu` 串行化,避免每个请求都写库)。
|
||||
|
||||
## 10. ZIP 安全
|
||||
|
||||
### 10.1 硬上限(代码内固定,不可配置关闭)
|
||||
|
||||
```text
|
||||
压缩包最大 64 MiB | 最大文件数 5000 | 单文件最大 64 MiB | 解压总大小 256 MiB | 清单 1 MiB | 预览图 5 MiB
|
||||
```
|
||||
|
||||
### 10.2 路径与文件类型防御
|
||||
|
||||
ZIP Slip(`../`)、绝对路径、Windows 盘符、反斜杠、符号链接、硬链接、设备文件、命名管道、大小写重复、Unicode 归一化重复、清单路径越界、文件数/总大小溢出。只解压普通文件和目录,权限归一化为目录 0755 / 文件 0644。
|
||||
|
||||
## 11. 前端构建
|
||||
|
||||
- `web/admin`:`base: '/admin/'`,独立 React + antd 项目,自带 HTTP 客户端和 `ColorModeContext`,不依赖 theme-sdk。
|
||||
- `web/portal`:`base: 生产 '/theme-assets/' / 开发 '/'`,默认主题,所有数据访问经 `@pika-monitor/theme-sdk` 的 `pika` 客户端。
|
||||
- `web/theme-sdk`:导出 `pika` 客户端、`PikaRuntimeConfig`/`PikaThemeManifest` 等类型、`getRuntimeConfig`/`resolveColorMode` 工具。源码即产物(`tsc --noEmit` 仅类型检查)。
|
||||
|
||||
`make build-web` 依次构建 theme-sdk(类型检查)→ admin → portal,再 assemble + verify。
|
||||
|
||||
## 12. 管理后台交互
|
||||
|
||||
「系统设置 → 主题管理」包含:
|
||||
|
||||
- 当前主题信息、已安装主题卡片(预览图、名称、版本、作者、兼容状态);
|
||||
- 启用、删除;
|
||||
- 上传 ZIP;
|
||||
- 所有安装/启用统一经「主题是同源可信代码、非沙箱」的红色二次确认 Modal;
|
||||
- 页面顶部常驻安全提示。
|
||||
|
||||
按钮约束:当前主题不显示删除;默认主题不显示删除;不兼容主题可查看可删除但不能启用;操作期间禁止重复提交;启用成功不刷新管理 SPA,只提示公开页面已切换。
|
||||
|
||||
## 13. 配置
|
||||
|
||||
```yaml
|
||||
App:
|
||||
Theme:
|
||||
Dir: "./data/themes"
|
||||
```
|
||||
|
||||
- `PIKA_THEME_DIR` 非空时覆盖 `Dir`;
|
||||
- SQLite 与 PostgreSQL Docker 部署都必须持久化 `/app/data`。
|
||||
|
||||
## 14. 测试覆盖
|
||||
|
||||
Go 单元测试覆盖:ZIP 安全(slip/绝对路径/盘符/symlink/大小写·Unicode 重复/文件数超限)、清单校验(保留 id/非法 schema·api/缺 capability)、HTML 元数据转义、文件系统恢复、完整安装/启用/删除生命周期。
|
||||
|
||||
## 15. 不做的事(明确边界)
|
||||
|
||||
- 不提供主题市场、不做远程下载、不做自动更新(见 §1.1);
|
||||
- 不做 origin 隔离(当前单进程单 origin;未来开放市场的前置条件,见 §1.3);
|
||||
- 不改前端 admin/portal/theme-sdk 的项目结构和构建。
|
||||
@@ -2,14 +2,10 @@ package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dushixiang/pika/internal/assets"
|
||||
"github.com/dushixiang/pika/internal/config"
|
||||
"github.com/dushixiang/pika/internal/handler"
|
||||
"github.com/dushixiang/pika/internal/migrate"
|
||||
@@ -124,36 +120,6 @@ func setupApi(app *orz.App, components *AppComponents) error {
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(ErrorHandler(logger))
|
||||
|
||||
webDir := assets.WebDir()
|
||||
if err := assets.RenderUIFilesInDir(webDir, components.PropertyService); err != nil {
|
||||
return fmt.Errorf("render ui files: %w", err)
|
||||
}
|
||||
|
||||
// 静态文件服务
|
||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
Skipper: func(c *echo.Context) bool {
|
||||
// 不处理接口
|
||||
if strings.HasPrefix(c.Request().RequestURI, "/api") {
|
||||
return true
|
||||
}
|
||||
// 不处理WebSocket
|
||||
if strings.HasPrefix(c.Request().RequestURI, "/ws") {
|
||||
return true
|
||||
}
|
||||
// 不暴露模板文件
|
||||
if strings.HasSuffix(c.Request().RequestURI, ".tmpl") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
Root: ".",
|
||||
Index: "index.html",
|
||||
HTML5: true,
|
||||
Browse: false,
|
||||
IgnoreBase: false,
|
||||
Filesystem: os.DirFS(webDir),
|
||||
}))
|
||||
|
||||
customValidator := CustomValidator{Validator: validator.New()}
|
||||
if err := customValidator.TransInit(); err != nil {
|
||||
logger.Fatal("failed to init custom validator", zap.Error(err))
|
||||
@@ -168,6 +134,7 @@ func setupApi(app *orz.App, components *AppComponents) error {
|
||||
publicApi.GET("/auth/config", components.AccountHandler.GetAuthConfig)
|
||||
publicApi.GET("/auth/oidc/url", components.AccountHandler.GetOIDCAuthURL)
|
||||
publicApi.GET("/auth/github/url", components.AccountHandler.GetGitHubAuthURL)
|
||||
publicApi.GET("/config", components.WebHandler.PublicConfig)
|
||||
|
||||
// Agent 版本和下载(完全公开,无需任何认证)
|
||||
publicApi.GET("/agent/version", components.AgentHandler.GetAgentVersion)
|
||||
@@ -293,6 +260,13 @@ func setupApi(app *orz.App, components *AppComponents) error {
|
||||
adminApi.POST("/ddns/:id/disable", components.DDNSHandler.Disable)
|
||||
adminApi.GET("/ddns/:id/records", components.DDNSHandler.GetRecords)
|
||||
adminApi.POST("/ddns/:id/trigger", components.DDNSHandler.TriggerUpdate)
|
||||
|
||||
// 可安装主题管理
|
||||
adminApi.GET("/themes", components.ThemeHandler.List)
|
||||
adminApi.POST("/themes/upload", components.ThemeHandler.Upload)
|
||||
adminApi.PUT("/themes/:id/activate", components.ThemeHandler.Activate)
|
||||
adminApi.DELETE("/themes/:id", components.ThemeHandler.Delete)
|
||||
adminApi.GET("/themes/:id/preview", components.ThemeHandler.Preview)
|
||||
}
|
||||
|
||||
// OIDC 认证路由(如果启用)
|
||||
@@ -301,23 +275,28 @@ func setupApi(app *orz.App, components *AppComponents) error {
|
||||
// GitHub 认证路由(如果启用)
|
||||
publicApi.POST("/auth/github/callback", components.AccountHandler.GitHubLogin)
|
||||
|
||||
// 官方管理 SPA 与活动公开主题使用互不重叠的资源前缀。
|
||||
e.GET("/admin/assets/*", components.WebHandler.ServeAdminAsset)
|
||||
e.GET("/theme-assets/*", components.WebHandler.ServeThemeAsset)
|
||||
e.GET("/*", components.WebHandler.ServeSPA)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func autoMigrate(database *gorm.DB) error {
|
||||
// 自动迁移数据库表
|
||||
return database.AutoMigrate(
|
||||
&models.Agent{}, // 探针
|
||||
&models.ApiKey{}, // ApiKey
|
||||
&models.AuditResult{}, // 审计历史
|
||||
&models.Property{}, // 系统属性
|
||||
&models.AlertRecord{}, // 告警记录
|
||||
&models.AlertState{}, // 告警状态
|
||||
&models.MonitorTask{}, // 服务监控
|
||||
&models.TamperEvent{}, // 防篡改事件
|
||||
&models.DDNSConfig{}, // DDNS 配置
|
||||
&models.DDNSRecord{}, // DDNS 记录
|
||||
&models.SSHLoginEvent{}, // SSH 登录事件
|
||||
&models.Agent{}, // 探针
|
||||
&models.ApiKey{}, // ApiKey
|
||||
&models.AuditResult{}, // 审计历史
|
||||
&models.Property{}, // 系统属性
|
||||
&models.AlertRecord{}, // 告警记录
|
||||
&models.AlertState{}, // 告警状态
|
||||
&models.MonitorTask{}, // 服务监控
|
||||
&models.TamperEvent{}, // 防篡改事件
|
||||
&models.DDNSConfig{}, // DDNS 配置
|
||||
&models.DDNSRecord{}, // DDNS 记录
|
||||
&models.SSHLoginEvent{}, // SSH 登录事件
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
const (
|
||||
defaultWebDir = "web/dist"
|
||||
defaultAgentDir = "bin/agents"
|
||||
defaultLogoPath = "web/public/logo.png"
|
||||
defaultLogoPath = "web/portal/public/logo.png"
|
||||
)
|
||||
|
||||
func WebDir() string {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/dushixiang/pika/internal/models"
|
||||
)
|
||||
|
||||
type SystemConfigProvider interface {
|
||||
GetSystemConfig(context.Context) (*models.SystemConfig, error)
|
||||
}
|
||||
|
||||
func RenderUIFiles(provider SystemConfigProvider) error {
|
||||
return RenderUIFilesInDir(WebDir(), provider)
|
||||
}
|
||||
|
||||
func RenderUIFilesInDir(dir string, provider SystemConfigProvider) error {
|
||||
if err := ensureIndexTemplate(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
systemConfig, err := provider.GetSystemConfig(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return renderIndexHTML(dir, systemConfig)
|
||||
}
|
||||
|
||||
func ensureIndexTemplate(dir string) error {
|
||||
tmplPath := filepath.Join(dir, "index.html.tmpl")
|
||||
if _, err := os.Stat(tmplPath); err == nil {
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
indexPath := filepath.Join(dir, "index.html")
|
||||
src, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(tmplPath, src, 0644)
|
||||
}
|
||||
|
||||
func renderIndexHTML(dir string, systemConfig *models.SystemConfig) error {
|
||||
tmplPath := filepath.Join(dir, "index.html.tmpl")
|
||||
tmpl, err := template.New(filepath.Base(tmplPath)).Delims("[[", "]]").ParseFiles(tmplPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data := struct {
|
||||
*models.SystemConfig
|
||||
}{
|
||||
SystemConfig: systemConfig,
|
||||
}
|
||||
|
||||
outPath := filepath.Join(dir, "index.html")
|
||||
var rendered strings.Builder
|
||||
if err := tmpl.Execute(&rendered, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
html := strings.ReplaceAll(rendered.String(), "/*__PIKA_CUSTOM_JS__*/", systemConfig.CustomJS)
|
||||
html = strings.ReplaceAll(html, "/*__PIKA_CUSTOM_CSS__*/", systemConfig.CustomCSS)
|
||||
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = out.WriteString(html)
|
||||
return err
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dushixiang/pika/internal/models"
|
||||
)
|
||||
|
||||
type testSystemConfigProvider struct {
|
||||
config *models.SystemConfig
|
||||
}
|
||||
|
||||
func (p testSystemConfigProvider) GetSystemConfig(context.Context) (*models.SystemConfig, error) {
|
||||
return p.config, nil
|
||||
}
|
||||
|
||||
func TestRenderUIFilesInDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
indexPath := filepath.Join(dir, "index.html")
|
||||
src := `<!doctype html>
|
||||
<title>[[.SystemNameZh]][[if and .SystemNameZh .SystemNameEn]] | [[end]][[.SystemNameEn]]</title>
|
||||
<script>
|
||||
window.SystemConfig = {
|
||||
SystemNameZh: "[[.SystemNameZh]]",
|
||||
SystemNameEn: "[[.SystemNameEn]]",
|
||||
ICPCode: "[[.ICPCode]]",
|
||||
DefaultView: "[[.DefaultView]]",
|
||||
Version: "[[.Version]]",
|
||||
};
|
||||
</script>
|
||||
<script>/*__PIKA_CUSTOM_JS__*/</script>
|
||||
<style>/*__PIKA_CUSTOM_CSS__*/</style>`
|
||||
if err := os.WriteFile(indexPath, []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
provider := testSystemConfigProvider{
|
||||
config: &models.SystemConfig{
|
||||
SystemNameZh: "皮卡监控",
|
||||
SystemNameEn: "Pika Monitor",
|
||||
ICPCode: "ICP-1",
|
||||
DefaultView: "grid",
|
||||
CustomJS: `console.log("pika");`,
|
||||
CustomCSS: `body { color: red; }`,
|
||||
Version: "v1.2.3",
|
||||
},
|
||||
}
|
||||
if err := RenderUIFilesInDir(dir, provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rendered, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(rendered)
|
||||
for _, want := range []string{
|
||||
"<title>皮卡监控 | Pika Monitor</title>",
|
||||
`SystemNameZh: "皮卡监控"`,
|
||||
`Version: "v1.2.3"`,
|
||||
`console.log("pika");`,
|
||||
`body { color: red; }`,
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("rendered index.html missing %q:\n%s", want, html)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "index.html.tmpl")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,12 @@ type AppConfig struct {
|
||||
GitHub *GitHubOAuthConfig `json:"GitHub"` // GitHub OAuth配置(可选)
|
||||
GeoIP *GeoIPConfig `json:"GeoIP"` // GeoIP配置(可选)
|
||||
VictoriaMetrics *VMConfig `json:"VictoriaMetrics"` // VictoriaMetrics配置(可选)
|
||||
Theme *ThemeConfig `json:"Theme"` // 主题系统配置(可选)
|
||||
}
|
||||
|
||||
// ThemeConfig 可安装主题系统配置。
|
||||
type ThemeConfig struct {
|
||||
Dir string `json:"Dir"`
|
||||
}
|
||||
|
||||
// JWTConfig JWT配置
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/dushixiang/pika/internal/assets"
|
||||
"github.com/dushixiang/pika/internal/models"
|
||||
"github.com/dushixiang/pika/internal/service"
|
||||
"github.com/go-orz/orz"
|
||||
@@ -89,13 +88,6 @@ func (h *PropertyHandler) SetProperty(c *echo.Context) error {
|
||||
h.logger.Error("设置属性失败", zap.String("id", id), zap.Error(err))
|
||||
return orz.NewError(500, "设置属性失败")
|
||||
}
|
||||
if id == service.PropertyIDSystemConfig {
|
||||
if err := assets.RenderUIFiles(h.service); err != nil {
|
||||
h.logger.Error("渲染前端模板失败", zap.String("id", id), zap.Error(err))
|
||||
return orz.NewError(500, "渲染前端模板失败")
|
||||
}
|
||||
}
|
||||
|
||||
return orz.Ok(c, orz.Map{})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/dushixiang/pika/internal/service"
|
||||
"github.com/go-orz/orz"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ThemeHandler struct {
|
||||
logger *zap.Logger
|
||||
service *service.ThemeService
|
||||
}
|
||||
|
||||
func NewThemeHandler(logger *zap.Logger, themeService *service.ThemeService) *ThemeHandler {
|
||||
return &ThemeHandler{logger: logger, service: themeService}
|
||||
}
|
||||
|
||||
func (h *ThemeHandler) List(c *echo.Context) error {
|
||||
items, err := h.service.List(c.Request().Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return orz.Ok(c, items)
|
||||
}
|
||||
|
||||
func (h *ThemeHandler) Upload(c *echo.Context) error {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "请选择主题 ZIP 文件")
|
||||
}
|
||||
if fileHeader.Size <= 0 || fileHeader.Size > 64<<20 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "主题 ZIP 为空或超过 64 MiB")
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
result, err := h.service.Install(c.Request().Context(), file)
|
||||
if err != nil {
|
||||
return themeHTTPError(err)
|
||||
}
|
||||
return orz.Ok(c, result)
|
||||
}
|
||||
|
||||
func (h *ThemeHandler) Activate(c *echo.Context) error {
|
||||
if err := h.service.Activate(c.Request().Context(), c.Param("id")); err != nil {
|
||||
return themeHTTPError(err)
|
||||
}
|
||||
return orz.Ok(c, orz.Map{})
|
||||
}
|
||||
|
||||
func (h *ThemeHandler) Delete(c *echo.Context) error {
|
||||
if err := h.service.Delete(c.Request().Context(), c.Param("id")); err != nil {
|
||||
return themeHTTPError(err)
|
||||
}
|
||||
return orz.Ok(c, orz.Map{})
|
||||
}
|
||||
|
||||
func (h *ThemeHandler) Preview(c *echo.Context) error {
|
||||
path, contentType, err := h.service.PreviewPath(c.Request().Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
return themeHTTPError(err)
|
||||
}
|
||||
if contentType != "" {
|
||||
c.Response().Header().Set("Content-Type", contentType)
|
||||
}
|
||||
c.Response().Header().Set("Cache-Control", "private, max-age=300")
|
||||
c.Response().Header().Set("X-Content-Type-Options", "nosniff")
|
||||
return serveResolvedStaticFile(c, path, false)
|
||||
}
|
||||
|
||||
func themeHTTPError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrThemeNotFound):
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrThemeExists), errors.Is(err, service.ErrThemeActive):
|
||||
return echo.NewHTTPError(http.StatusConflict, err.Error())
|
||||
case errors.Is(err, service.ErrDefaultTheme):
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dushixiang/pika/internal/assets"
|
||||
"github.com/dushixiang/pika/internal/service"
|
||||
"github.com/dushixiang/pika/pkg/version"
|
||||
"github.com/go-orz/orz"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type WebHandler struct {
|
||||
themeService *service.ThemeService
|
||||
propertyService *service.PropertyService
|
||||
}
|
||||
|
||||
func NewWebHandler(themeService *service.ThemeService, propertyService *service.PropertyService) *WebHandler {
|
||||
return &WebHandler{themeService: themeService, propertyService: propertyService}
|
||||
}
|
||||
|
||||
func (h *WebHandler) PublicConfig(c *echo.Context) error {
|
||||
runtime, err := h.runtimeConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return orz.Ok(c, runtime)
|
||||
}
|
||||
|
||||
func (h *WebHandler) ServeAdminAsset(c *echo.Context) error {
|
||||
return serveStaticFile(c, filepath.Join(assets.WebDir(), "admin", "assets"), c.Param("*"), true)
|
||||
}
|
||||
|
||||
func (h *WebHandler) ServeThemeAsset(c *echo.Context) error {
|
||||
_, dist, err := h.themeService.ActiveDistDir(c.Request().Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return serveStaticFile(c, dist, c.Param("*"), true)
|
||||
}
|
||||
|
||||
func (h *WebHandler) ServeSPA(c *echo.Context) error {
|
||||
if c.Request().Method != http.MethodGet && c.Request().Method != http.MethodHead {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
}
|
||||
path := c.Request().URL.Path
|
||||
if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/ws") {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
}
|
||||
if isAdminPage(path) {
|
||||
return h.serveAdminIndex(c)
|
||||
}
|
||||
if path != "/" {
|
||||
_, dist, err := h.themeService.ActiveDistDir(c.Request().Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if full, ok := resolveStaticPath(dist, strings.TrimPrefix(path, "/")); ok {
|
||||
return serveResolvedStaticFile(c, full, true)
|
||||
}
|
||||
}
|
||||
if filepath.Ext(path) != "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
}
|
||||
return h.serveThemeIndex(c)
|
||||
}
|
||||
|
||||
func isAdminPage(path string) bool {
|
||||
return path == "/admin" || strings.HasPrefix(path, "/admin/")
|
||||
}
|
||||
|
||||
func (h *WebHandler) serveAdminIndex(c *echo.Context) error {
|
||||
path := filepath.Join(assets.WebDir(), "admin", "index.html")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "官方管理前端缺失")
|
||||
}
|
||||
setHTMLHeaders(c)
|
||||
return c.Blob(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
func (h *WebHandler) serveThemeIndex(c *echo.Context) error {
|
||||
systemConfig, err := h.propertyService.GetSystemConfig(c.Request().Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime, err := h.runtimeConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := h.themeService.RenderIndex(c.Request().Context(), runtime, systemConfig)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "公开主题入口不可用")
|
||||
}
|
||||
setHTMLHeaders(c)
|
||||
return c.Blob(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
func (h *WebHandler) runtimeConfig(c *echo.Context) (map[string]any, error) {
|
||||
ctx := c.Request().Context()
|
||||
systemConfig, err := h.propertyService.GetSystemConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appearance, err := h.propertyService.GetAppearanceConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
theme, err := h.themeService.Active(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
legacy := map[string]any{
|
||||
"SystemNameZh": systemConfig.SystemNameZh,
|
||||
"SystemNameEn": systemConfig.SystemNameEn,
|
||||
"ICPCode": systemConfig.ICPCode,
|
||||
"DefaultView": systemConfig.DefaultView,
|
||||
"Version": version.Version,
|
||||
}
|
||||
return map[string]any{
|
||||
"apiVersion": service.ThemeAPIVersion,
|
||||
"system": map[string]any{
|
||||
"nameZh": systemConfig.SystemNameZh,
|
||||
"nameEn": systemConfig.SystemNameEn,
|
||||
"logo": "/api/logo",
|
||||
"icpCode": systemConfig.ICPCode,
|
||||
"version": version.Version,
|
||||
"defaultView": systemConfig.DefaultView,
|
||||
"defaultColorMode": appearance.DefaultColorMode,
|
||||
},
|
||||
"theme": map[string]any{
|
||||
"id": theme.ID,
|
||||
"version": theme.Version,
|
||||
},
|
||||
"features": map[string]bool{
|
||||
"serverList": true, "serverDetail": true,
|
||||
"monitorList": true, "monitorDetail": true,
|
||||
},
|
||||
"legacySystemConfig": legacy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serveStaticFile(c *echo.Context, root, requested string, immutable bool) error {
|
||||
full, ok := resolveStaticPath(root, requested)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
}
|
||||
return serveResolvedStaticFile(c, full, immutable)
|
||||
}
|
||||
|
||||
func resolveStaticPath(root, requested string) (string, bool) {
|
||||
requested = strings.TrimPrefix(requested, "/")
|
||||
if requested == "" || strings.Contains(requested, "\\") {
|
||||
return "", false
|
||||
}
|
||||
clean := filepath.Clean(requested)
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
full := filepath.Join(root, clean)
|
||||
rel, err := filepath.Rel(root, full)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
stat, err := os.Lstat(full)
|
||||
if err != nil || !stat.Mode().IsRegular() {
|
||||
return "", false
|
||||
}
|
||||
realRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
realFile, err := filepath.EvalSymlinks(full)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
realRel, err := filepath.Rel(realRoot, realFile)
|
||||
if err != nil || realRel == ".." || strings.HasPrefix(realRel, ".."+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
return realFile, true
|
||||
}
|
||||
|
||||
func serveResolvedStaticFile(c *echo.Context, full string, immutable bool) error {
|
||||
file, err := os.Open(full)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
}
|
||||
defer file.Close()
|
||||
stat, err := file.Stat()
|
||||
if err != nil || !stat.Mode().IsRegular() {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
}
|
||||
contentType := mime.TypeByExtension(filepath.Ext(full))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
c.Response().Header().Set("Content-Type", contentType)
|
||||
c.Response().Header().Set("X-Content-Type-Options", "nosniff")
|
||||
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
if immutable {
|
||||
c.Response().Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
http.ServeContent(c.Response(), c.Request(), stat.Name(), stat.ModTime(), file)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setHTMLHeaders(c *echo.Context) {
|
||||
c.Response().Header().Set("Cache-Control", "no-cache")
|
||||
c.Response().Header().Set("X-Content-Type-Options", "nosniff")
|
||||
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
func TestAdminPageRoutingBoundary(t *testing.T) {
|
||||
for _, path := range []string{"/admin", "/admin/login", "/admin/github/callback", "/admin/oidc/callback", "/admin/agents"} {
|
||||
if !isAdminPage(path) {
|
||||
t.Errorf("expected admin route: %s", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/", "/login", "/github/callback", "/oidc/callback", "/servers/id", "/monitors/id", "/administrator", "/api/admin/themes"} {
|
||||
if isAdminPage(path) {
|
||||
t.Errorf("public/API route classified as admin page: %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeSPARejectsAPIWebSocketAssetsAndNonGET(t *testing.T) {
|
||||
handler := &WebHandler{}
|
||||
for _, test := range []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/unknown"},
|
||||
{http.MethodGet, "/ws/unknown"},
|
||||
{http.MethodPost, "/servers/1"},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(test.method, test.path, nil)
|
||||
context := echo.New().NewContext(request, recorder)
|
||||
err := handler.ServeSPA(context)
|
||||
httpError, ok := err.(*echo.HTTPError)
|
||||
if !ok || httpError.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s should return 404, got %v", test.method, test.path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStaticPathAndHeaders(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
asset := filepath.Join(root, "assets", "app.js")
|
||||
if err := os.MkdirAll(filepath.Dir(asset), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(asset, []byte("console.log('ok')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved, ok := resolveStaticPath(root, "assets/app.js"); !ok || resolved == "" {
|
||||
t.Fatal("valid static asset was rejected")
|
||||
}
|
||||
for _, path := range []string{"", "../secret", "assets\\app.js", "/../../secret"} {
|
||||
if _, ok := resolveStaticPath(root, path); ok {
|
||||
t.Errorf("unsafe path accepted: %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
outside := filepath.Join(t.TempDir(), "secret.js")
|
||||
if err := os.WriteFile(outside, []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "link.js")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := resolveStaticPath(root, "link.js"); ok {
|
||||
t.Fatal("symlink asset was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin/assets/app.js", nil)
|
||||
context := echo.New().NewContext(request, recorder)
|
||||
if err := serveStaticFile(context, root, "assets/app.js", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recorder.Header().Get("X-Content-Type-Options") != "nosniff" ||
|
||||
recorder.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" ||
|
||||
recorder.Header().Get("Content-Type") != "text/javascript; charset=utf-8" {
|
||||
t.Fatalf("unexpected static headers: %#v", recorder.Header())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEchoWildcardRoutesKeepAdminAndAPISeparated(t *testing.T) {
|
||||
webRoot := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(webRoot, "admin", "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webRoot, "admin", "assets", "app.js"), []byte("admin"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webRoot, "admin", "index.html"), []byte("<html>admin</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PIKA_WEB_DIR", webRoot)
|
||||
|
||||
e := echo.New()
|
||||
handler := &WebHandler{}
|
||||
e.GET("/admin/assets/*", handler.ServeAdminAsset)
|
||||
e.GET("/*", handler.ServeSPA)
|
||||
|
||||
assetRecorder := httptest.NewRecorder()
|
||||
e.ServeHTTP(assetRecorder, httptest.NewRequest(http.MethodGet, "/admin/assets/app.js", nil))
|
||||
if assetRecorder.Code != http.StatusOK || assetRecorder.Body.String() != "admin" {
|
||||
t.Fatalf("wildcard asset route failed: status=%d body=%q", assetRecorder.Code, assetRecorder.Body.String())
|
||||
}
|
||||
|
||||
adminRecorder := httptest.NewRecorder()
|
||||
e.ServeHTTP(adminRecorder, httptest.NewRequest(http.MethodGet, "/admin/agents", nil))
|
||||
if adminRecorder.Code != http.StatusOK || adminRecorder.Body.String() != "<html>admin</html>" {
|
||||
t.Fatalf("admin SPA route failed: status=%d body=%q", adminRecorder.Code, adminRecorder.Body.String())
|
||||
}
|
||||
|
||||
apiRecorder := httptest.NewRecorder()
|
||||
e.ServeHTTP(apiRecorder, httptest.NewRequest(http.MethodGet, "/api/unknown", nil))
|
||||
if apiRecorder.Code != http.StatusNotFound || apiRecorder.Header().Get("Content-Type") == "text/html; charset=utf-8" {
|
||||
t.Fatalf("unknown API entered SPA fallback: status=%d type=%q", apiRecorder.Code, apiRecorder.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ const (
|
||||
PropertyIDDNSProviders = "dns_providers"
|
||||
// PropertyIDAgentInstallConfig 探针安装配置的固定 ID
|
||||
PropertyIDAgentInstallConfig = "agent_install_config"
|
||||
// PropertyIDAppearanceConfig 当前公开主题和默认明暗模式。
|
||||
PropertyIDAppearanceConfig = "appearance_config"
|
||||
)
|
||||
|
||||
var defaultPublicIPv4APIs = []string{
|
||||
@@ -142,6 +144,24 @@ func (s *PropertyService) GetSystemConfig(ctx context.Context) (*models.SystemCo
|
||||
return &systemConfig, nil
|
||||
}
|
||||
|
||||
func (s *PropertyService) GetAppearanceConfig(ctx context.Context) (*AppearanceConfig, error) {
|
||||
var config AppearanceConfig
|
||||
if err := s.GetValue(ctx, PropertyIDAppearanceConfig, &config); err != nil {
|
||||
return nil, fmt.Errorf("获取外观配置失败: %w", err)
|
||||
}
|
||||
if config.ActiveTheme == "" {
|
||||
config.ActiveTheme = "default"
|
||||
}
|
||||
if config.DefaultColorMode == "" {
|
||||
config.DefaultColorMode = "system"
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (s *PropertyService) SetAppearanceConfig(ctx context.Context, config AppearanceConfig) error {
|
||||
return s.Set(ctx, PropertyIDAppearanceConfig, "外观配置", config)
|
||||
}
|
||||
|
||||
// GetPublicIPConfig 获取公网 IP 采集配置
|
||||
func (s *PropertyService) GetPublicIPConfig(ctx context.Context) (*models.PublicIPConfig, error) {
|
||||
var config models.PublicIPConfig
|
||||
@@ -352,6 +372,14 @@ func (s *PropertyService) InitializeDefaultConfigs(ctx context.Context) error {
|
||||
DefaultView: "grid",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: PropertyIDAppearanceConfig,
|
||||
Name: "外观配置",
|
||||
Value: AppearanceConfig{
|
||||
ActiveTheme: "default",
|
||||
DefaultColorMode: "system",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: PropertyIDPublicIPConfig,
|
||||
Name: "公网 IP 采集配置",
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dushixiang/pika/internal/assets"
|
||||
"github.com/dushixiang/pika/internal/config"
|
||||
"github.com/dushixiang/pika/internal/models"
|
||||
"github.com/dushixiang/pika/pkg/version"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// ThemeManifest 是 pika-theme.json 的结构,只含展示用的关键信息。
|
||||
type ThemeManifest struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Homepage string `json:"homepage"`
|
||||
License string `json:"license"`
|
||||
Preview string `json:"preview"`
|
||||
Entry string `json:"entry"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
// AppearanceConfig 记录当前启用主题和默认明暗模式,存放在 appearance_config Property。
|
||||
type AppearanceConfig struct {
|
||||
ActiveTheme string `json:"activeTheme"`
|
||||
DefaultColorMode string `json:"defaultColorMode"`
|
||||
}
|
||||
|
||||
type ThemeInfo struct {
|
||||
ThemeManifest
|
||||
Active bool `json:"active"`
|
||||
Official bool `json:"official"`
|
||||
Compatible bool `json:"compatible"`
|
||||
CompatibilityError string `json:"compatibilityError"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
}
|
||||
|
||||
type ThemeInstallResult struct {
|
||||
Theme ThemeInfo `json:"theme"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultThemeID = "default"
|
||||
ThemeSchemaVersion = 1
|
||||
ThemeAPIVersion = "v1"
|
||||
maxThemeArchiveSize = 64 << 20
|
||||
maxThemeFiles = 5000
|
||||
maxThemeFileSize = 64 << 20
|
||||
maxThemeExtracted = 256 << 20
|
||||
maxThemeManifestSize = 1 << 20
|
||||
maxThemePreviewSize = 5 << 20
|
||||
maxThemeIndexSize = 8 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
ErrThemeNotFound = errors.New("主题不存在")
|
||||
ErrThemeExists = errors.New("主题已存在")
|
||||
ErrThemeActive = errors.New("当前主题不能删除")
|
||||
ErrDefaultTheme = errors.New("默认主题不能修改或删除")
|
||||
themeIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`)
|
||||
reservedThemeIDs = map[string]struct{}{
|
||||
"default": {}, "admin": {}, "official": {}, "system": {},
|
||||
"api": {}, "assets": {}, "theme-assets": {},
|
||||
}
|
||||
requiredThemeCaps = []string{"server-list", "server-detail", "monitor-list", "monitor-detail"}
|
||||
titleElement = regexp.MustCompile(`(?is)<title(?:\s[^>]*)?>.*?</title>`)
|
||||
descriptionMeta = regexp.MustCompile(`(?is)<meta\s+[^>]*name\s*=\s*["']description["'][^>]*>`)
|
||||
iconLink = regexp.MustCompile(`(?is)<link\s+[^>]*rel\s*=\s*["'][^"']*icon[^"']*["'][^>]*>`)
|
||||
headEnd = regexp.MustCompile(`(?i)</head\s*>`)
|
||||
bodyEnd = regexp.MustCompile(`(?i)</body\s*>`)
|
||||
)
|
||||
|
||||
// ThemeService 通过扫描本地主题目录提供主题的列表、安装、启用、删除和入口 HTML 渲染。
|
||||
// 主题数据全部存放在文件系统,不经过数据库。
|
||||
type ThemeService struct {
|
||||
logger *zap.Logger
|
||||
propertyService *PropertyService
|
||||
themeDir string
|
||||
defaultThemeDir string
|
||||
mu sync.RWMutex
|
||||
renderMu sync.RWMutex
|
||||
renderCacheKey string
|
||||
renderCache []byte
|
||||
fallbackMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewThemeService(logger *zap.Logger, propertyService *PropertyService, cfg *config.AppConfig) (*ThemeService, error) {
|
||||
themeCfg := &config.ThemeConfig{}
|
||||
if cfg != nil && cfg.Theme != nil {
|
||||
themeCfg = cfg.Theme
|
||||
}
|
||||
themeDir := strings.TrimSpace(os.Getenv("PIKA_THEME_DIR"))
|
||||
if themeDir == "" {
|
||||
themeDir = strings.TrimSpace(themeCfg.Dir)
|
||||
}
|
||||
if themeDir == "" {
|
||||
themeDir = filepath.Join("data", "themes")
|
||||
}
|
||||
s := &ThemeService{
|
||||
logger: logger,
|
||||
propertyService: propertyService,
|
||||
themeDir: themeDir,
|
||||
defaultThemeDir: filepath.Join(assets.WebDir(), "default-theme"),
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(themeDir, ".staging"), 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建主题目录失败: %w", err)
|
||||
}
|
||||
if err := validateOfficialWebBuild(assets.WebDir(), s.defaultThemeDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.recoverFilesystem(); err != nil {
|
||||
logger.Warn("主题目录启动恢复存在异常", zap.Error(err))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) ActiveThemeID(ctx context.Context) string {
|
||||
appearance, err := s.propertyService.GetAppearanceConfig(ctx)
|
||||
if err != nil || appearance.ActiveTheme == "" {
|
||||
return DefaultThemeID
|
||||
}
|
||||
return appearance.ActiveTheme
|
||||
}
|
||||
|
||||
func (s *ThemeService) Active(ctx context.Context) (*ThemeInfo, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
id := s.ActiveThemeID(ctx)
|
||||
info, err := s.getUnlocked(ctx, id)
|
||||
if err == nil && info.Compatible {
|
||||
return info, nil
|
||||
}
|
||||
defaultInfo, defaultErr := s.getUnlocked(ctx, DefaultThemeID)
|
||||
if defaultErr != nil {
|
||||
return nil, fmt.Errorf("当前主题 %q 不可用且默认主题回退失败: %w", id, defaultErr)
|
||||
}
|
||||
if id != DefaultThemeID {
|
||||
s.repairActiveTheme(ctx, id, err, info)
|
||||
}
|
||||
return defaultInfo, nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) repairActiveTheme(ctx context.Context, brokenID string, loadErr error, info *ThemeInfo) {
|
||||
s.fallbackMu.Lock()
|
||||
defer s.fallbackMu.Unlock()
|
||||
if s.ActiveThemeID(ctx) != brokenID {
|
||||
return
|
||||
}
|
||||
appearance, err := s.propertyService.GetAppearanceConfig(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("读取损坏主题的外观配置失败", zap.String("theme", brokenID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
appearance.ActiveTheme = DefaultThemeID
|
||||
if err := s.propertyService.SetAppearanceConfig(ctx, *appearance); err != nil {
|
||||
s.logger.Error("自动回退默认主题失败", zap.String("theme", brokenID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
reason := loadErr
|
||||
if reason == nil && info != nil && info.CompatibilityError != "" {
|
||||
reason = errors.New(info.CompatibilityError)
|
||||
}
|
||||
s.logger.Warn("活动主题不可用,已自动回退默认主题", zap.String("theme", brokenID), zap.Error(reason))
|
||||
s.invalidateRenderCache()
|
||||
}
|
||||
|
||||
func (s *ThemeService) List(ctx context.Context) ([]ThemeInfo, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
active := s.ActiveThemeID(ctx)
|
||||
items := make([]ThemeInfo, 0)
|
||||
info, err := s.infoFromRoot(DefaultThemeID, s.defaultThemeDir, true, active)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("默认主题无效: %w", err)
|
||||
}
|
||||
items = append(items, *info)
|
||||
entries, err := os.ReadDir(s.themeDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
item, loadErr := s.infoFromRoot(entry.Name(), filepath.Join(s.themeDir, entry.Name()), false, active)
|
||||
if loadErr != nil {
|
||||
s.logger.Warn("忽略无效主题目录", zap.String("theme", entry.Name()), zap.Error(loadErr))
|
||||
continue
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) Get(ctx context.Context, id string) (*ThemeInfo, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.getUnlocked(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ThemeService) getUnlocked(ctx context.Context, id string) (*ThemeInfo, error) {
|
||||
if id != DefaultThemeID && !validThemeID(id) {
|
||||
return nil, ErrThemeNotFound
|
||||
}
|
||||
root := s.themeRoot(id)
|
||||
if stat, err := os.Stat(root); err != nil || !stat.IsDir() {
|
||||
return nil, ErrThemeNotFound
|
||||
}
|
||||
return s.infoFromRoot(id, root, id == DefaultThemeID, s.ActiveThemeID(ctx))
|
||||
}
|
||||
|
||||
func (s *ThemeService) infoFromRoot(id, root string, official bool, active string) (*ThemeInfo, error) {
|
||||
manifest, err := loadThemeManifest(filepath.Join(root, "pika-theme.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if manifest.ID != id {
|
||||
return nil, fmt.Errorf("清单 id %q 与主题目录 %q 不一致", manifest.ID, id)
|
||||
}
|
||||
compatErr := validateThemeManifest(manifest, root)
|
||||
info := &ThemeInfo{
|
||||
ThemeManifest: *manifest,
|
||||
Active: active == id,
|
||||
Official: official,
|
||||
Compatible: compatErr == nil,
|
||||
PreviewURL: "/api/admin/themes/" + id + "/preview",
|
||||
}
|
||||
if compatErr != nil {
|
||||
info.CompatibilityError = compatErr.Error()
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) Install(ctx context.Context, reader io.Reader) (*ThemeInstallResult, error) {
|
||||
data, hash, err := readThemeArchive(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
info, err := s.installArchiveLocked(ctx, data, "", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ThemeInstallResult{Theme: *info, SHA256: hash}, nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) installArchiveLocked(ctx context.Context, data []byte, expectedID string, replace bool) (*ThemeInfo, error) {
|
||||
stage := filepath.Join(s.themeDir, ".staging", uuid.NewString())
|
||||
unpacked := filepath.Join(stage, "unpacked")
|
||||
if err := os.MkdirAll(unpacked, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
if err := extractThemeArchive(data, unpacked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manifest, err := loadThemeManifest(filepath.Join(unpacked, "pika-theme.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expectedID != "" && manifest.ID != expectedID {
|
||||
return nil, errors.New("更新包的主题 id 与目标主题不一致")
|
||||
}
|
||||
if manifest.ID == DefaultThemeID {
|
||||
return nil, ErrDefaultTheme
|
||||
}
|
||||
if err := validateThemeManifest(manifest, unpacked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing, found, err := s.findInstalledThemeID(manifest.ID); err != nil {
|
||||
return nil, err
|
||||
} else if found && (!replace || existing != manifest.ID) {
|
||||
return nil, ErrThemeExists
|
||||
}
|
||||
destination := filepath.Join(s.themeDir, manifest.ID)
|
||||
if _, err := os.Stat(destination); err == nil && !replace {
|
||||
return nil, ErrThemeExists
|
||||
}
|
||||
backup := ""
|
||||
if replace {
|
||||
if _, err := os.Stat(destination); err != nil {
|
||||
return nil, ErrThemeNotFound
|
||||
}
|
||||
backup = destination + ".backup-" + uuid.NewString()
|
||||
if err := os.Rename(destination, backup); err != nil {
|
||||
return nil, fmt.Errorf("备份旧主题失败: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(unpacked, destination); err != nil {
|
||||
if backup != "" {
|
||||
_ = os.Rename(backup, destination)
|
||||
}
|
||||
return nil, fmt.Errorf("安装主题失败: %w", err)
|
||||
}
|
||||
installedInfo, err := s.infoFromRoot(manifest.ID, destination, false, s.ActiveThemeID(ctx))
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(destination)
|
||||
if backup != "" {
|
||||
_ = os.Rename(backup, destination)
|
||||
}
|
||||
return nil, fmt.Errorf("安装后健康检查失败: %w", err)
|
||||
}
|
||||
if backup != "" {
|
||||
_ = os.RemoveAll(backup)
|
||||
}
|
||||
s.invalidateRenderCache()
|
||||
return installedInfo, nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) findInstalledThemeID(id string) (string, bool, error) {
|
||||
entries, err := os.ReadDir(s.themeDir)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
wanted := strings.ToLower(norm.NFC.String(id))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || strings.Contains(entry.Name(), ".backup-") {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(norm.NFC.String(entry.Name())) == wanted {
|
||||
return entry.Name(), true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) Activate(ctx context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
info, err := s.getUnlocked(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Compatible {
|
||||
return errors.New(info.CompatibilityError)
|
||||
}
|
||||
appearance, err := s.propertyService.GetAppearanceConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appearance.ActiveTheme = id
|
||||
if err := s.propertyService.SetAppearanceConfig(ctx, *appearance); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidateRenderCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) Delete(ctx context.Context, id string) error {
|
||||
if id == DefaultThemeID {
|
||||
return ErrDefaultTheme
|
||||
}
|
||||
if !validThemeID(id) {
|
||||
return ErrThemeNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.ActiveThemeID(ctx) == id {
|
||||
return ErrThemeActive
|
||||
}
|
||||
root := filepath.Join(s.themeDir, id)
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return ErrThemeNotFound
|
||||
}
|
||||
trash := root + ".delete-" + uuid.NewString()
|
||||
if err := os.Rename(root, trash); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(trash); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidateRenderCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) ActiveDistDir(ctx context.Context) (string, string, error) {
|
||||
info, err := s.Active(ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return info.ID, filepath.Join(s.themeRoot(info.ID), "dist"), nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) PreviewPath(ctx context.Context, id string) (string, string, error) {
|
||||
info, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
path := filepath.Join(s.themeRoot(id), filepath.Clean(info.Preview))
|
||||
return path, mime.TypeByExtension(filepath.Ext(path)), nil
|
||||
}
|
||||
|
||||
func (s *ThemeService) themeRoot(id string) string {
|
||||
if id == DefaultThemeID {
|
||||
return s.defaultThemeDir
|
||||
}
|
||||
return filepath.Join(s.themeDir, id)
|
||||
}
|
||||
|
||||
func (s *ThemeService) RenderIndex(ctx context.Context, runtime any, systemConfig *models.SystemConfig) ([]byte, error) {
|
||||
_, dist, err := s.ActiveDistDir(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dist, "index.html"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeJSON, err := json.Marshal(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customCSS, customJS := "", ""
|
||||
if systemConfig != nil {
|
||||
customCSS = systemConfig.CustomCSS
|
||||
customJS = systemConfig.CustomJS
|
||||
}
|
||||
cacheHash := sha256.New()
|
||||
cacheHash.Write(data)
|
||||
cacheHash.Write(runtimeJSON)
|
||||
io.WriteString(cacheHash, "\x00"+customCSS+"\x00"+customJS+"\x00"+version.Version)
|
||||
cacheKey := hex.EncodeToString(cacheHash.Sum(nil))
|
||||
s.renderMu.RLock()
|
||||
if s.renderCacheKey == cacheKey && s.renderCache != nil {
|
||||
cached := append([]byte(nil), s.renderCache...)
|
||||
s.renderMu.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
s.renderMu.RUnlock()
|
||||
|
||||
injection := `<script id="pika-runtime-config">window.PikaRuntime=` + string(runtimeJSON) + `;window.SystemConfig=window.PikaRuntime.legacySystemConfig;</script>`
|
||||
if customCSS != "" {
|
||||
injection += `<style id="pika-custom-css">` + customCSS + `</style>`
|
||||
}
|
||||
htmlText := string(data)
|
||||
htmlText = applySystemMetadata(htmlText, systemConfig)
|
||||
if strings.Contains(htmlText, "<!-- pika:head -->") {
|
||||
htmlText = strings.Replace(htmlText, "<!-- pika:head -->", injection, 1)
|
||||
} else {
|
||||
htmlText = replaceBeforeFirst(htmlText, headEnd, injection)
|
||||
}
|
||||
if customJS != "" {
|
||||
script := `<script id="pika-custom-js">` + customJS + `</script>`
|
||||
if strings.Contains(htmlText, "<!-- pika:body -->") {
|
||||
htmlText = strings.Replace(htmlText, "<!-- pika:body -->", script, 1)
|
||||
} else {
|
||||
htmlText = replaceBeforeFirst(htmlText, bodyEnd, script)
|
||||
}
|
||||
}
|
||||
rendered := []byte(htmlText)
|
||||
s.renderMu.Lock()
|
||||
s.renderCacheKey = cacheKey
|
||||
s.renderCache = append(s.renderCache[:0], rendered...)
|
||||
s.renderMu.Unlock()
|
||||
return append([]byte(nil), rendered...), nil
|
||||
}
|
||||
|
||||
func applySystemMetadata(htmlText string, systemConfig *models.SystemConfig) string {
|
||||
if systemConfig == nil {
|
||||
return htmlText
|
||||
}
|
||||
nameZh := strings.TrimSpace(systemConfig.SystemNameZh)
|
||||
nameEn := strings.TrimSpace(systemConfig.SystemNameEn)
|
||||
title := nameZh
|
||||
if title == "" {
|
||||
title = nameEn
|
||||
} else if nameEn != "" && nameEn != nameZh {
|
||||
title += " | " + nameEn
|
||||
}
|
||||
if title == "" {
|
||||
title = "Pika Monitor"
|
||||
}
|
||||
titleTag := "<title>" + html.EscapeString(title) + "</title>"
|
||||
description := html.EscapeString(title + " - 服务器状态与监控")
|
||||
descriptionTag := `<meta name="description" content="` + description + `">`
|
||||
iconTag := `<link rel="icon" href="/api/logo">`
|
||||
if titleElement.MatchString(htmlText) {
|
||||
htmlText = titleElement.ReplaceAllStringFunc(htmlText, func(string) string { return titleTag })
|
||||
} else {
|
||||
htmlText = replaceBeforeFirst(htmlText, headEnd, titleTag)
|
||||
}
|
||||
if descriptionMeta.MatchString(htmlText) {
|
||||
htmlText = descriptionMeta.ReplaceAllStringFunc(htmlText, func(string) string { return descriptionTag })
|
||||
} else {
|
||||
htmlText = replaceBeforeFirst(htmlText, headEnd, descriptionTag)
|
||||
}
|
||||
if iconLink.MatchString(htmlText) {
|
||||
htmlText = iconLink.ReplaceAllStringFunc(htmlText, func(string) string { return iconTag })
|
||||
} else {
|
||||
htmlText = replaceBeforeFirst(htmlText, headEnd, iconTag)
|
||||
}
|
||||
return htmlText
|
||||
}
|
||||
|
||||
func (s *ThemeService) invalidateRenderCache() {
|
||||
s.renderMu.Lock()
|
||||
s.renderCacheKey = ""
|
||||
s.renderCache = nil
|
||||
s.renderMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *ThemeService) recoverFilesystem() error {
|
||||
var recoveryErrors []error
|
||||
staging := filepath.Join(s.themeDir, ".staging")
|
||||
entries, err := os.ReadDir(staging)
|
||||
if err == nil {
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
for _, entry := range entries {
|
||||
info, infoErr := entry.Info()
|
||||
if infoErr == nil && info.ModTime().Before(cutoff) {
|
||||
_ = os.RemoveAll(filepath.Join(staging, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, err = os.ReadDir(s.themeDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backups := map[string][]string{}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
marker := strings.Index(name, ".backup-")
|
||||
if marker <= 0 || !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
id := name[:marker]
|
||||
backups[id] = append(backups[id], filepath.Join(s.themeDir, name))
|
||||
}
|
||||
for id, candidates := range backups {
|
||||
official := filepath.Join(s.themeDir, id)
|
||||
if themeRootValid(id, official) {
|
||||
for _, candidate := range candidates {
|
||||
if err := os.RemoveAll(candidate); err != nil {
|
||||
recoveryErrors = append(recoveryErrors, fmt.Errorf("清理主题 %s 旧备份失败: %w", id, err))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
validBackups := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if themeRootValid(id, candidate) {
|
||||
validBackups = append(validBackups, candidate)
|
||||
}
|
||||
}
|
||||
if len(validBackups) != 1 || len(candidates) != 1 {
|
||||
recoveryErrors = append(recoveryErrors, fmt.Errorf("主题 %s 的备份状态不明确,已保留全部目录", id))
|
||||
continue
|
||||
}
|
||||
if _, statErr := os.Stat(official); statErr == nil {
|
||||
broken := filepath.Join(s.themeDir, ".broken-"+id+"-"+uuid.NewString())
|
||||
if err := os.Rename(official, broken); err != nil {
|
||||
recoveryErrors = append(recoveryErrors, fmt.Errorf("隔离主题 %s 损坏目录失败: %w", id, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := os.Rename(validBackups[0], official); err != nil {
|
||||
recoveryErrors = append(recoveryErrors, fmt.Errorf("恢复主题 %s 备份失败: %w", id, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(recoveryErrors...)
|
||||
}
|
||||
|
||||
func themeRootValid(id, root string) bool {
|
||||
manifest, err := loadThemeManifest(filepath.Join(root, "pika-theme.json"))
|
||||
return err == nil && manifest.ID == id && validateThemeManifest(manifest, root) == nil
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dushixiang/pika/internal/config"
|
||||
"github.com/dushixiang/pika/internal/models"
|
||||
"github.com/dushixiang/pika/pkg/version"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestValidateOfficialWebBuildAllowsMissingBuildInDevelopment(t *testing.T) {
|
||||
previousVersion := version.Version
|
||||
version.Version = "dev"
|
||||
t.Cleanup(func() { version.Version = previousVersion })
|
||||
t.Setenv("PIKA_WEB_DIR", "")
|
||||
|
||||
webRoot := t.TempDir()
|
||||
if err := validateOfficialWebBuild(webRoot, filepath.Join(webRoot, "default-theme")); err != nil {
|
||||
t.Fatalf("development build should not require packaged Web assets: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOfficialWebBuildChecksExplicitWebDirInDevelopment(t *testing.T) {
|
||||
previousVersion := version.Version
|
||||
version.Version = "dev"
|
||||
t.Cleanup(func() { version.Version = previousVersion })
|
||||
|
||||
webRoot := t.TempDir()
|
||||
t.Setenv("PIKA_WEB_DIR", webRoot)
|
||||
if err := validateOfficialWebBuild(webRoot, filepath.Join(webRoot, "default-theme")); err == nil {
|
||||
t.Fatal("explicit PIKA_WEB_DIR should be validated in development")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractThemeArchiveSecurity(t *testing.T) {
|
||||
valid := makeThemeZIP(t, map[string]string{
|
||||
"pika-theme.json": `{}`,
|
||||
"dist/index.html": `<!doctype html>`,
|
||||
"preview.png": "preview",
|
||||
})
|
||||
if err := extractThemeArchive(valid, t.TempDir()); err != nil {
|
||||
t.Fatalf("valid archive rejected: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
}{
|
||||
{name: "zip slip", files: map[string]string{"../escape.js": "x"}},
|
||||
{name: "absolute", files: map[string]string{"/escape.js": "x"}},
|
||||
{name: "windows drive", files: map[string]string{"C:/escape.js": "x"}},
|
||||
{name: "case duplicate", files: map[string]string{"dist/app.js": "a", "DIST/App.js": "b"}},
|
||||
{name: "unicode duplicate", files: map[string]string{"dist/caf\u00e9.js": "a", "dist/cafe\u0301.js": "b"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := extractThemeArchive(makeThemeZIP(t, test.files), t.TempDir()); err == nil {
|
||||
t.Fatal("unsafe archive was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractThemeArchiveRejectsSymlinkAndFileCount(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
header := &zip.FileHeader{Name: "dist/link"}
|
||||
header.SetMode(os.ModeSymlink | 0o777)
|
||||
entry, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = entry.Write([]byte("../../secret"))
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := extractThemeArchive(buffer.Bytes(), t.TempDir()); err == nil {
|
||||
t.Fatal("symlink archive was accepted")
|
||||
}
|
||||
|
||||
// 用 FileHeader 直接生成唯一名字,避免测试数据本身发生覆盖。
|
||||
buffer.Reset()
|
||||
writer = zip.NewWriter(&buffer)
|
||||
for index := 0; index <= maxThemeFiles; index++ {
|
||||
if _, err := writer.Create("dist/file-" + strconv.Itoa(index)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := extractThemeArchive(buffer.Bytes(), t.TempDir()); err == nil {
|
||||
t.Fatal("archive exceeding file count was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateThemeManifest(t *testing.T) {
|
||||
root := createValidThemeRoot(t, "minimal")
|
||||
manifest := validTestManifest("minimal")
|
||||
if err := validateThemeManifest(&manifest, root); err != nil {
|
||||
t.Fatalf("valid manifest rejected: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*ThemeManifest)
|
||||
}{
|
||||
{name: "reserved id", mutate: func(value *ThemeManifest) { value.ID = "admin" }},
|
||||
{name: "invalid schema", mutate: func(value *ThemeManifest) { value.SchemaVersion = 2 }},
|
||||
{name: "invalid api", mutate: func(value *ThemeManifest) { value.APIVersion = "v2" }},
|
||||
{name: "invalid entry", mutate: func(value *ThemeManifest) { value.Entry = "index.html" }},
|
||||
{name: "missing capability", mutate: func(value *ThemeManifest) { value.Capabilities = []string{"server-list"} }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := manifest
|
||||
test.mutate(&candidate)
|
||||
if err := validateThemeManifest(&candidate, root); err == nil {
|
||||
t.Fatal("invalid manifest was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySystemMetadataEscapesValues(t *testing.T) {
|
||||
rendered := applySystemMetadata(`<!doctype html><html><head><title>old</title></head><body></body></html>`, &models.SystemConfig{
|
||||
SystemNameZh: `<script>alert(1)</script>`,
|
||||
SystemNameEn: `Pika $1`,
|
||||
})
|
||||
if strings.Contains(rendered, `<script>alert(1)</script>`) || !strings.Contains(rendered, `<script>`) {
|
||||
t.Fatalf("metadata was not escaped: %s", rendered)
|
||||
}
|
||||
if !strings.Contains(rendered, `<meta name="description"`) || !strings.Contains(rendered, `<link rel="icon" href="/api/logo">`) {
|
||||
t.Fatalf("metadata tags missing: %s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverFilesystemRestoresUniqueBackup(t *testing.T) {
|
||||
themeDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(themeDir, ".staging"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup := filepath.Join(themeDir, "minimal.backup-test")
|
||||
writeThemeRoot(t, backup, validTestManifest("minimal"))
|
||||
service := &ThemeService{themeDir: themeDir}
|
||||
if err := service.recoverFilesystem(); err != nil {
|
||||
t.Fatalf("recovery failed: %v", err)
|
||||
}
|
||||
if !themeRootValid("minimal", filepath.Join(themeDir, "minimal")) {
|
||||
t.Fatal("unique valid backup was not restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeInstallActivateDeleteLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
webRoot := t.TempDir()
|
||||
writeOfficialWebBuild(t, webRoot)
|
||||
t.Setenv("PIKA_WEB_DIR", webRoot)
|
||||
themeDir := filepath.Join(t.TempDir(), "themes")
|
||||
|
||||
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(&models.Property{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
properties := NewPropertyService(zap.NewNop(), database)
|
||||
if err := properties.InitializeDefaultConfigs(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
themeService, err := NewThemeService(zap.NewNop(), properties, &config.AppConfig{Theme: &config.ThemeConfig{Dir: themeDir}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
archive := makeInstallableThemeZIP(t, validTestManifest("minimal"))
|
||||
result, err := themeService.Install(ctx, bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
t.Fatalf("install failed: %v", err)
|
||||
}
|
||||
if result.Theme.ID != "minimal" || len(result.SHA256) != 64 {
|
||||
t.Fatalf("unexpected install result: %#v", result)
|
||||
}
|
||||
if _, err := themeService.Install(ctx, bytes.NewReader(archive)); !errors.Is(err, ErrThemeExists) {
|
||||
t.Fatalf("duplicate install should fail with ErrThemeExists, got %v", err)
|
||||
}
|
||||
caseVariant := validTestManifest("Minimal")
|
||||
if _, err := themeService.Install(ctx, bytes.NewReader(makeInstallableThemeZIP(t, caseVariant))); !errors.Is(err, ErrThemeExists) {
|
||||
t.Fatalf("case-insensitive duplicate should fail with ErrThemeExists, got %v", err)
|
||||
}
|
||||
defaultManifest := validTestManifest(DefaultThemeID)
|
||||
if _, err := themeService.Install(ctx, bytes.NewReader(makeInstallableThemeZIP(t, defaultManifest))); !errors.Is(err, ErrDefaultTheme) {
|
||||
t.Fatalf("default override should fail with ErrDefaultTheme, got %v", err)
|
||||
}
|
||||
|
||||
if err := themeService.Activate(ctx, "minimal"); err != nil {
|
||||
t.Fatalf("activate failed: %v", err)
|
||||
}
|
||||
if active, err := themeService.Active(ctx); err != nil || active.ID != "minimal" {
|
||||
t.Fatalf("unexpected active theme: %#v, %v", active, err)
|
||||
}
|
||||
if err := themeService.Delete(ctx, "minimal"); !errors.Is(err, ErrThemeActive) {
|
||||
t.Fatalf("active delete should fail with ErrThemeActive, got %v", err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(themeDir, "minimal", "dist", "index.html")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active, err := themeService.Active(ctx); err != nil || active.ID != DefaultThemeID {
|
||||
t.Fatalf("broken active theme did not fall back: %#v, %v", active, err)
|
||||
}
|
||||
appearance, err := properties.GetAppearanceConfig(ctx)
|
||||
if err != nil || appearance.ActiveTheme != DefaultThemeID {
|
||||
t.Fatalf("fallback did not repair appearance config: %#v, %v", appearance, err)
|
||||
}
|
||||
if err := themeService.Delete(ctx, "minimal"); err != nil {
|
||||
t.Fatalf("inactive theme delete failed: %v", err)
|
||||
}
|
||||
if _, err := themeService.Get(ctx, "minimal"); !errors.Is(err, ErrThemeNotFound) {
|
||||
t.Fatalf("deleted theme still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func makeThemeZIP(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
for name, content := range files {
|
||||
entry, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func makeInstallableThemeZIP(t *testing.T, manifest ThemeManifest) []byte {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return makeThemeZIP(t, map[string]string{
|
||||
"pika-theme.json": string(data),
|
||||
"dist/index.html": `<!doctype html><html><head><!-- pika:head --></head><body><!-- pika:body --></body></html>`,
|
||||
"preview.png": "preview",
|
||||
})
|
||||
}
|
||||
|
||||
func writeOfficialWebBuild(t *testing.T, webRoot string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(webRoot, "admin", "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(webRoot, "admin"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webRoot, "admin", "index.html"), []byte("<html>admin</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeThemeRoot(t, filepath.Join(webRoot, "default-theme"), validTestManifest(DefaultThemeID))
|
||||
}
|
||||
|
||||
func createValidThemeRoot(t *testing.T, id string) string {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), id)
|
||||
writeThemeRoot(t, root, validTestManifest(id))
|
||||
return root
|
||||
}
|
||||
|
||||
func writeThemeRoot(t *testing.T, root string, manifest ThemeManifest) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(root, "dist"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "pika-theme.json"), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "dist", "index.html"), []byte(`<!doctype html><html><head></head><body></body></html>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "preview.png"), []byte("preview"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func validTestManifest(id string) ThemeManifest {
|
||||
return ThemeManifest{
|
||||
SchemaVersion: 1,
|
||||
ID: id,
|
||||
Name: "Minimal",
|
||||
Version: "1.2.3",
|
||||
Author: "Pika",
|
||||
Preview: "preview.png",
|
||||
Entry: "dist/index.html",
|
||||
APIVersion: ThemeAPIVersion,
|
||||
Capabilities: append([]string(nil), requiredThemeCaps...),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/dushixiang/pika/pkg/version"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// validateOfficialWebBuild 启动时验证官方 web/dist 完整性。
|
||||
func validateOfficialWebBuild(webDir, defaultThemeDir string) error {
|
||||
if version.GetVersion() == "dev" && strings.TrimSpace(os.Getenv("PIKA_WEB_DIR")) == "" {
|
||||
return nil
|
||||
}
|
||||
requiredFiles := []string{
|
||||
filepath.Join(webDir, "admin", "index.html"),
|
||||
filepath.Join(defaultThemeDir, "pika-theme.json"),
|
||||
filepath.Join(defaultThemeDir, "dist", "index.html"),
|
||||
}
|
||||
for _, path := range requiredFiles {
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil || !stat.Mode().IsRegular() {
|
||||
return fmt.Errorf("官方 Web 构建缺失: %s", path)
|
||||
}
|
||||
}
|
||||
assetStat, err := os.Stat(filepath.Join(webDir, "admin", "assets"))
|
||||
if err != nil || !assetStat.IsDir() {
|
||||
return fmt.Errorf("官方管理静态资源目录缺失: %s", filepath.Join(webDir, "admin", "assets"))
|
||||
}
|
||||
manifest, err := loadThemeManifest(filepath.Join(defaultThemeDir, "pika-theme.json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("官方默认主题清单无效: %w", err)
|
||||
}
|
||||
if manifest.ID != DefaultThemeID {
|
||||
return errors.New("官方默认主题清单 id 必须为 default")
|
||||
}
|
||||
if err := validateThemeManifest(manifest, defaultThemeDir); err != nil {
|
||||
return fmt.Errorf("官方默认主题无效: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadThemeManifest 读取并解析 pika-theme.json。
|
||||
func loadThemeManifest(path string) (*ThemeManifest, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxThemeManifestSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxThemeManifestSize {
|
||||
return nil, errors.New("主题清单超过大小限制")
|
||||
}
|
||||
var manifest ThemeManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("解析主题清单失败: %w", err)
|
||||
}
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
func validateThemeManifest(manifest *ThemeManifest, root string) error {
|
||||
if manifest.SchemaVersion != ThemeSchemaVersion {
|
||||
return fmt.Errorf("不支持的主题清单版本: %d", manifest.SchemaVersion)
|
||||
}
|
||||
if manifest.ID != DefaultThemeID && !validThemeID(manifest.ID) {
|
||||
return errors.New("主题 id 无效或属于保留名称")
|
||||
}
|
||||
if strings.TrimSpace(manifest.Name) == "" || strings.TrimSpace(manifest.Author) == "" || strings.TrimSpace(manifest.Version) == "" {
|
||||
return errors.New("主题清单缺少 name、author 或 version")
|
||||
}
|
||||
if manifest.APIVersion != ThemeAPIVersion {
|
||||
return fmt.Errorf("主题 API 版本 %q 与系统 %q 不兼容", manifest.APIVersion, ThemeAPIVersion)
|
||||
}
|
||||
if manifest.Entry != "dist/index.html" {
|
||||
return errors.New("entry 必须是 dist/index.html")
|
||||
}
|
||||
if err := validateRelativeFile(root, manifest.Entry, 0); err != nil {
|
||||
return fmt.Errorf("主题入口无效: %w", err)
|
||||
}
|
||||
if err := validateThemeIndex(filepath.Join(root, manifest.Entry)); err != nil {
|
||||
return fmt.Errorf("主题入口无效: %w", err)
|
||||
}
|
||||
if err := validateRelativeFile(root, manifest.Preview, maxThemePreviewSize); err != nil {
|
||||
return fmt.Errorf("主题预览图无效: %w", err)
|
||||
}
|
||||
capabilities := make(map[string]bool, len(manifest.Capabilities))
|
||||
for _, capability := range manifest.Capabilities {
|
||||
capabilities[capability] = true
|
||||
}
|
||||
for _, required := range requiredThemeCaps {
|
||||
if !capabilities[required] {
|
||||
return fmt.Errorf("主题缺少核心能力 %s", required)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateThemeIndex(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxThemeIndexSize+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) == 0 || len(data) > maxThemeIndexSize {
|
||||
return errors.New("index.html 为空或超过 8 MiB")
|
||||
}
|
||||
lower := strings.ToLower(string(data))
|
||||
if !strings.Contains(lower, "</head>") || !strings.Contains(lower, "</body>") {
|
||||
return errors.New("index.html 缺少 head 或 body 结束标签")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validThemeID(id string) bool {
|
||||
if !themeIDPattern.MatchString(id) {
|
||||
return false
|
||||
}
|
||||
_, reserved := reservedThemeIDs[strings.ToLower(id)]
|
||||
return !reserved
|
||||
}
|
||||
|
||||
// validateRelativeFile 防止文件路径穿越(含符号链接)。
|
||||
func validateRelativeFile(root, relative string, maxSize int64) error {
|
||||
if relative == "" || filepath.IsAbs(relative) || strings.Contains(relative, "\\") {
|
||||
return errors.New("文件路径无效")
|
||||
}
|
||||
clean := filepath.Clean(relative)
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
|
||||
return errors.New("文件路径越界")
|
||||
}
|
||||
full := filepath.Join(root, clean)
|
||||
rel, err := filepath.Rel(root, full)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return errors.New("文件路径越界")
|
||||
}
|
||||
stat, err := os.Lstat(full)
|
||||
if err != nil || !stat.Mode().IsRegular() {
|
||||
return errors.New("文件不存在或不是普通文件")
|
||||
}
|
||||
realRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return errors.New("主题根目录不可用")
|
||||
}
|
||||
realFile, err := filepath.EvalSymlinks(full)
|
||||
if err != nil {
|
||||
return errors.New("文件路径不可用")
|
||||
}
|
||||
realRel, err := filepath.Rel(realRoot, realFile)
|
||||
if err != nil || realRel == ".." || strings.HasPrefix(realRel, ".."+string(os.PathSeparator)) {
|
||||
return errors.New("文件路径经过符号链接越界")
|
||||
}
|
||||
if maxSize > 0 && stat.Size() > maxSize {
|
||||
return errors.New("文件超过大小限制")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readThemeArchive 读取上传的 ZIP 数据并计算 SHA-256。
|
||||
func readThemeArchive(reader io.Reader) ([]byte, string, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(reader, maxThemeArchiveSize+1))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(data) == 0 || len(data) > maxThemeArchiveSize {
|
||||
return nil, "", errors.New("主题压缩包为空或超过 64 MiB")
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return data, hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// extractThemeArchive 安全解压主题 ZIP,防御 ZIP Slip、符号链接、重复路径、大小溢出等。
|
||||
func extractThemeArchive(data []byte, destination string) error {
|
||||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return errors.New("无法读取主题 ZIP")
|
||||
}
|
||||
if len(reader.File) == 0 || len(reader.File) > maxThemeFiles {
|
||||
return errors.New("主题 ZIP 文件数量无效或超过限制")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var total uint64
|
||||
for _, file := range reader.File {
|
||||
name := strings.ReplaceAll(file.Name, "\\", "/")
|
||||
clean := filepath.ToSlash(filepath.Clean(name))
|
||||
if clean == "." || strings.HasPrefix(clean, "/") || strings.HasPrefix(clean, "../") ||
|
||||
strings.Contains(clean, "/../") || (len(clean) >= 2 && clean[1] == ':') {
|
||||
return fmt.Errorf("主题 ZIP 包含非法路径: %s", file.Name)
|
||||
}
|
||||
key := strings.ToLower(norm.NFC.String(clean))
|
||||
if seen[key] {
|
||||
return fmt.Errorf("主题 ZIP 包含重复路径: %s", file.Name)
|
||||
}
|
||||
seen[key] = true
|
||||
mode := file.Mode()
|
||||
if mode&os.ModeSymlink != 0 || (mode&os.ModeType != 0 && !mode.IsDir()) {
|
||||
return fmt.Errorf("主题 ZIP 包含不支持的文件类型: %s", file.Name)
|
||||
}
|
||||
if file.UncompressedSize64 > maxThemeFileSize {
|
||||
return fmt.Errorf("主题文件超过大小限制: %s", file.Name)
|
||||
}
|
||||
total += file.UncompressedSize64
|
||||
if total > maxThemeExtracted {
|
||||
return errors.New("主题解压后总大小超过限制")
|
||||
}
|
||||
target := filepath.Join(destination, filepath.FromSlash(clean))
|
||||
rel, relErr := filepath.Rel(destination, target)
|
||||
if relErr != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return errors.New("主题 ZIP 路径越界")
|
||||
}
|
||||
if file.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(target, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
src.Close()
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(dst, io.LimitReader(src, maxThemeFileSize+1))
|
||||
closeErr := dst.Close()
|
||||
src.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceBeforeFirst(value string, marker *regexp.Regexp, insertion string) string {
|
||||
location := marker.FindStringIndex(value)
|
||||
if location == nil {
|
||||
return value
|
||||
}
|
||||
return value[:location[0]] + insertion + value[location[0]:]
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App
|
||||
service.NewDDNSService,
|
||||
service.NewSSHLoginService,
|
||||
service.NewPublicIPService,
|
||||
service.NewThemeService,
|
||||
|
||||
service.NewNotifier,
|
||||
// WebSocket Manager
|
||||
@@ -55,6 +56,8 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App
|
||||
handler.NewDNSProviderHandler,
|
||||
handler.NewDDNSHandler,
|
||||
handler.NewSSHLoginHandler,
|
||||
handler.NewThemeHandler,
|
||||
handler.NewWebHandler,
|
||||
|
||||
// App Components
|
||||
wire.Struct(new(AppComponents), "*"),
|
||||
@@ -74,6 +77,8 @@ type AppComponents struct {
|
||||
DNSProviderHandler *handler.DNSProviderHandler
|
||||
DDNSHandler *handler.DDNSHandler
|
||||
SSHLoginHandler *handler.SSHLoginHandler
|
||||
ThemeHandler *handler.ThemeHandler
|
||||
WebHandler *handler.WebHandler
|
||||
|
||||
AgentService *service.AgentService
|
||||
TrafficService *service.TrafficService
|
||||
@@ -86,6 +91,7 @@ type AppComponents struct {
|
||||
DDNSService *service.DDNSService
|
||||
SSHLoginService *service.SSHLoginService
|
||||
PublicIPService *service.PublicIPService
|
||||
ThemeService *service.ThemeService
|
||||
|
||||
WSManager *websocket.Manager
|
||||
VMClient *vmclient.VMClient
|
||||
|
||||
@@ -28,6 +28,10 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App
|
||||
accountHandler := handler.NewAccountHandler(accountService)
|
||||
apiKeyService := service.NewApiKeyService(logger, db)
|
||||
propertyService := service.NewPropertyService(logger, db)
|
||||
themeService, err := service.NewThemeService(logger, propertyService, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifier := service.NewNotifier(logger)
|
||||
notificationService := service.NewNotificationService(logger, propertyService, notifier)
|
||||
trafficService := service.NewTrafficService(logger, db, notificationService)
|
||||
@@ -54,6 +58,8 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App
|
||||
dnsProviderHandler := handler.NewDNSProviderHandler(logger, propertyService)
|
||||
ddnsHandler := handler.NewDDNSHandler(logger, ddnsService)
|
||||
sshLoginHandler := handler.NewSSHLoginHandler(logger, sshLoginService)
|
||||
themeHandler := handler.NewThemeHandler(logger, themeService)
|
||||
webHandler := handler.NewWebHandler(themeService, propertyService)
|
||||
appComponents := &AppComponents{
|
||||
AccountHandler: accountHandler,
|
||||
AgentHandler: agentHandler,
|
||||
@@ -65,6 +71,8 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App
|
||||
DNSProviderHandler: dnsProviderHandler,
|
||||
DDNSHandler: ddnsHandler,
|
||||
SSHLoginHandler: sshLoginHandler,
|
||||
ThemeHandler: themeHandler,
|
||||
WebHandler: webHandler,
|
||||
AgentService: agentService,
|
||||
TrafficService: trafficService,
|
||||
MetricService: metricService,
|
||||
@@ -76,6 +84,7 @@ func InitializeApp(logger *zap.Logger, db *gorm.DB, cfg *config.AppConfig) (*App
|
||||
DDNSService: ddnsService,
|
||||
SSHLoginService: sshLoginService,
|
||||
PublicIPService: publicIPService,
|
||||
ThemeService: themeService,
|
||||
WSManager: manager,
|
||||
VMClient: vmClient,
|
||||
}
|
||||
@@ -96,6 +105,8 @@ type AppComponents struct {
|
||||
DNSProviderHandler *handler.DNSProviderHandler
|
||||
DDNSHandler *handler.DDNSHandler
|
||||
SSHLoginHandler *handler.SSHLoginHandler
|
||||
ThemeHandler *handler.ThemeHandler
|
||||
WebHandler *handler.WebHandler
|
||||
|
||||
AgentService *service.AgentService
|
||||
TrafficService *service.TrafficService
|
||||
@@ -108,6 +119,7 @@ type AppComponents struct {
|
||||
DDNSService *service.DDNSService
|
||||
SSHLoginService *service.SSHLoginService
|
||||
PublicIPService *service.PublicIPService
|
||||
ThemeService *service.ThemeService
|
||||
|
||||
WSManager *websocket.Manager
|
||||
VMClient *vmclient.VMClient
|
||||
|
||||
@@ -1,73 +1,39 @@
|
||||
# React + TypeScript + Vite
|
||||
# Pika Web
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
Web 前端由两个相互隔离的 React/Vite 应用和一个主题 SDK 组成:
|
||||
|
||||
Currently, two official plugins are available:
|
||||
- `admin/`:官方管理后台、登录和 OAuth/OIDC 回调,只发布到 `/admin/assets/*`;
|
||||
- `portal/`:官方默认公开主题,只发布到 `/theme-assets/*`,构建后封装为不可删除的 `default-theme`;
|
||||
- `theme-sdk/`:第三方主题可依赖的稳定公开 API 与 TypeScript 契约;
|
||||
- 三个目录分别维护自己的 `package.json` 和 `package-lock.json`,根 `web` 不再是 npm workspace。
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
两个 React 项目没有跨目录源码 import。第三方主题也不能引用 `admin` 或 `portal` 源码。
|
||||
|
||||
## React Compiler
|
||||
`portal/src/components` 只放跨页面共享的 UI;服务器和监控页面的私有卡片、区块及图表分别与页面放在 `portal/src/pages/servers` 和 `portal/src/pages/monitors`,避免把所有页面片段都提升为全局组件。
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
分别开发和构建:
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```bash
|
||||
cd web/admin && npm ci && npm run dev
|
||||
cd web/portal && npm ci && npm run dev
|
||||
cd web/theme-sdk && npm ci && npm run build
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
默认开发地址分别为 portal `http://localhost:5173/`、admin `http://localhost:5174/admin/`,两者都把 `/api/*` 代理到 `http://localhost:8080`,可以同时启动。portal 只在生产构建时使用 `/theme-assets/` 资源前缀。
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
`admin` 和 `portal` 都只向各自目录的 `dist/` 输出。仓库发布构建使用 `make build-web` 独立安装和编译三个项目,再组装、校验以下服务端产物:
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```text
|
||||
dist/admin/index.html
|
||||
dist/admin/assets/*
|
||||
dist/default-theme/pika-theme.json
|
||||
dist/default-theme/dist/index.html
|
||||
dist/default-theme/dist/assets/*
|
||||
```
|
||||
|
||||
运行路径固定为:
|
||||
|
||||
- portal:`/`、`/servers/*`、`/monitors/*`;
|
||||
- admin:`/admin/*`,包括 `/admin/login` 和 OAuth/OIDC 回调;
|
||||
- admin 资源:`/admin/assets/*`;
|
||||
- 活动主题资源:`/theme-assets/*`。
|
||||
@@ -0,0 +1,14 @@
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
{ignores: ['dist', 'node_modules']},
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: {jsx: true}},
|
||||
globals: {...globals.browser, ...globals.es2022},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link rel="icon" href="/api/logo"/>
|
||||
<title>Pika 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,20 +1,16 @@
|
||||
{
|
||||
"name": "web",
|
||||
"name": "@pika/admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
||||
"@radix-ui/react-scroll-area": "^1.2.14",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.12",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@types/qs": "^6.15.1",
|
||||
"antd": "^6.5.0",
|
||||
@@ -26,26 +22,19 @@
|
||||
"qs": "^6.15.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-is": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-use": "^17.6.1",
|
||||
"recharts": "^3.9.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.2"
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"baseline-browser-mapping": "^2.10.43",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "8.1.4"
|
||||
@@ -0,0 +1,15 @@
|
||||
import {RouterProvider} from 'react-router-dom';
|
||||
import {App as AntdApp, ConfigProvider} from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import adminRouter from '@/router/admin';
|
||||
import {ColorModeProvider} from '@/shared/contexts/ColorModeContext';
|
||||
|
||||
export default function AdminApp() {
|
||||
return (
|
||||
<ColorModeProvider>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<AntdApp><RouterProvider router={adminRouter}/></AntdApp>
|
||||
</ConfigProvider>
|
||||
</ColorModeProvider>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 822 B After Width: | Height: | Size: 822 B |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -15,7 +15,7 @@ const PrivateRoute = ({ children }: PrivateRouteProps) => {
|
||||
|
||||
// 如果没有登录信息,重定向到登录页
|
||||
if (!token || !userInfo) {
|
||||
return <Navigate to="/login" replace />;
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
|
||||
// 已登录,渲染子组件
|
||||
@@ -22,7 +22,7 @@ import {cn} from '@/lib/utils';
|
||||
import {getServerVersion, type VersionInfo} from "@/api/version.ts";
|
||||
import {flushSync} from "react-dom";
|
||||
import {ScrollArea} from "@radix-ui/react-scroll-area";
|
||||
import {useTheme} from '@/portal/contexts/ThemeContext';
|
||||
import {useColorMode} from '@/shared/contexts/ColorModeContext';
|
||||
|
||||
interface NavItem {
|
||||
key: string;
|
||||
@@ -40,7 +40,7 @@ const AdminLayout = () => {
|
||||
const {message: messageApi, modal} = AntApp.useApp();
|
||||
const [userInfo, setUserInfo] = useState<User | null>(null);
|
||||
const [version, setVersion] = useState<VersionInfo>();
|
||||
const {appliedTheme, setTheme} = useTheme();
|
||||
const {resolvedColorMode: appliedTheme, setColorMode: setTheme} = useColorMode();
|
||||
const themeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const menuItems: NavItem[] = useMemo(
|
||||
@@ -96,7 +96,7 @@ const AdminLayout = () => {
|
||||
const userInfoStr = localStorage.getItem('userInfo');
|
||||
|
||||
if (!token || !userInfoStr) {
|
||||
navigate('/login');
|
||||
navigate('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const GitHubCallback = () => {
|
||||
|
||||
if (!code || !state) {
|
||||
messageApi.error('缺少认证参数');
|
||||
navigate('/login');
|
||||
navigate('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const GitHubCallback = () => {
|
||||
navigate('/admin/agents');
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.response?.data?.message || 'GitHub 认证失败');
|
||||
navigate('/login');
|
||||
navigate('/admin/login');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ const OIDCCallback = () => {
|
||||
? decodeURIComponent(errorDescription)
|
||||
: `认证失败: ${error}`;
|
||||
messageApi.error(errorMessage);
|
||||
navigate('/login');
|
||||
navigate('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const OIDCCallback = () => {
|
||||
|
||||
if (!code || !state) {
|
||||
messageApi.error('缺少认证参数');
|
||||
navigate('/login');
|
||||
navigate('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const OIDCCallback = () => {
|
||||
navigate('/admin/agents');
|
||||
} catch (error: any) {
|
||||
messageApi.error(error.response?.data?.message || 'OIDC 认证失败');
|
||||
navigate('/login');
|
||||
navigate('/admin/login');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { GithubOutlined, GlobalOutlined, LockOutlined, UserOutlined } from '@ant
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { getAuthConfig, getGitHubAuthURL, getOIDCAuthURL, login } from '@/api/auth.ts';
|
||||
import type { LoginRequest } from '@/types';
|
||||
import { useTheme } from '@/portal/contexts/ThemeContext';
|
||||
import { useColorMode } from '@/shared/contexts/ColorModeContext';
|
||||
import { flushSync } from 'react-dom';
|
||||
|
||||
const Login = () => {
|
||||
@@ -17,7 +17,7 @@ const Login = () => {
|
||||
const [githubLoading, setGithubLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { message: messageApi } = App.useApp();
|
||||
const { appliedTheme, setTheme } = useTheme();
|
||||
const { resolvedColorMode: appliedTheme, setColorMode: setTheme } = useColorMode();
|
||||
const themeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -249,4 +249,4 @@ const Login = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
export default Login;
|
||||
@@ -23,11 +23,19 @@ const SystemConfigComponent = () => {
|
||||
// 保存系统配置 mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: saveSystemConfig,
|
||||
onSuccess: () => {
|
||||
messageApi.success('保存成功');
|
||||
queryClient.invalidateQueries({ queryKey: ['systemConfig'] });
|
||||
// 刷新页面以应用新的系统配置
|
||||
window.location.reload();
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['systemConfig'] });
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
if (response.ok) {
|
||||
const runtime = await response.json();
|
||||
window.PikaRuntime = runtime;
|
||||
window.SystemConfig = runtime.legacySystemConfig;
|
||||
}
|
||||
} catch {
|
||||
// 管理界面不依赖这次刷新成功;公开页面刷新后仍会读取最新配置。
|
||||
}
|
||||
messageApi.success('保存成功,公开页面刷新后生效');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
messageApi.error(getErrorMessage(error, '保存失败'));
|
||||
@@ -0,0 +1,112 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Alert, App, Button, Card, Col, Image, Popconfirm, Row, Space, Spin, Tag, Typography, Upload} from 'antd';
|
||||
import {Palette, ShieldAlert, Trash2, UploadCloud} from 'lucide-react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {activateTheme, deleteTheme, listThemes, type ThemeInfo, uploadTheme} from '@/api/theme';
|
||||
import {getErrorMessage} from '@/lib/utils';
|
||||
|
||||
const {Text, Paragraph} = Typography;
|
||||
|
||||
const ThemePreview = ({theme}: {theme: ThemeInfo}) => {
|
||||
const [source, setSource] = useState<string>();
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let objectURL = '';
|
||||
const token = localStorage.getItem('token');
|
||||
fetch(theme.previewUrl, {
|
||||
signal: controller.signal,
|
||||
headers: token ? {Authorization: 'Bearer ' + token} : {},
|
||||
})
|
||||
.then((response) => response.ok ? response.blob() : Promise.reject())
|
||||
.then((blob) => {
|
||||
objectURL = URL.createObjectURL(blob);
|
||||
setSource(objectURL);
|
||||
})
|
||||
.catch(() => setSource(undefined));
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||
};
|
||||
}, [theme.id, theme.previewUrl]);
|
||||
return source
|
||||
? <Image preview={false} src={source} className="!h-40 !w-full object-cover" alt={theme.name}/>
|
||||
: <div className="flex h-40 items-center justify-center bg-slate-100 dark:bg-slate-800"><Palette size={38}/></div>;
|
||||
};
|
||||
|
||||
const ThemeSettings = () => {
|
||||
const {message, modal} = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const themesQuery = useQuery({queryKey: ['themes'], queryFn: listThemes});
|
||||
const refresh = () => queryClient.invalidateQueries({queryKey: ['themes']});
|
||||
const operation = useMutation({
|
||||
mutationFn: async (fn: () => Promise<unknown>) => fn(),
|
||||
onSuccess: async () => { message.success('操作成功'); await refresh(); },
|
||||
onError: (error) => message.error(getErrorMessage(error, '主题操作失败')),
|
||||
});
|
||||
|
||||
const confirmUntrusted = (title: string, action: () => Promise<unknown>) => {
|
||||
modal.confirm({
|
||||
title,
|
||||
icon: <ShieldAlert className="text-amber-500"/>,
|
||||
width: 560,
|
||||
content: (
|
||||
<Alert className="mt-4" type="warning" showIcon
|
||||
message="主题是同源可信代码"
|
||||
description="主题包含可执行 JavaScript,能够访问此站点浏览器存储和公开 API。只安装并启用你完全信任的主题。"/>
|
||||
),
|
||||
okText: '我信任此主题,继续',
|
||||
okButtonProps: {danger: true},
|
||||
onOk: () => operation.mutateAsync(action),
|
||||
});
|
||||
};
|
||||
|
||||
const installedCards = (themesQuery.data || []).map((theme) => (
|
||||
<Col xs={24} md={12} xl={8} key={theme.id}>
|
||||
<Card className="h-full overflow-hidden" cover={<ThemePreview theme={theme}/>}
|
||||
actions={[
|
||||
theme.active ? <Tag color="success">当前主题</Tag> :
|
||||
<Button type="link" disabled={!theme.compatible}
|
||||
onClick={() => confirmUntrusted('启用 ' + theme.name, () => activateTheme(theme.id))}>启用</Button>,
|
||||
theme.official ? <Tag>内置</Tag> :
|
||||
<Popconfirm title="确认删除主题?" description="主题文件删除后只能通过重新安装恢复。"
|
||||
disabled={theme.active}
|
||||
onConfirm={() => operation.mutateAsync(() => deleteTheme(theme.id))}>
|
||||
<Button type="link" danger disabled={theme.active} icon={<Trash2 size={15}/>}>删除</Button>
|
||||
</Popconfirm>,
|
||||
]}>
|
||||
<Card.Meta
|
||||
title={<Space>{theme.name}<Tag>{theme.version}</Tag>{theme.official && <Tag color="blue">官方</Tag>}</Space>}
|
||||
description={<>
|
||||
<Paragraph ellipsis={{rows: 2}}>{theme.description || '暂无说明'}</Paragraph>
|
||||
<Text type="secondary">作者:{theme.author}</Text>
|
||||
{!theme.compatible && <Alert className="mt-3" type="error" showIcon message={theme.compatibilityError}/>}
|
||||
</>}/>
|
||||
</Card>
|
||||
</Col>
|
||||
));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">主题管理</h2>
|
||||
<p className="mt-2 text-gray-500">安装和切换完整的公开页面主题;管理后台始终使用官方界面。</p>
|
||||
</div>
|
||||
<Upload accept=".zip,application/zip" showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
confirmUntrusted('上传并安装 ' + file.name, () => uploadTheme(file as File));
|
||||
return false;
|
||||
}}>
|
||||
<Button icon={<UploadCloud size={15}/>}>上传 ZIP</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
<Alert className="mb-5" type="warning" showIcon message="安全提示"
|
||||
description="第三方主题会在 Pika 同一域名下执行 JavaScript。主题不是沙箱,只安装你完全信任的来源。"/>
|
||||
{themesQuery.isLoading
|
||||
? <div className="py-16 text-center"><Spin/></div>
|
||||
: <Row gutter={[16, 16]}>{installedCards}</Row>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSettings;
|
||||
@@ -1,9 +1,10 @@
|
||||
import {Tabs} from 'antd';
|
||||
import {Bell, MessageSquare, Settings2, Wifi} from 'lucide-react';
|
||||
import {Bell, MessageSquare, Palette, Settings2, Wifi} from 'lucide-react';
|
||||
import AlertSettings from './AlertSettings';
|
||||
import NotificationChannels from './NotificationChannels';
|
||||
import SystemConfig from './SystemConfig';
|
||||
import PublicIPConfig from './PublicIPConfig';
|
||||
import ThemeSettings from './ThemeSettings';
|
||||
import {PageHeader} from "@admin/components";
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
|
||||
@@ -39,6 +40,16 @@ const Settings = () => {
|
||||
),
|
||||
children: <SystemConfig/>,
|
||||
},
|
||||
{
|
||||
key: 'themes',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Palette size={16}/>
|
||||
主题管理
|
||||
</span>
|
||||
),
|
||||
children: <ThemeSettings/>,
|
||||
},
|
||||
{
|
||||
key: 'channels',
|
||||
label: (
|
||||
@@ -82,7 +82,7 @@ const sendRequest = async <T>(url: string, config: RequestConfig = {}): Promise<
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userInfo');
|
||||
if (window.location.pathname.startsWith('/admin')) {
|
||||
window.location.href = '/login';
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
throw new HttpError('未认证或认证已过期', {
|
||||
status: response.status,
|
||||
@@ -0,0 +1,17 @@
|
||||
import {del, get, post, put} from './request';
|
||||
|
||||
export interface ThemeInfo {
|
||||
schemaVersion: number; id: string; name: string; description?: string; version: string; author: string;
|
||||
homepage?: string; license?: string; active: boolean; official: boolean;
|
||||
compatible: boolean; compatibilityError?: string; previewUrl: string;
|
||||
}
|
||||
export interface ThemeInstallResult { theme: ThemeInfo; sha256: string; }
|
||||
|
||||
export const listThemes = async () => (await get<ThemeInfo[]>('/admin/themes')).data;
|
||||
export const uploadTheme = async (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return (await post<ThemeInstallResult>('/admin/themes/upload', form, {timeout: 120000})).data;
|
||||
};
|
||||
export const activateTheme = async (id: string) => { await put(`/admin/themes/${encodeURIComponent(id)}/activate`); };
|
||||
export const deleteTheme = async (id: string) => { await del(`/admin/themes/${encodeURIComponent(id)}`); };
|
||||
@@ -0,0 +1,39 @@
|
||||
import {StrictMode, type ReactNode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import './index.css';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export const renderApplication = (application: ReactNode) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {queries: {refetchOnWindowFocus: false, retry: 1}},
|
||||
});
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>{application}</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
};
|
||||
|
||||
export const loadRuntimeConfig = async () => {
|
||||
if (window.PikaRuntime) return;
|
||||
const response = await fetch('/api/config');
|
||||
if (!response.ok) throw new Error('无法加载 Pika 运行时配置');
|
||||
const payload = await response.json();
|
||||
window.PikaRuntime = payload;
|
||||
window.SystemConfig = payload.legacySystemConfig;
|
||||
};
|
||||
|
||||
export const ensureRuntimeFallback = () => {
|
||||
if (window.SystemConfig) return;
|
||||
window.SystemConfig = {
|
||||
SystemNameZh: '皮卡监控',
|
||||
SystemNameEn: 'Pika Monitor',
|
||||
ICPCode: '',
|
||||
DefaultView: 'grid',
|
||||
Version: '',
|
||||
};
|
||||
};
|
||||