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 二开只需看一个文件
This commit is contained in:
dushixiang committed 2026-08-09 22:45:43 +08:00
1 parent 4bc998c77a
commit 1af64eccd9
201 files changed
+13471 -10742

No files matched your search

-11
View File
@@ -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: |
-11
View File
@@ -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: |
-1
View File
@@ -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
+7 -2
View File
@@ -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:
+6 -2
View File
@@ -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"
+6 -3
View File
@@ -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 # 读超时时间(秒)
+1 -1
View File
@@ -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
+135
View File
@@ -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 和移动宽度下测试。
+301
View File
@@ -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 的项目结构和构建。
+24 -45
View File
@@ -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 登录事件
)
}
+1 -1
View File
@@ -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 {
-81
View File
@@ -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
}
-75
View File
@@ -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)
}
}
+6
View File
@@ -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配置
-8
View File
@@ -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{})
}
+88
View File
@@ -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())
}
}
+216
View File
@@ -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")
}
+127
View File
@@ -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"))
}
}
+28
View File
@@ -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 采集配置",
+590
View File
@@ -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
}
+321
View File
@@ -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, `&lt;script&gt;`) {
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...),
}
}
+265
View File
@@ -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]:]
}
+6
View File
@@ -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
+12
View File
@@ -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
+28 -62
View File
@@ -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/*`。
File renamed without changes.
+14
View File
@@ -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},
},
},
];
+13
View File
@@ -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>
File diff suppressed because it is too large. Load diff
+4 -15
View File
@@ -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"
File renamed without changes.
+15
View File
@@ -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>
);
}
File renamed without changes.

Before

Width:  |  Height:  |  Size: 822 B

After

Width:  |  Height:  |  Size: 822 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

File renamed without changes.

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 />;
}
// 已登录,渲染子组件
File renamed without changes.
@@ -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;
}
File renamed without changes.
@@ -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: (
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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,
File renamed without changes.
+17
View File
@@ -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)}`); };
File renamed without changes.
+39
View File
@@ -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: '',
};
};
File renamed without changes.
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff. Show more