Initial commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
data
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
APP_SECRET=
|
||||||
|
SESSION_TTL_HOURS=12
|
||||||
|
PUBLIC_RATE_LIMIT_PER_HOUR=20
|
||||||
|
PUBLIC_INSTALLATION_LIMIT_PER_HOUR=8
|
||||||
|
MAX_SCREENSHOT_BYTES=5242880
|
||||||
|
FEEDBACK_DOMAIN=feedback.example.com
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{$FEEDBACK_DOMAIN} {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy app:3000
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:24-bookworm-slim AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --ignore-scripts \
|
||||||
|
&& node node_modules/esbuild/install.js
|
||||||
|
|
||||||
|
FROM dependencies AS build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
RUN npm prune --omit=dev --ignore-scripts
|
||||||
|
|
||||||
|
FROM node:24-bookworm-slim AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
RUN groupadd --system productissue \
|
||||||
|
&& useradd --system --gid productissue --home-dir /app productissue \
|
||||||
|
&& mkdir -p /app/data \
|
||||||
|
&& chown -R productissue:productissue /app
|
||||||
|
COPY --from=build --chown=productissue:productissue /app/package.json /app/package-lock.json ./
|
||||||
|
COPY --from=build --chown=productissue:productissue /app/node_modules ./node_modules
|
||||||
|
COPY --from=build --chown=productissue:productissue /app/dist ./dist
|
||||||
|
USER productissue
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/server/server/main.js"]
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# MesaLogo Product Issue
|
||||||
|
|
||||||
|
MesaLogo Product Issue 是一个可独立部署的多产品反馈平台。GoodBuddy
|
||||||
|
是默认接入的第一个产品,后续产品可在管理后台创建并独立筛选。
|
||||||
|
|
||||||
|
平台将公开提交 API、内部管理后台、SQLite 数据库和私有截图存储放在
|
||||||
|
同一套单机部署中,不依赖 GitHub Issue 作为核心数据源。
|
||||||
|
|
||||||
|
## 已实现能力
|
||||||
|
|
||||||
|
- 版本化公共反馈 API,支持问题、建议、体验和其他反馈
|
||||||
|
- 可选联系邮箱、客户端环境信息和一张截图
|
||||||
|
- 按产品和客户端请求 ID 幂等提交
|
||||||
|
- 默认 `goodbuddy` 产品,以及后台中的多产品创建和启停
|
||||||
|
- 中文管理后台,包含统计、搜索、筛选、分页和反馈详情
|
||||||
|
- `待处理 → 已分诊 → 处理中 → 已解决 → 已关闭` 状态流转
|
||||||
|
- 仅管理员可见的截图和内部备注
|
||||||
|
- SQLite WAL 持久化,截图使用独立私有目录
|
||||||
|
- scrypt 管理员密码、HttpOnly 会话 Cookie 和 CSRF 校验
|
||||||
|
- IP 与安装实例双层限流
|
||||||
|
- PNG/JPEG/WebP 解码校验、自动旋转、PNG 重编码和元数据清理
|
||||||
|
- Docker Compose + Caddy 单机部署及自动 HTTPS
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
GoodBuddy / 其他产品
|
||||||
|
|
|
||||||
|
| HTTPS POST /api/v1/feedback
|
||||||
|
v
|
||||||
|
Caddy
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Fastify + React 管理后台
|
||||||
|
|
|
||||||
|
+---- SQLite (反馈、产品、管理员、会话、备注)
|
||||||
|
|
|
||||||
|
+---- 私有 attachments/ (标准化 PNG)
|
||||||
|
```
|
||||||
|
|
||||||
|
管理后台与 API 同源部署。只有 Caddy 暴露 80/443 端口,应用容器不直接
|
||||||
|
发布端口。
|
||||||
|
|
||||||
|
## 快速部署
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 一台安装了 Docker Engine 和 Docker Compose v2 的 Linux 主机
|
||||||
|
- 一个已将 A/AAAA 记录指向该主机的域名
|
||||||
|
- 公网可访问的 TCP 80、TCP 443 和 UDP 443
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `.env`:
|
||||||
|
|
||||||
|
- 将 `FEEDBACK_DOMAIN` 改为真实域名
|
||||||
|
- 用至少 32 字节的随机值替换 `APP_SECRET`
|
||||||
|
|
||||||
|
可用以下方式生成 secret:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
APP_SECRET="$(openssl rand -hex 32)"
|
||||||
|
sed -i "s|^APP_SECRET=.*|APP_SECRET=${APP_SECRET}|" .env
|
||||||
|
unset APP_SECRET
|
||||||
|
chmod 600 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
构建镜像并创建首个管理员:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose build
|
||||||
|
read -rsp "Administrator password: " PRODUCT_ISSUE_ADMIN_PASSWORD
|
||||||
|
echo
|
||||||
|
export PRODUCT_ISSUE_ADMIN_PASSWORD
|
||||||
|
docker compose run --rm \
|
||||||
|
-e PRODUCT_ISSUE_ADMIN_PASSWORD \
|
||||||
|
app node dist/server/server/cli/create-admin.js \
|
||||||
|
--email admin@example.com \
|
||||||
|
--name "MesaLogo Admin"
|
||||||
|
unset PRODUCT_ISSUE_ADMIN_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
启动服务:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
curl "https://feedback.example.com/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
将示例域名替换为 `.env` 中的真实域名。管理后台位于
|
||||||
|
`https://你的域名/admin/`。
|
||||||
|
|
||||||
|
> 管理员创建命令也用于密码重置。重置后,该管理员的所有既有会话会立即
|
||||||
|
> 失效。不要把密码写入 `.env`、命令参数或 Git。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
要求 Node.js 24 或更新版本。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
开发服务器需要通过进程环境提供 `APP_SECRET` 和其他配置。后端默认监听
|
||||||
|
`127.0.0.1:3000`,管理后台 Vite 服务默认监听 `127.0.0.1:5174` 并代理
|
||||||
|
`/api`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
# 另一个终端
|
||||||
|
npm run dev:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [公共 API 契约](docs/api.md)
|
||||||
|
- [部署、升级、备份与恢复](docs/operations.md)
|
||||||
|
- [数据与隐私边界](docs/privacy.md)
|
||||||
|
- [GoodBuddy 接入边界](docs/goodbuddy-integration.md)
|
||||||
|
|
||||||
|
## 当前边界
|
||||||
|
|
||||||
|
- 这是单机部署方案,不提供多节点 SQLite 共享写入。
|
||||||
|
- 反馈和附件默认持续保留,由部署方制定并执行保留策略。
|
||||||
|
- 管理后台不对公网提供注册入口,管理员只能通过主机命令创建或重置。
|
||||||
|
- GoodBuddy 桌面端接入将在生产 API 域名确定后实现。
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
init: true
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=64m,mode=1777
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 3000
|
||||||
|
PUBLIC_ORIGIN: https://${FEEDBACK_DOMAIN:?FEEDBACK_DOMAIN is required}
|
||||||
|
DATA_DIRECTORY: /app/data
|
||||||
|
APP_SECRET: ${APP_SECRET:?APP_SECRET is required}
|
||||||
|
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-12}
|
||||||
|
TRUST_PROXY: "true"
|
||||||
|
PUBLIC_RATE_LIMIT_PER_HOUR: ${PUBLIC_RATE_LIMIT_PER_HOUR:-20}
|
||||||
|
PUBLIC_INSTALLATION_LIMIT_PER_HOUR: ${PUBLIC_INSTALLATION_LIMIT_PER_HOUR:-8}
|
||||||
|
MAX_SCREENSHOT_BYTES: ${MAX_SCREENSHOT_BYTES:-5242880}
|
||||||
|
volumes:
|
||||||
|
- feedback-data:/app/data
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:3000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2.10.2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
FEEDBACK_DOMAIN: ${FEEDBACK_DOMAIN:?FEEDBACK_DOMAIN is required}
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
- caddy-config:/config
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
feedback-data:
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# 公共反馈 API
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
该接口不使用用户账户。服务端按来源 IP 和 `installationId` 的 keyed
|
||||||
|
digest 分别限流,并使用 `(productKey, clientRequestId)` 保证幂等。
|
||||||
|
|
||||||
|
生产环境必须使用 HTTPS。若请求包含 `Origin`,它必须与服务端
|
||||||
|
`PUBLIC_ORIGIN` 的 origin 完全一致。桌面应用的 Main 进程请求通常不发送
|
||||||
|
`Origin`。
|
||||||
|
|
||||||
|
## JSON 提交
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/feedback HTTP/1.1
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"productKey": "goodbuddy",
|
||||||
|
"category": "bug",
|
||||||
|
"title": "魔法笔记页签不可见",
|
||||||
|
"description": "平台功能中的魔法笔记页签被布局压缩,无法看到或点击。",
|
||||||
|
"contactEmail": "user@example.com",
|
||||||
|
"environment": {
|
||||||
|
"appVersion": "0.11.0",
|
||||||
|
"platform": "windows",
|
||||||
|
"architecture": "x64",
|
||||||
|
"locale": "zh-CN"
|
||||||
|
},
|
||||||
|
"installationId": "00000000-0000-4000-8000-000000000101",
|
||||||
|
"clientRequestId": "00000000-0000-4000-8000-000000000102"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multipart 提交截图
|
||||||
|
|
||||||
|
使用 `multipart/form-data` 时:
|
||||||
|
|
||||||
|
- `payload`:上述 JSON 的字符串形式
|
||||||
|
- `screenshot`:可选,最多一个 PNG、JPEG 或 WebP 文件
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl \
|
||||||
|
--fail-with-body \
|
||||||
|
--form 'payload={
|
||||||
|
"schemaVersion":1,
|
||||||
|
"productKey":"goodbuddy",
|
||||||
|
"category":"bug",
|
||||||
|
"title":"魔法笔记页签不可见",
|
||||||
|
"description":"平台功能中的魔法笔记页签被布局压缩,无法看到或点击。",
|
||||||
|
"installationId":"00000000-0000-4000-8000-000000000101",
|
||||||
|
"clientRequestId":"00000000-0000-4000-8000-000000000102"
|
||||||
|
}' \
|
||||||
|
--form 'screenshot=@capture.png;type=image/png' \
|
||||||
|
https://feedback.example.com/api/v1/feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端会验证实际解码格式,只接受单帧 PNG、JPEG 或 WebP,随后应用方向
|
||||||
|
信息、重编码为 PNG 并去除原文件元数据。
|
||||||
|
输入和重编码后的文件都必须满足 `MAX_SCREENSHOT_BYTES`。最大边长为
|
||||||
|
8192 像素,最大像素数为 3200 万。
|
||||||
|
|
||||||
|
## 字段
|
||||||
|
|
||||||
|
| 字段 | 必填 | 约束 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `schemaVersion` | 是 | 当前固定为 `1` |
|
||||||
|
| `productKey` | 是 | 2–64 位小写字母、数字或连字符,产品必须启用 |
|
||||||
|
| `category` | 是 | `bug`、`feature`、`experience`、`other` |
|
||||||
|
| `title` | 是 | 去除首尾空白后 1–120 字符 |
|
||||||
|
| `description` | 是 | 去除首尾空白后 10–5000 字符 |
|
||||||
|
| `contactEmail` | 否 | 有效邮箱,最多 254 字符 |
|
||||||
|
| `environment.appVersion` | 环境存在时 | 1–64 字符 |
|
||||||
|
| `environment.platform` | 环境存在时 | `windows`、`macos`、`linux`、`unknown` |
|
||||||
|
| `environment.architecture` | 环境存在时 | `x64`、`arm64`、`unknown` |
|
||||||
|
| `environment.locale` | 否 | 2–32 字符 |
|
||||||
|
| `installationId` | 是 | 应用安装级随机 UUID,不应使用硬件 ID |
|
||||||
|
| `clientRequestId` | 是 | 每次新反馈生成的随机 UUID;重试时保持不变 |
|
||||||
|
|
||||||
|
请求对象采用严格 schema,未声明字段会导致请求失败。
|
||||||
|
|
||||||
|
## 成功响应
|
||||||
|
|
||||||
|
首次创建返回 HTTP 201:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reference": "GOODBUDDY-000001",
|
||||||
|
"duplicate": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同一产品和 `clientRequestId` 的重试返回 HTTP 200,并返回原编号:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reference": "GOODBUDDY-000001",
|
||||||
|
"duplicate": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误
|
||||||
|
|
||||||
|
| HTTP | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 400 | 字段、JSON、multipart 或图片无效 |
|
||||||
|
| 403 | 请求 `Origin` 不允许 |
|
||||||
|
| 404 | 产品不存在或已停用 |
|
||||||
|
| 413 | 请求体超过限制 |
|
||||||
|
| 429 | IP 或安装实例超过提交频率 |
|
||||||
|
| 500 | 服务端内部错误 |
|
||||||
|
|
||||||
|
客户端应:
|
||||||
|
|
||||||
|
1. 为一次用户提交生成 `clientRequestId`,网络重试时复用。
|
||||||
|
2. 设置明确的连接与总超时。
|
||||||
|
3. 限制响应体大小,只接受 JSON。
|
||||||
|
4. 将非 2xx 响应作为失败处理,并向用户保留输入内容。
|
||||||
|
5. 仅在用户明确选择后添加截图。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# GoodBuddy 接入边界
|
||||||
|
|
||||||
|
本文件记录 Product Issue 已提供的服务端契约。GoodBuddy 桌面端接入应在
|
||||||
|
生产域名确定后实现。
|
||||||
|
|
||||||
|
## 推荐调用链
|
||||||
|
|
||||||
|
```text
|
||||||
|
About 页面反馈表单
|
||||||
|
→ shared Zod contract
|
||||||
|
→ preload 显式方法
|
||||||
|
→ Main IPC handler
|
||||||
|
→ Main HTTPS client
|
||||||
|
→ POST /api/v1/feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
不要从 Electron renderer 直接跨域调用公开 API,也不要向 renderer 暴露
|
||||||
|
通用网络能力。
|
||||||
|
|
||||||
|
## Renderer
|
||||||
|
|
||||||
|
- 在“关于”页面内提供反馈入口,不跳转 GitHub Issue。
|
||||||
|
- 字段包括类型、标题、描述、可选邮箱和可选截图。
|
||||||
|
- 发送前显示会提交的环境字段和隐私说明。
|
||||||
|
- 截图需要主动选择或粘贴,支持预览和移除。
|
||||||
|
- 失败时保留所有输入,成功时显示服务端反馈编号。
|
||||||
|
- 同一次提交重试必须复用 `clientRequestId`。
|
||||||
|
|
||||||
|
## Preload 与 IPC
|
||||||
|
|
||||||
|
- 在 `src/shared` 定义并复用 Zod 输入/输出 schema。
|
||||||
|
- 使用现有 trusted sender 校验。
|
||||||
|
- preload 只暴露一个用途明确的反馈提交方法。
|
||||||
|
- IPC 输入必须限制字符串长度、附件数量和字节数。
|
||||||
|
- 不传递文件系统路径给服务端。
|
||||||
|
|
||||||
|
## Main HTTPS client
|
||||||
|
|
||||||
|
- 固定并校验生产 HTTPS origin。
|
||||||
|
- 使用正常系统 TLS 校验,不允许忽略证书错误。
|
||||||
|
- 设置连接和总超时,支持取消。
|
||||||
|
- 禁止或严格限制重定向,避免把反馈发送到非预期 origin。
|
||||||
|
- 限制响应体大小并按公共响应 schema 验证。
|
||||||
|
- 不记录请求正文、邮箱、图片、installation ID 或响应凭据。
|
||||||
|
|
||||||
|
## 标识
|
||||||
|
|
||||||
|
- `installationId`:应用首次生成并持久保存的随机 UUID,不使用硬件序列号、
|
||||||
|
用户名或设备指纹。
|
||||||
|
- `clientRequestId`:用户发起一条新反馈时生成;只有该次反馈的网络重试
|
||||||
|
复用。
|
||||||
|
- `productKey`:GoodBuddy 固定使用 `goodbuddy`。
|
||||||
|
|
||||||
|
## 明确不自动附加
|
||||||
|
|
||||||
|
- 对话和提示词
|
||||||
|
- 日志
|
||||||
|
- 文件、项目或工作区路径
|
||||||
|
- API key、Token、Cookie、SSH 信息
|
||||||
|
- 模型、知识库和 Runtime 配置
|
||||||
|
- 剪贴板或屏幕内容
|
||||||
|
|
||||||
|
服务端完整字段与响应见 [公共 API 契约](api.md)。
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# 部署与运维
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
Docker Compose 从项目根目录 `.env` 读取以下值:
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FEEDBACK_DOMAIN` | Caddy 申请证书并对外服务的域名 | 必填 |
|
||||||
|
| `APP_SECRET` | 会话、IP 和安装标识 digest 的主 secret,至少 32 字符 | 必填 |
|
||||||
|
| `SESSION_TTL_HOURS` | 管理员会话时长 | `12` |
|
||||||
|
| `PUBLIC_RATE_LIMIT_PER_HOUR` | 单 IP 每小时公开提交上限 | `20` |
|
||||||
|
| `PUBLIC_INSTALLATION_LIMIT_PER_HOUR` | 单安装实例每小时公开提交上限 | `8` |
|
||||||
|
| `MAX_SCREENSHOT_BYTES` | 输入及标准化后截图的字节上限 | `5242880` |
|
||||||
|
|
||||||
|
`.env` 包含 secret,不得提交到 Git 或复制到工单、聊天和日志中。建议在
|
||||||
|
密码管理器中保存一份加密副本,并在主机上执行 `chmod 600 .env`。
|
||||||
|
|
||||||
|
Compose 会从 `FEEDBACK_DOMAIN` 生成
|
||||||
|
`PUBLIC_ORIGIN=https://FEEDBACK_DOMAIN`。直接运行 Node 服务时,
|
||||||
|
production 环境仍必须显式提供只含 HTTPS origin 的 `PUBLIC_ORIGIN`。
|
||||||
|
|
||||||
|
## 网络与 TLS
|
||||||
|
|
||||||
|
- DNS A/AAAA 记录必须指向主机。
|
||||||
|
- 入站开放 TCP 80、TCP 443 和 UDP 443。
|
||||||
|
- 不要直接发布应用容器的 3000 端口。
|
||||||
|
- Caddy 自动申请并续期证书。
|
||||||
|
- `TRUST_PROXY=true` 仅用于当前 Caddy → app 的受控容器网络拓扑;改变
|
||||||
|
代理层级时必须重新检查真实客户端 IP 解析。
|
||||||
|
|
||||||
|
验证:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose ps
|
||||||
|
curl --fail "https://feedback.example.com/health"
|
||||||
|
curl -I "https://feedback.example.com/admin/"
|
||||||
|
```
|
||||||
|
|
||||||
|
将示例域名替换为 `.env` 中的真实 `FEEDBACK_DOMAIN`。
|
||||||
|
|
||||||
|
## 管理员
|
||||||
|
|
||||||
|
创建或重置管理员:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
read -rsp "Administrator password: " PRODUCT_ISSUE_ADMIN_PASSWORD
|
||||||
|
echo
|
||||||
|
export PRODUCT_ISSUE_ADMIN_PASSWORD
|
||||||
|
docker compose run --rm \
|
||||||
|
-e PRODUCT_ISSUE_ADMIN_PASSWORD \
|
||||||
|
app node dist/server/server/cli/create-admin.js \
|
||||||
|
--email admin@example.com \
|
||||||
|
--name "MesaLogo Admin"
|
||||||
|
unset PRODUCT_ISSUE_ADMIN_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
密码必须为 12–128 字符。相同邮箱再次执行会更新显示名和密码、重新启用
|
||||||
|
管理员,并删除该管理员的所有既有会话。
|
||||||
|
|
||||||
|
## 备份
|
||||||
|
|
||||||
|
应用数据全部位于 `feedback-data` volume:
|
||||||
|
|
||||||
|
- `product-issue.sqlite` 以及可能存在的 `-wal` / `-shm`
|
||||||
|
- `attachments/<feedback-id>/screenshot.png`
|
||||||
|
|
||||||
|
为获得一致快照,备份时短暂停止 app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
umask 077
|
||||||
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
mkdir -p "backups/${STAMP}"
|
||||||
|
docker compose stop app
|
||||||
|
docker compose cp app:/app/data/. "backups/${STAMP}/"
|
||||||
|
docker compose start app
|
||||||
|
tar -C backups -czf "backups/product-issue-${STAMP}.tar.gz" "${STAMP}"
|
||||||
|
```
|
||||||
|
|
||||||
|
同时安全备份 `.env` 中的生产配置,但应与数据归档分开加密保存。不要只
|
||||||
|
备份 SQLite 主文件而遗漏 WAL 或 attachments。
|
||||||
|
|
||||||
|
建议至少每日备份,并定期把加密副本同步到另一台主机或对象存储。定期在
|
||||||
|
隔离环境执行恢复演练,而不是只检查归档文件存在。
|
||||||
|
|
||||||
|
## 恢复
|
||||||
|
|
||||||
|
恢复会替换当前 volume 内容。先保存当前数据并确认选中的归档:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p restore
|
||||||
|
tar -C restore -xzf backups/product-issue-YYYYMMDDTHHMMSSZ.tar.gz
|
||||||
|
|
||||||
|
docker compose stop app
|
||||||
|
docker compose run --rm --no-deps app sh -c \
|
||||||
|
'find /app/data -mindepth 1 -maxdepth 1 -exec rm -rf {} +'
|
||||||
|
docker compose run --rm --no-deps \
|
||||||
|
-v "$PWD/restore/YYYYMMDDTHHMMSSZ:/restore:ro" \
|
||||||
|
app sh -c 'cp -R /restore/. /app/data/'
|
||||||
|
docker compose start app
|
||||||
|
```
|
||||||
|
|
||||||
|
随后检查:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose logs --tail=100 app
|
||||||
|
curl --fail "https://feedback.example.com/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
登录后台并抽查产品、反馈、备注和截图。
|
||||||
|
|
||||||
|
## 升级
|
||||||
|
|
||||||
|
1. 创建并验证当前备份。
|
||||||
|
2. 获取经过审核的新版本源码。
|
||||||
|
3. 运行 `docker compose build --pull`。
|
||||||
|
4. 运行 `docker compose up -d`。
|
||||||
|
5. 检查 health、容器日志、管理员登录和一条测试反馈。
|
||||||
|
|
||||||
|
数据库迁移在应用启动时执行,采用可重复的 `CREATE TABLE/INDEX IF NOT
|
||||||
|
EXISTS`。未来出现结构变更时,仍应先备份再升级。
|
||||||
|
|
||||||
|
## Secret 轮换
|
||||||
|
|
||||||
|
更新 `.env` 中的 `APP_SECRET`,然后重建 app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d --force-recreate app
|
||||||
|
```
|
||||||
|
|
||||||
|
轮换会让所有管理员会话失效,并使后续 IP/安装标识使用新的 digest。
|
||||||
|
数据库中的反馈、备注和截图不依赖该 secret 解密,因此仍可读取。
|
||||||
|
|
||||||
|
## 监控与故障排查
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=200 app
|
||||||
|
docker compose logs --tail=200 caddy
|
||||||
|
docker compose exec app node -e \
|
||||||
|
"fetch('http://127.0.0.1:3000/health').then(async r=>console.log(r.status,await r.text()))"
|
||||||
|
```
|
||||||
|
|
||||||
|
应用关闭了常规逐请求访问日志,避免把公开提交者的原始 IP 持久写入应用
|
||||||
|
日志。拒绝和内部错误使用请求 ID 记录,不记录反馈正文、邮箱、截图内容、
|
||||||
|
Cookie、CSRF token 或 secret。
|
||||||
|
|
||||||
|
## 数据保留
|
||||||
|
|
||||||
|
当前版本不自动删除反馈或附件。部署方应根据实际支持流程定义保留期限,
|
||||||
|
并将数据库行和对应附件视为同一条记录处理。任何直接数据库维护都必须先
|
||||||
|
停止写入、完成备份,并在隔离副本上验证。
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 数据与隐私边界
|
||||||
|
|
||||||
|
## 公开提交会存储什么
|
||||||
|
|
||||||
|
| 数据 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| 产品、类型、标题、描述 | 分流和处理反馈 |
|
||||||
|
| 可选联系邮箱 | 在需要时联系提交者 |
|
||||||
|
| 可选应用版本、系统、架构、语言 | 判断影响范围和辅助复现 |
|
||||||
|
| `clientRequestId` | 网络重试幂等 |
|
||||||
|
| installation ID 的 keyed digest | 安装实例限流和滥用控制 |
|
||||||
|
| 来源 IP 的 keyed digest | 滥用调查和限流关联 |
|
||||||
|
| 可选截图 | 复现界面或视觉问题 |
|
||||||
|
| 创建和更新时间 | 排序与处理记录 |
|
||||||
|
|
||||||
|
服务器不会保存原始 installation ID 或原始 IP。digest 使用
|
||||||
|
`APP_SECRET` 生成。管理员状态修改和内部备注也会存入数据库。
|
||||||
|
|
||||||
|
## 默认不收集什么
|
||||||
|
|
||||||
|
GoodBuddy 客户端接入必须保持以下默认边界,除非未来为具体字段另行设计并
|
||||||
|
向用户清楚展示:
|
||||||
|
|
||||||
|
- 对话、提示词或模型回复
|
||||||
|
- 应用日志和崩溃日志
|
||||||
|
- 文件内容、项目内容和本地路径
|
||||||
|
- API key、Cookie、Token、SSH 凭据或主机配置
|
||||||
|
- 模型、知识库或 Runtime 的详细配置
|
||||||
|
- 剪贴板内容
|
||||||
|
- 屏幕截图
|
||||||
|
|
||||||
|
截图只能由用户主动选择或粘贴后随该次反馈上传。提交界面必须在发送前让
|
||||||
|
用户看到截图已包含,并允许移除。
|
||||||
|
|
||||||
|
## 截图处理
|
||||||
|
|
||||||
|
- 只接受单帧 PNG、JPEG、WebP,并验证实际解码格式。
|
||||||
|
- 服务端实际解码图片,不依赖扩展名。
|
||||||
|
- 应用方向信息后统一重编码为 PNG。
|
||||||
|
- 不复制原图 EXIF/XMP 等元数据。
|
||||||
|
- 原始上传字节不会作为附件保留。
|
||||||
|
- 标准化后的图片位于私有数据 volume,不由静态服务器公开。
|
||||||
|
- 只有具有有效管理员会话的同源接口可以读取截图,响应使用
|
||||||
|
`Cache-Control: private, no-store`。
|
||||||
|
|
||||||
|
截图可能仍然包含屏幕中肉眼可见的个人信息、文件名、消息或其他敏感内容。
|
||||||
|
客户端必须提醒用户检查画面。
|
||||||
|
|
||||||
|
## 管理访问
|
||||||
|
|
||||||
|
- 没有公开注册入口。
|
||||||
|
- 管理员只能通过主机 CLI 创建或重置。
|
||||||
|
- 密码使用带随机 salt 的 scrypt hash 保存。
|
||||||
|
- 会话 token 仅放在 HttpOnly、SameSite=Strict Cookie 中,数据库只保存
|
||||||
|
keyed digest。
|
||||||
|
- 管理写操作需要 CSRF token。
|
||||||
|
- 内部备注和可选联系邮箱只在管理后台展示。
|
||||||
|
|
||||||
|
## 传输、日志与保留
|
||||||
|
|
||||||
|
- 生产流量通过 Caddy HTTPS 传输。
|
||||||
|
- 常规应用访问日志关闭,避免持久记录公开提交者的原始 IP。
|
||||||
|
- 应用错误日志不得包含反馈正文、邮箱、截图、认证 Cookie 或 secret。
|
||||||
|
- 当前版本不会自动删除反馈。部署方负责公布和执行实际保留期限,并保护
|
||||||
|
备份中的同类数据。
|
||||||
|
|
||||||
|
## 客户端展示要求
|
||||||
|
|
||||||
|
在 GoodBuddy 或其他产品发送前,界面至少应说明:
|
||||||
|
|
||||||
|
1. 标题、描述和所选反馈类型会发送到 MesaLogo 反馈服务。
|
||||||
|
2. 邮箱是可选项。
|
||||||
|
3. 应用版本、操作系统和架构会在界面列明后发送。
|
||||||
|
4. 截图默认不上传,只有明确添加后才发送。
|
||||||
|
5. 不会自动附加对话、日志、文件、项目路径、模型配置或凭据。
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import eslint from '@eslint/js'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'dist/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'coverage/**',
|
||||||
|
'data/**',
|
||||||
|
'eslint.config.js'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: [
|
||||||
|
'./tsconfig.server.json',
|
||||||
|
'./tsconfig.admin.json',
|
||||||
|
'./tsconfig.test.json'
|
||||||
|
],
|
||||||
|
tsconfigRootDir: import.meta.dirname
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'@typescript-eslint/consistent-type-imports': 'error',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
|
'@typescript-eslint/no-misused-promises': 'error',
|
||||||
|
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||||
|
'@typescript-eslint/require-await': 'off',
|
||||||
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
|
'@typescript-eslint/restrict-template-expressions': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
allowNumber: true,
|
||||||
|
allowBoolean: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
Generated
+5470
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "product-issue",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Private multi-product feedback platform for MesaLogo products",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server/main.ts",
|
||||||
|
"dev:admin": "vite",
|
||||||
|
"build": "npm run typecheck && npm run build:admin && npm run build:server",
|
||||||
|
"build:admin": "vite build",
|
||||||
|
"build:server": "tsc -p tsconfig.server.json",
|
||||||
|
"start": "node dist/server/server/main.js",
|
||||||
|
"admin:create": "tsx src/server/cli/create-admin.ts",
|
||||||
|
"admin:create:prod": "node dist/server/server/cli/create-admin.js",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.admin.json && tsc --noEmit -p tsconfig.test.json",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.5",
|
||||||
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
|
"eslint": "^10.9.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"tsx": "^4.23.12",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.67.0",
|
||||||
|
"vite": "^8.2.2",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.1.2",
|
||||||
|
"@fastify/helmet": "^13.1.1",
|
||||||
|
"@fastify/multipart": "^10.1.1",
|
||||||
|
"@fastify/rate-limit": "^11.2.0",
|
||||||
|
"@fastify/static": "^10.1.3",
|
||||||
|
"better-sqlite3": "^13.0.3",
|
||||||
|
"fastify": "^5.12.1",
|
||||||
|
"lucide-react": "^1.33.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"better-sqlite3@13.0.3": false,
|
||||||
|
"esbuild@0.28.2": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+1037
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
|||||||
|
import type {
|
||||||
|
AdminSessionView,
|
||||||
|
FeedbackCategory,
|
||||||
|
FeedbackDetail,
|
||||||
|
FeedbackListItem,
|
||||||
|
FeedbackStatus,
|
||||||
|
ProductView
|
||||||
|
} from '../shared/contracts'
|
||||||
|
|
||||||
|
let csrfToken = ''
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const method = options.method ?? 'GET'
|
||||||
|
const headers = new Headers(options.headers)
|
||||||
|
if (options.body && !(options.body instanceof FormData)) {
|
||||||
|
headers.set('Content-Type', 'application/json')
|
||||||
|
}
|
||||||
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && csrfToken) {
|
||||||
|
headers.set('X-CSRF-Token', csrfToken)
|
||||||
|
}
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => ({}))) as {
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
payload.error ?? `请求失败(HTTP ${response.status})`,
|
||||||
|
response.status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberSession(session: AdminSessionView): AdminSessionView {
|
||||||
|
csrfToken = session.csrfToken
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
session: async (): Promise<AdminSessionView> =>
|
||||||
|
rememberSession(await request<AdminSessionView>('/api/admin/session')),
|
||||||
|
|
||||||
|
login: async (email: string, password: string): Promise<AdminSessionView> =>
|
||||||
|
rememberSession(
|
||||||
|
await request<AdminSessionView>('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
|
logout: async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await request<void>('/api/admin/logout', { method: 'POST' })
|
||||||
|
} finally {
|
||||||
|
csrfToken = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
products: async (): Promise<ProductView[]> =>
|
||||||
|
(await request<{ items: ProductView[] }>('/api/admin/products')).items,
|
||||||
|
|
||||||
|
createProduct: async (key: string, name: string): Promise<ProductView> =>
|
||||||
|
request<ProductView>('/api/admin/products', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ key, name })
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateProduct: async (
|
||||||
|
id: string,
|
||||||
|
update: { name?: string; enabled?: boolean }
|
||||||
|
): Promise<ProductView> =>
|
||||||
|
request<ProductView>(`/api/admin/products/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(update)
|
||||||
|
}),
|
||||||
|
|
||||||
|
feedback: async (query: {
|
||||||
|
productKey?: string
|
||||||
|
status?: FeedbackStatus
|
||||||
|
category?: FeedbackCategory
|
||||||
|
search?: string
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
}): Promise<{
|
||||||
|
items: FeedbackListItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
}> => {
|
||||||
|
const parameters = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value !== undefined && value !== '') {
|
||||||
|
parameters.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request(`/api/admin/feedback?${parameters.toString()}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
feedbackDetail: async (id: string): Promise<FeedbackDetail> =>
|
||||||
|
request(`/api/admin/feedback/${encodeURIComponent(id)}`),
|
||||||
|
|
||||||
|
updateStatus: async (
|
||||||
|
id: string,
|
||||||
|
status: FeedbackStatus
|
||||||
|
): Promise<FeedbackDetail> =>
|
||||||
|
request(`/api/admin/feedback/${encodeURIComponent(id)}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status })
|
||||||
|
}),
|
||||||
|
|
||||||
|
addNote: async (id: string, body: string): Promise<FeedbackDetail> =>
|
||||||
|
request(`/api/admin/feedback/${encodeURIComponent(id)}/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ body })
|
||||||
|
}),
|
||||||
|
|
||||||
|
summary: async (
|
||||||
|
productKey?: string
|
||||||
|
): Promise<{
|
||||||
|
total: number
|
||||||
|
byStatus: Record<FeedbackStatus, number>
|
||||||
|
}> => {
|
||||||
|
const parameters = new URLSearchParams()
|
||||||
|
if (productKey) {
|
||||||
|
parameters.set('productKey', productKey)
|
||||||
|
}
|
||||||
|
return request(`/api/admin/summary?${parameters.toString()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="theme-color" content="#f4f7fb" />
|
||||||
|
<title>MesaLogo 产品反馈</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { App } from './App'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
const root = document.getElementById('root')
|
||||||
|
if (!root) {
|
||||||
|
throw new Error('Missing application root')
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,649 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import cookie from '@fastify/cookie'
|
||||||
|
import helmet from '@fastify/helmet'
|
||||||
|
import multipart from '@fastify/multipart'
|
||||||
|
import rateLimit from '@fastify/rate-limit'
|
||||||
|
import staticFiles from '@fastify/static'
|
||||||
|
import Fastify, {
|
||||||
|
LogController,
|
||||||
|
type FastifyInstance,
|
||||||
|
type FastifyReply,
|
||||||
|
type FastifyRequest
|
||||||
|
} from 'fastify'
|
||||||
|
import { ZodError } from 'zod'
|
||||||
|
import {
|
||||||
|
adminLoginSchema,
|
||||||
|
feedbackListQuerySchema,
|
||||||
|
feedbackNoteCreateSchema,
|
||||||
|
feedbackStatusUpdateSchema,
|
||||||
|
productCreateSchema,
|
||||||
|
productUpdateSchema,
|
||||||
|
publicFeedbackPayloadSchema,
|
||||||
|
summaryQuerySchema,
|
||||||
|
type AdminSessionView
|
||||||
|
} from '../shared/contracts.js'
|
||||||
|
import { AuthService } from './auth-service.js'
|
||||||
|
import type { AppConfig } from './config.js'
|
||||||
|
import type { AppDatabase } from './database.js'
|
||||||
|
import {
|
||||||
|
FeedbackRepository,
|
||||||
|
ProductNotFoundError
|
||||||
|
} from './feedback-repository.js'
|
||||||
|
import { InstallationLimiter } from './installation-limiter.js'
|
||||||
|
import { keyedDigest, safeTokenEquals } from './security.js'
|
||||||
|
import {
|
||||||
|
ScreenshotStorage,
|
||||||
|
ScreenshotValidationError
|
||||||
|
} from './screenshot-storage.js'
|
||||||
|
|
||||||
|
const maximumPayloadCharacters = 8_000
|
||||||
|
const maximumPayloadBytes = 32 * 1_024
|
||||||
|
const supportedScreenshotMimeTypes = new Set([
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/webp'
|
||||||
|
])
|
||||||
|
|
||||||
|
type AppDependencies = {
|
||||||
|
config: AppConfig
|
||||||
|
database: AppDatabase
|
||||||
|
adminDirectory?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class PublicSubmissionError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'PublicSubmissionError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientErrorStatus(error: unknown): number | undefined {
|
||||||
|
if (
|
||||||
|
typeof error !== 'object' ||
|
||||||
|
error === null ||
|
||||||
|
!('statusCode' in error) ||
|
||||||
|
typeof error.statusCode !== 'number'
|
||||||
|
) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return error.statusCode >= 400 && error.statusCode < 500
|
||||||
|
? error.statusCode
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : 'Unexpected error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMutation(method: string): boolean {
|
||||||
|
return !['GET', 'HEAD', 'OPTIONS'].includes(method)
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminView(
|
||||||
|
admin: NonNullable<FastifyRequest['admin']>
|
||||||
|
): AdminSessionView {
|
||||||
|
return {
|
||||||
|
admin: {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
displayName: admin.displayName
|
||||||
|
},
|
||||||
|
csrfToken: admin.csrfToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPublicSubmission(
|
||||||
|
request: FastifyRequest,
|
||||||
|
maximumScreenshotBytes: number
|
||||||
|
): Promise<{ payload: unknown; screenshot?: Buffer }> {
|
||||||
|
if (!request.isMultipart()) {
|
||||||
|
return { payload: request.body }
|
||||||
|
}
|
||||||
|
let payloadText: string | undefined
|
||||||
|
let screenshot: Buffer | undefined
|
||||||
|
for await (const part of request.parts()) {
|
||||||
|
if (part.type === 'field') {
|
||||||
|
if (part.fieldname === 'payload') {
|
||||||
|
if (typeof part.value !== 'string') {
|
||||||
|
throw new PublicSubmissionError(
|
||||||
|
'Feedback payload must be text'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (payloadText !== undefined) {
|
||||||
|
throw new PublicSubmissionError(
|
||||||
|
'Only one feedback payload is allowed'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
payloadText = part.value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (part.fieldname !== 'screenshot') {
|
||||||
|
part.file.resume()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (screenshot) {
|
||||||
|
part.file.resume()
|
||||||
|
throw new PublicSubmissionError('Only one screenshot is allowed')
|
||||||
|
}
|
||||||
|
if (!supportedScreenshotMimeTypes.has(part.mimetype)) {
|
||||||
|
part.file.resume()
|
||||||
|
throw new PublicSubmissionError(
|
||||||
|
'Screenshot type is not supported'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
screenshot = await part.toBuffer()
|
||||||
|
if (screenshot.byteLength > maximumScreenshotBytes) {
|
||||||
|
throw new PublicSubmissionError('Screenshot is too large')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!payloadText || payloadText.length > maximumPayloadCharacters) {
|
||||||
|
throw new PublicSubmissionError(
|
||||||
|
'Feedback payload is missing or too large'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
payload: JSON.parse(payloadText) as unknown,
|
||||||
|
...(screenshot ? { screenshot } : {})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new PublicSubmissionError('Feedback payload is not valid JSON')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildApp(
|
||||||
|
dependencies: AppDependencies
|
||||||
|
): Promise<FastifyInstance> {
|
||||||
|
const { config, database } = dependencies
|
||||||
|
const sessionCookieName =
|
||||||
|
config.nodeEnv === 'production'
|
||||||
|
? '__Host-product_issue_session'
|
||||||
|
: 'product_issue_session'
|
||||||
|
const app = Fastify({
|
||||||
|
logger: config.nodeEnv !== 'test',
|
||||||
|
logController: new LogController({
|
||||||
|
disableRequestLogging: true
|
||||||
|
}),
|
||||||
|
trustProxy: config.trustProxy,
|
||||||
|
bodyLimit: config.maxScreenshotBytes + maximumPayloadBytes + 16 * 1_024,
|
||||||
|
requestIdHeader: false,
|
||||||
|
genReqId: () => randomUUID()
|
||||||
|
})
|
||||||
|
const auth = new AuthService(database, config.appSecret, config.sessionTtlMs)
|
||||||
|
const repository = new FeedbackRepository(database)
|
||||||
|
const screenshots = new ScreenshotStorage(
|
||||||
|
config.dataDirectory,
|
||||||
|
config.maxScreenshotBytes
|
||||||
|
)
|
||||||
|
const installationLimiter = new InstallationLimiter(
|
||||||
|
config.publicInstallationLimitPerHour
|
||||||
|
)
|
||||||
|
|
||||||
|
await app.register(cookie)
|
||||||
|
await app.register(helmet, {
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'"],
|
||||||
|
imgSrc: ["'self'", 'data:'],
|
||||||
|
connectSrc: ["'self'"],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
baseUri: ["'none'"],
|
||||||
|
frameAncestors: ["'none'"],
|
||||||
|
formAction: ["'self'"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
crossOriginResourcePolicy: {
|
||||||
|
policy: 'same-origin'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
global: false,
|
||||||
|
hook: 'onRequest',
|
||||||
|
keyGenerator: (request) => request.ip
|
||||||
|
})
|
||||||
|
await app.register(multipart, {
|
||||||
|
limits: {
|
||||||
|
files: 1,
|
||||||
|
fields: 1,
|
||||||
|
fileSize: config.maxScreenshotBytes,
|
||||||
|
fieldSize: maximumPayloadBytes,
|
||||||
|
parts: 2
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
|
if (request.url.startsWith('/api/')) {
|
||||||
|
reply.header('Cache-Control', 'no-store')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.addHook('onClose', async () => {
|
||||||
|
database.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/health', async (_request, reply) => {
|
||||||
|
database.prepare('SELECT 1').get()
|
||||||
|
return reply
|
||||||
|
.header('Cache-Control', 'no-store')
|
||||||
|
.send({
|
||||||
|
status: 'ok',
|
||||||
|
time: new Date().toISOString()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/v1/feedback',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
rateLimit: {
|
||||||
|
max: config.publicRateLimitPerHour,
|
||||||
|
timeWindow: '1 hour'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: async (request, reply) => {
|
||||||
|
const origin = request.headers.origin
|
||||||
|
if (
|
||||||
|
config.publicOrigin &&
|
||||||
|
origin &&
|
||||||
|
origin !== config.publicOrigin
|
||||||
|
) {
|
||||||
|
await reply.code(403).send({ error: 'Origin is not allowed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
let storedId: string | undefined
|
||||||
|
try {
|
||||||
|
const submission = await readPublicSubmission(
|
||||||
|
request,
|
||||||
|
config.maxScreenshotBytes
|
||||||
|
)
|
||||||
|
const payload = publicFeedbackPayloadSchema.parse(submission.payload)
|
||||||
|
const duplicate = repository.getDuplicate(
|
||||||
|
payload.productKey,
|
||||||
|
payload.clientRequestId
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
return reply.send({
|
||||||
|
reference: duplicate.reference,
|
||||||
|
duplicate: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const installationHash = keyedDigest(
|
||||||
|
config.appSecret,
|
||||||
|
`installation:${payload.installationId}`
|
||||||
|
)
|
||||||
|
if (!installationLimiter.consume(installationHash)) {
|
||||||
|
return reply.code(429).send({
|
||||||
|
error: 'Too many feedback submissions from this installation'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const id = randomUUID()
|
||||||
|
storedId = id
|
||||||
|
const screenshot = submission.screenshot
|
||||||
|
? await screenshots.store(id, submission.screenshot)
|
||||||
|
: undefined
|
||||||
|
try {
|
||||||
|
const result = repository.createFeedback({
|
||||||
|
id,
|
||||||
|
payload,
|
||||||
|
sourceIpHash: keyedDigest(
|
||||||
|
config.appSecret,
|
||||||
|
`ip:${request.ip}`
|
||||||
|
),
|
||||||
|
installationHash,
|
||||||
|
...(screenshot ? { screenshot } : {})
|
||||||
|
})
|
||||||
|
return reply.code(201).send({
|
||||||
|
reference: result.reference,
|
||||||
|
duplicate: false
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const racedDuplicate = repository.getDuplicate(
|
||||||
|
payload.productKey,
|
||||||
|
payload.clientRequestId
|
||||||
|
)
|
||||||
|
if (racedDuplicate) {
|
||||||
|
await screenshots.remove(id)
|
||||||
|
storedId = undefined
|
||||||
|
return reply.send({
|
||||||
|
reference: racedDuplicate.reference,
|
||||||
|
duplicate: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (storedId) {
|
||||||
|
await screenshots.remove(storedId)
|
||||||
|
}
|
||||||
|
if (error instanceof ProductNotFoundError) {
|
||||||
|
return reply.code(404).send({ error: error.message })
|
||||||
|
}
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Feedback payload is invalid',
|
||||||
|
issues: error.issues.map((issue) => ({
|
||||||
|
path: issue.path.join('.'),
|
||||||
|
message: issue.message
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error instanceof PublicSubmissionError ||
|
||||||
|
error instanceof ScreenshotValidationError
|
||||||
|
) {
|
||||||
|
return reply.code(400).send({ error: error.message })
|
||||||
|
}
|
||||||
|
const statusCode = clientErrorStatus(error)
|
||||||
|
if (statusCode) {
|
||||||
|
return reply.code(statusCode).send({
|
||||||
|
error:
|
||||||
|
statusCode === 413
|
||||||
|
? 'Feedback submission is too large'
|
||||||
|
: 'Feedback submission was rejected'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
request.log.error(
|
||||||
|
{ err: error, requestId: request.id },
|
||||||
|
'Feedback submission failed'
|
||||||
|
)
|
||||||
|
return reply.code(500).send({ error: 'Internal server error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const requireAdmin = async (
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply
|
||||||
|
): Promise<void> => {
|
||||||
|
const admin = auth.authenticate(request.cookies[sessionCookieName])
|
||||||
|
if (!admin) {
|
||||||
|
await reply.code(401).send({ error: 'Authentication required' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.admin = admin
|
||||||
|
if (
|
||||||
|
isMutation(request.method) &&
|
||||||
|
!safeTokenEquals(
|
||||||
|
request.headers['x-csrf-token']?.toString() ?? '',
|
||||||
|
admin.csrfToken
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
delete request.admin
|
||||||
|
await reply.code(403).send({ error: 'CSRF token is invalid' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/admin/login',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
rateLimit: {
|
||||||
|
max: 5,
|
||||||
|
timeWindow: '15 minutes'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const input = adminLoginSchema.parse(request.body)
|
||||||
|
const session = await auth.login(input.email, input.password)
|
||||||
|
if (!session) {
|
||||||
|
return reply
|
||||||
|
.code(401)
|
||||||
|
.send({ error: 'Email or password is incorrect' })
|
||||||
|
}
|
||||||
|
reply.setCookie(sessionCookieName, session.token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: config.nodeEnv === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/',
|
||||||
|
maxAge: Math.floor(config.sessionTtlMs / 1_000)
|
||||||
|
})
|
||||||
|
return reply.send(
|
||||||
|
adminView({
|
||||||
|
id: session.id,
|
||||||
|
email: session.email,
|
||||||
|
displayName: session.displayName,
|
||||||
|
csrfToken: session.csrfToken
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({ error: 'Login input is invalid' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/session',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request) => adminView(request.admin!)
|
||||||
|
)
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/admin/logout',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request, reply) => {
|
||||||
|
auth.logout(request.cookies[sessionCookieName])
|
||||||
|
reply.clearCookie(sessionCookieName, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: config.nodeEnv === 'production',
|
||||||
|
sameSite: 'strict'
|
||||||
|
})
|
||||||
|
return reply.code(204).send()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/products',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async () => ({ items: repository.listProducts() })
|
||||||
|
)
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/admin/products',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const input = productCreateSchema.parse(request.body)
|
||||||
|
return reply.code(201).send(repository.createProduct(input.key, input.name))
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({ error: 'Product input is invalid' })
|
||||||
|
}
|
||||||
|
if (/UNIQUE constraint failed/u.test(errorMessage(error))) {
|
||||||
|
return reply.code(409).send({ error: 'Product key already exists' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app.patch(
|
||||||
|
'/api/admin/products/:id',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const input = productUpdateSchema.parse(request.body)
|
||||||
|
const id = (request.params as { id: string }).id
|
||||||
|
return repository.updateProduct(id, input)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({ error: 'Product input is invalid' })
|
||||||
|
}
|
||||||
|
if (/Product not found/u.test(errorMessage(error))) {
|
||||||
|
return reply.code(404).send({ error: 'Product not found' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/feedback',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const query = feedbackListQuerySchema.parse(request.query)
|
||||||
|
return repository.listFeedback(query)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({ error: 'Feedback query is invalid' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/feedback/:id',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = (request.params as { id: string }).id
|
||||||
|
const feedback = repository.getFeedback(id)
|
||||||
|
return feedback ?? reply.code(404).send({ error: 'Feedback not found' })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app.patch(
|
||||||
|
'/api/admin/feedback/:id/status',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const input = feedbackStatusUpdateSchema.parse(request.body)
|
||||||
|
const id = (request.params as { id: string }).id
|
||||||
|
const feedback = repository.updateStatus(id, input.status)
|
||||||
|
return (
|
||||||
|
feedback ?? reply.code(404).send({ error: 'Feedback not found' })
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({ error: 'Status input is invalid' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/admin/feedback/:id/notes',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const input = feedbackNoteCreateSchema.parse(request.body)
|
||||||
|
const id = (request.params as { id: string }).id
|
||||||
|
const feedback = repository.addNote(id, request.admin!.id, input.body)
|
||||||
|
return (
|
||||||
|
feedback ?? reply.code(404).send({ error: 'Feedback not found' })
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({ error: 'Note input is invalid' })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/feedback/:id/screenshot',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = (request.params as { id: string }).id
|
||||||
|
const path = repository.getScreenshotPath(id)
|
||||||
|
if (!path) {
|
||||||
|
return reply.code(404).send({ error: 'Screenshot not found' })
|
||||||
|
}
|
||||||
|
const data = await screenshots.read(path)
|
||||||
|
return reply
|
||||||
|
.header('Cache-Control', 'private, no-store')
|
||||||
|
.header('Content-Disposition', 'inline; filename="screenshot.png"')
|
||||||
|
.type('image/png')
|
||||||
|
.send(data)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/admin/summary',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const query = summaryQuerySchema.parse(request.query)
|
||||||
|
return repository.summary(query.productKey)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Summary query is invalid'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
auth.pruneExpiredSessions()
|
||||||
|
|
||||||
|
if (dependencies.adminDirectory) {
|
||||||
|
await app.register(staticFiles, {
|
||||||
|
root: dependencies.adminDirectory,
|
||||||
|
prefix: '/admin/',
|
||||||
|
wildcard: false,
|
||||||
|
setHeaders: (reply, path) => {
|
||||||
|
if (path.endsWith('index.html')) {
|
||||||
|
reply.header('Cache-Control', 'no-cache')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply.header(
|
||||||
|
'Cache-Control',
|
||||||
|
'public, max-age=31536000, immutable'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app.get('/', async (_request, reply) => reply.redirect('/admin/'))
|
||||||
|
app.get('/admin', async (_request, reply) => reply.redirect('/admin/'))
|
||||||
|
app.get('/admin/*', async (request, reply) => {
|
||||||
|
if (request.url.startsWith('/admin/assets/')) {
|
||||||
|
return reply.code(404).type('text/plain').send('Not found')
|
||||||
|
}
|
||||||
|
return reply.sendFile('index.html')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.setNotFoundHandler(async (request, reply) => {
|
||||||
|
if (request.url.startsWith('/api/')) {
|
||||||
|
return reply.code(404).send({ error: 'Endpoint not found' })
|
||||||
|
}
|
||||||
|
return reply.code(404).type('text/plain').send('Not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
app.setErrorHandler(async (error, request, reply) => {
|
||||||
|
const statusCode = clientErrorStatus(error)
|
||||||
|
if (statusCode) {
|
||||||
|
request.log.warn(
|
||||||
|
{ err: error, requestId: request.id, statusCode },
|
||||||
|
'Request rejected'
|
||||||
|
)
|
||||||
|
return reply.code(statusCode).send({
|
||||||
|
error:
|
||||||
|
statusCode === 413
|
||||||
|
? 'Request body is too large'
|
||||||
|
: 'Request was rejected'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
request.log.error(
|
||||||
|
{ err: error, requestId: request.id },
|
||||||
|
'Unhandled request error'
|
||||||
|
)
|
||||||
|
return reply.code(500).send({ error: 'Internal server error' })
|
||||||
|
})
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type { AppDatabase } from './database.js'
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
keyedDigest,
|
||||||
|
randomToken,
|
||||||
|
verifyPassword
|
||||||
|
} from './security.js'
|
||||||
|
|
||||||
|
type AdminRow = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
display_name: string
|
||||||
|
password_hash: string
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionRow = {
|
||||||
|
token_hash: string
|
||||||
|
admin_id: string
|
||||||
|
csrf_token: string
|
||||||
|
expires_at: string
|
||||||
|
email: string
|
||||||
|
display_name: string
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyPasswordHash =
|
||||||
|
'scrypt$16384$8$1$ZvSK6l0GBRPxN-shgTmzUg$mAidkO9abocDjDqu3b_HX-rlkKYQAEGDRX3TKWc48doWD6Y8Ej9_Kp72M1M67kAP46vRIFXANs8kGlsMMdAXXg'
|
||||||
|
|
||||||
|
export type AuthenticatedAdmin = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
displayName: string
|
||||||
|
csrfToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreatedSession = AuthenticatedAdmin & {
|
||||||
|
token: string
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
private lastSessionPruneAt = 0
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly database: AppDatabase,
|
||||||
|
private readonly secret: string,
|
||||||
|
private readonly sessionTtlMs: number
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createAdmin(input: {
|
||||||
|
email: string
|
||||||
|
displayName: string
|
||||||
|
password: string
|
||||||
|
}): Promise<void> {
|
||||||
|
const email = input.email.trim().toLowerCase()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const passwordHash = await hashPassword(input.password)
|
||||||
|
this.database.transaction(() => {
|
||||||
|
this.database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO admins (
|
||||||
|
id, email, display_name, password_hash, active, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||||
|
ON CONFLICT(email) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
password_hash = excluded.password_hash,
|
||||||
|
active = 1,
|
||||||
|
updated_at = excluded.updated_at`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
randomUUID(),
|
||||||
|
email,
|
||||||
|
input.displayName.trim(),
|
||||||
|
passwordHash,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
const admin = this.database
|
||||||
|
.prepare('SELECT id FROM admins WHERE email = ?')
|
||||||
|
.get(email) as { id: string }
|
||||||
|
this.database
|
||||||
|
.prepare('DELETE FROM admin_sessions WHERE admin_id = ?')
|
||||||
|
.run(admin.id)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(email: string, password: string): Promise<CreatedSession | undefined> {
|
||||||
|
this.pruneExpiredSessionsIfDue()
|
||||||
|
const admin = this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, email, display_name, password_hash, active
|
||||||
|
FROM admins WHERE email = ?`
|
||||||
|
)
|
||||||
|
.get(email.trim().toLowerCase()) as AdminRow | undefined
|
||||||
|
if (!admin || admin.active !== 1) {
|
||||||
|
await verifyPassword(password, dummyPasswordHash)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (!(await verifyPassword(password, admin.password_hash))) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const token = randomToken()
|
||||||
|
const csrfToken = randomToken(24)
|
||||||
|
const now = new Date()
|
||||||
|
const expiresAt = new Date(now.getTime() + this.sessionTtlMs).toISOString()
|
||||||
|
this.database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO admin_sessions (
|
||||||
|
token_hash, admin_id, csrf_token, expires_at, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
keyedDigest(this.secret, token),
|
||||||
|
admin.id,
|
||||||
|
csrfToken,
|
||||||
|
expiresAt,
|
||||||
|
now.toISOString()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
id: admin.id,
|
||||||
|
email: admin.email,
|
||||||
|
displayName: admin.display_name,
|
||||||
|
csrfToken,
|
||||||
|
token,
|
||||||
|
expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticate(token: string | undefined): AuthenticatedAdmin | undefined {
|
||||||
|
this.pruneExpiredSessionsIfDue()
|
||||||
|
if (!token) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const tokenHash = keyedDigest(this.secret, token)
|
||||||
|
const session = this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
s.token_hash, s.admin_id, s.csrf_token, s.expires_at,
|
||||||
|
a.email, a.display_name, a.active
|
||||||
|
FROM admin_sessions s
|
||||||
|
JOIN admins a ON a.id = s.admin_id
|
||||||
|
WHERE s.token_hash = ?`
|
||||||
|
)
|
||||||
|
.get(tokenHash) as SessionRow | undefined
|
||||||
|
if (
|
||||||
|
!session ||
|
||||||
|
session.active !== 1 ||
|
||||||
|
Date.parse(session.expires_at) <= Date.now()
|
||||||
|
) {
|
||||||
|
if (session) {
|
||||||
|
this.database
|
||||||
|
.prepare('DELETE FROM admin_sessions WHERE token_hash = ?')
|
||||||
|
.run(tokenHash)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: session.admin_id,
|
||||||
|
email: session.email,
|
||||||
|
displayName: session.display_name,
|
||||||
|
csrfToken: session.csrf_token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(token: string | undefined): void {
|
||||||
|
if (!token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.database
|
||||||
|
.prepare('DELETE FROM admin_sessions WHERE token_hash = ?')
|
||||||
|
.run(keyedDigest(this.secret, token))
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneExpiredSessions(now = new Date().toISOString()): void {
|
||||||
|
this.database
|
||||||
|
.prepare('DELETE FROM admin_sessions WHERE expires_at <= ?')
|
||||||
|
.run(now)
|
||||||
|
this.lastSessionPruneAt = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneExpiredSessionsIfDue(now = Date.now()): void {
|
||||||
|
if (now - this.lastSessionPruneAt >= 60 * 60 * 1_000) {
|
||||||
|
this.pruneExpiredSessions(new Date(now).toISOString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { join } from 'node:path'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { AuthService } from '../auth-service.js'
|
||||||
|
import { loadConfig } from '../config.js'
|
||||||
|
import { openDatabase } from '../database.js'
|
||||||
|
|
||||||
|
const argumentsSchema = z.object({
|
||||||
|
email: z.email().max(254),
|
||||||
|
name: z.string().trim().min(1).max(120)
|
||||||
|
})
|
||||||
|
|
||||||
|
function parseArguments(input: string[]): { email: string; name: string } {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
for (let index = 0; index < input.length; index += 2) {
|
||||||
|
const key = input[index]
|
||||||
|
const value = input[index + 1]
|
||||||
|
if (!key?.startsWith('--') || !value) {
|
||||||
|
throw new Error('Use --email <address> --name <display name>')
|
||||||
|
}
|
||||||
|
values.set(key.slice(2), value)
|
||||||
|
}
|
||||||
|
return argumentsSchema.parse({
|
||||||
|
email: values.get('email'),
|
||||||
|
name: values.get('name')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
process.umask(0o077)
|
||||||
|
|
||||||
|
const config = loadConfig()
|
||||||
|
const password = process.env['PRODUCT_ISSUE_ADMIN_PASSWORD']
|
||||||
|
if (!password || password.length < 12 || password.length > 128) {
|
||||||
|
throw new Error(
|
||||||
|
'Set PRODUCT_ISSUE_ADMIN_PASSWORD to a password between 12 and 128 characters'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const input = parseArguments(process.argv.slice(2))
|
||||||
|
const database = openDatabase(join(config.dataDirectory, 'product-issue.sqlite'))
|
||||||
|
try {
|
||||||
|
const auth = new AuthService(database, config.appSecret, config.sessionTtlMs)
|
||||||
|
await auth.createAdmin({
|
||||||
|
email: input.email,
|
||||||
|
displayName: input.name,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
console.log(`Administrator ${input.email.toLowerCase()} is ready.`)
|
||||||
|
} finally {
|
||||||
|
database.close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const booleanEnvironmentSchema = z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.transform((value) => value === 'true')
|
||||||
|
|
||||||
|
const configSchema = z
|
||||||
|
.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
HOST: z.string().min(1).default('127.0.0.1'),
|
||||||
|
PORT: z.coerce.number().int().min(1).max(65_535).default(3_000),
|
||||||
|
PUBLIC_ORIGIN: z.url().optional(),
|
||||||
|
DATA_DIRECTORY: z.string().min(1).default('./data'),
|
||||||
|
APP_SECRET: z
|
||||||
|
.string()
|
||||||
|
.min(32)
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
value !== 'replace-with-at-least-32-random-characters',
|
||||||
|
'APP_SECRET must be a random production secret'
|
||||||
|
),
|
||||||
|
SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(168).default(12),
|
||||||
|
TRUST_PROXY: booleanEnvironmentSchema.default(false),
|
||||||
|
PUBLIC_RATE_LIMIT_PER_HOUR: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(10_000)
|
||||||
|
.default(20),
|
||||||
|
PUBLIC_INSTALLATION_LIMIT_PER_HOUR: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(1_000)
|
||||||
|
.default(8),
|
||||||
|
MAX_SCREENSHOT_BYTES: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(64 * 1_024)
|
||||||
|
.max(10 * 1_024 * 1_024)
|
||||||
|
.default(5 * 1_024 * 1_024)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
if (value.NODE_ENV === 'production' && !value.PUBLIC_ORIGIN) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['PUBLIC_ORIGIN'],
|
||||||
|
message: 'PUBLIC_ORIGIN is required in production'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!value.PUBLIC_ORIGIN) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = new URL(value.PUBLIC_ORIGIN)
|
||||||
|
if (
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.pathname !== '/' ||
|
||||||
|
url.search ||
|
||||||
|
url.hash
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['PUBLIC_ORIGIN'],
|
||||||
|
message: 'PUBLIC_ORIGIN must contain only an origin'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (value.NODE_ENV === 'production' && url.protocol !== 'https:') {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['PUBLIC_ORIGIN'],
|
||||||
|
message: 'PUBLIC_ORIGIN must use HTTPS in production'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AppConfig = {
|
||||||
|
nodeEnv: 'development' | 'test' | 'production'
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
publicOrigin?: string
|
||||||
|
dataDirectory: string
|
||||||
|
appSecret: string
|
||||||
|
sessionTtlMs: number
|
||||||
|
trustProxy: boolean
|
||||||
|
publicRateLimitPerHour: number
|
||||||
|
publicInstallationLimitPerHour: number
|
||||||
|
maxScreenshotBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(
|
||||||
|
environment: NodeJS.ProcessEnv = process.env
|
||||||
|
): AppConfig {
|
||||||
|
const parsed = configSchema.parse({
|
||||||
|
NODE_ENV: environment['NODE_ENV'],
|
||||||
|
HOST: environment['HOST'],
|
||||||
|
PORT: environment['PORT'],
|
||||||
|
PUBLIC_ORIGIN: environment['PUBLIC_ORIGIN'],
|
||||||
|
DATA_DIRECTORY: environment['DATA_DIRECTORY'],
|
||||||
|
APP_SECRET: environment['APP_SECRET'],
|
||||||
|
SESSION_TTL_HOURS: environment['SESSION_TTL_HOURS'],
|
||||||
|
TRUST_PROXY: environment['TRUST_PROXY'],
|
||||||
|
PUBLIC_RATE_LIMIT_PER_HOUR:
|
||||||
|
environment['PUBLIC_RATE_LIMIT_PER_HOUR'],
|
||||||
|
PUBLIC_INSTALLATION_LIMIT_PER_HOUR:
|
||||||
|
environment['PUBLIC_INSTALLATION_LIMIT_PER_HOUR'],
|
||||||
|
MAX_SCREENSHOT_BYTES: environment['MAX_SCREENSHOT_BYTES']
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
nodeEnv: parsed.NODE_ENV,
|
||||||
|
host: parsed.HOST,
|
||||||
|
port: parsed.PORT,
|
||||||
|
...(parsed.PUBLIC_ORIGIN
|
||||||
|
? { publicOrigin: new URL(parsed.PUBLIC_ORIGIN).origin }
|
||||||
|
: {}),
|
||||||
|
dataDirectory: resolve(parsed.DATA_DIRECTORY),
|
||||||
|
appSecret: parsed.APP_SECRET,
|
||||||
|
sessionTtlMs: parsed.SESSION_TTL_HOURS * 60 * 60 * 1_000,
|
||||||
|
trustProxy: parsed.TRUST_PROXY,
|
||||||
|
publicRateLimitPerHour: parsed.PUBLIC_RATE_LIMIT_PER_HOUR,
|
||||||
|
publicInstallationLimitPerHour:
|
||||||
|
parsed.PUBLIC_INSTALLATION_LIMIT_PER_HOUR,
|
||||||
|
maxScreenshotBytes: parsed.MAX_SCREENSHOT_BYTES
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { dirname } from 'node:path'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
|
||||||
|
const migration = `
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
admin_id TEXT NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||||
|
csrf_token TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS admin_sessions_expires_idx
|
||||||
|
ON admin_sessions(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
public_key TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
reference TEXT NOT NULL UNIQUE,
|
||||||
|
reference_number INTEGER NOT NULL UNIQUE,
|
||||||
|
product_id TEXT NOT NULL REFERENCES products(id),
|
||||||
|
category TEXT NOT NULL CHECK (
|
||||||
|
category IN ('bug', 'feature', 'experience', 'other')
|
||||||
|
),
|
||||||
|
status TEXT NOT NULL DEFAULT 'new' CHECK (
|
||||||
|
status IN ('new', 'triaged', 'in-progress', 'resolved', 'closed')
|
||||||
|
),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
contact_email TEXT,
|
||||||
|
environment_json TEXT,
|
||||||
|
screenshot_file TEXT,
|
||||||
|
screenshot_mime TEXT,
|
||||||
|
screenshot_size INTEGER,
|
||||||
|
screenshot_width INTEGER,
|
||||||
|
screenshot_height INTEGER,
|
||||||
|
client_request_id TEXT NOT NULL,
|
||||||
|
installation_hash TEXT NOT NULL,
|
||||||
|
source_ip_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(product_id, client_request_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS feedback_product_created_idx
|
||||||
|
ON feedback(product_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS feedback_status_created_idx
|
||||||
|
ON feedback(status, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback_notes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
feedback_id TEXT NOT NULL REFERENCES feedback(id) ON DELETE CASCADE,
|
||||||
|
admin_id TEXT NOT NULL REFERENCES admins(id),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS feedback_notes_feedback_idx
|
||||||
|
ON feedback_notes(feedback_id, created_at ASC);
|
||||||
|
`
|
||||||
|
|
||||||
|
export type AppDatabase = Database.Database
|
||||||
|
|
||||||
|
export function openDatabase(filePath: string): AppDatabase {
|
||||||
|
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 })
|
||||||
|
const database = new Database(filePath)
|
||||||
|
database.pragma('foreign_keys = ON')
|
||||||
|
database.pragma('journal_mode = WAL')
|
||||||
|
database.pragma('synchronous = NORMAL')
|
||||||
|
database.pragma('busy_timeout = 5000')
|
||||||
|
database.exec(migration)
|
||||||
|
seedGoodBuddy(database)
|
||||||
|
return database
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedGoodBuddy(database: AppDatabase): void {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR IGNORE INTO products (
|
||||||
|
id, public_key, name, enabled, created_at, updated_at
|
||||||
|
) VALUES (?, 'goodbuddy', 'GoodBuddy', 1, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(randomUUID(), now, now)
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import type { AuthenticatedAdmin } from './auth-service.js'
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
admin?: AuthenticatedAdmin
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type {
|
||||||
|
FeedbackCategory,
|
||||||
|
FeedbackDetail,
|
||||||
|
FeedbackEnvironment,
|
||||||
|
FeedbackListItem,
|
||||||
|
FeedbackStatus,
|
||||||
|
ProductView,
|
||||||
|
PublicFeedbackPayload
|
||||||
|
} from '../shared/contracts.js'
|
||||||
|
import type { AppDatabase } from './database.js'
|
||||||
|
import type { StoredScreenshot } from './screenshot-storage.js'
|
||||||
|
|
||||||
|
type ProductRow = {
|
||||||
|
id: string
|
||||||
|
public_key: string
|
||||||
|
name: string
|
||||||
|
enabled: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedbackRow = {
|
||||||
|
id: string
|
||||||
|
reference: string
|
||||||
|
product_id: string
|
||||||
|
product_key: string
|
||||||
|
product_name: string
|
||||||
|
category: FeedbackCategory
|
||||||
|
status: FeedbackStatus
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
contact_email: string | null
|
||||||
|
environment_json: string | null
|
||||||
|
screenshot_file: string | null
|
||||||
|
screenshot_mime: 'image/png' | null
|
||||||
|
screenshot_size: number | null
|
||||||
|
screenshot_width: number | null
|
||||||
|
screenshot_height: number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedbackListRow = Pick<
|
||||||
|
FeedbackRow,
|
||||||
|
| 'id'
|
||||||
|
| 'reference'
|
||||||
|
| 'product_id'
|
||||||
|
| 'product_key'
|
||||||
|
| 'product_name'
|
||||||
|
| 'category'
|
||||||
|
| 'status'
|
||||||
|
| 'title'
|
||||||
|
| 'screenshot_file'
|
||||||
|
| 'created_at'
|
||||||
|
| 'updated_at'
|
||||||
|
>
|
||||||
|
|
||||||
|
type NoteRow = {
|
||||||
|
id: string
|
||||||
|
body: string
|
||||||
|
author_name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProductNotFoundError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Product is unavailable')
|
||||||
|
this.name = 'ProductNotFoundError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function productView(row: ProductRow): ProductView {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
key: row.public_key,
|
||||||
|
name: row.name,
|
||||||
|
enabled: row.enabled === 1,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedbackListItem(row: FeedbackListRow): FeedbackListItem {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
product: {
|
||||||
|
id: row.product_id,
|
||||||
|
key: row.product_key,
|
||||||
|
name: row.product_name
|
||||||
|
},
|
||||||
|
category: row.category,
|
||||||
|
status: row.status,
|
||||||
|
title: row.title,
|
||||||
|
hasScreenshot: row.screenshot_file !== null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchPattern(value: string): string {
|
||||||
|
return `%${value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function referencePrefix(productKey: string): string {
|
||||||
|
return productKey.replaceAll(/[^a-z0-9]/giu, '').toUpperCase().slice(0, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FeedbackRepository {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
listProducts(): ProductView[] {
|
||||||
|
const rows = this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, public_key, name, enabled, created_at, updated_at
|
||||||
|
FROM products ORDER BY name COLLATE NOCASE`
|
||||||
|
)
|
||||||
|
.all() as ProductRow[]
|
||||||
|
return rows.map(productView)
|
||||||
|
}
|
||||||
|
|
||||||
|
createProduct(key: string, name: string): ProductView {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const id = randomUUID()
|
||||||
|
this.database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO products (
|
||||||
|
id, public_key, name, enabled, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, 1, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(id, key, name, now, now)
|
||||||
|
return this.getProduct(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProduct(
|
||||||
|
id: string,
|
||||||
|
update: {
|
||||||
|
name?: string | undefined
|
||||||
|
enabled?: boolean | undefined
|
||||||
|
}
|
||||||
|
): ProductView {
|
||||||
|
const existing = this.getProduct(id)
|
||||||
|
this.database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE products SET name = ?, enabled = ?, updated_at = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
update.name ?? existing.name,
|
||||||
|
(update.enabled ?? existing.enabled) ? 1 : 0,
|
||||||
|
new Date().toISOString(),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
return this.getProduct(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getDuplicate(
|
||||||
|
productKey: string,
|
||||||
|
clientRequestId: string
|
||||||
|
): { id: string; reference: string } | undefined {
|
||||||
|
return this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT f.id, f.reference
|
||||||
|
FROM feedback f
|
||||||
|
JOIN products p ON p.id = f.product_id
|
||||||
|
WHERE p.public_key = ? AND f.client_request_id = ?`
|
||||||
|
)
|
||||||
|
.get(productKey, clientRequestId) as
|
||||||
|
| { id: string; reference: string }
|
||||||
|
| undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
createFeedback(input: {
|
||||||
|
id: string
|
||||||
|
payload: PublicFeedbackPayload
|
||||||
|
sourceIpHash: string
|
||||||
|
installationHash: string
|
||||||
|
screenshot?: StoredScreenshot
|
||||||
|
}): { reference: string } {
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const product = this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, public_key, name, enabled, created_at, updated_at
|
||||||
|
FROM products WHERE public_key = ? AND enabled = 1`
|
||||||
|
)
|
||||||
|
.get(input.payload.productKey) as ProductRow | undefined
|
||||||
|
if (!product) {
|
||||||
|
throw new ProductNotFoundError()
|
||||||
|
}
|
||||||
|
const next = this.database
|
||||||
|
.prepare(
|
||||||
|
'SELECT COALESCE(MAX(reference_number), 0) + 1 AS value FROM feedback'
|
||||||
|
)
|
||||||
|
.get() as { value: number }
|
||||||
|
const reference = `${referencePrefix(product.public_key)}-${String(next.value).padStart(6, '0')}`
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
this.database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO feedback (
|
||||||
|
id, reference, reference_number, product_id, category, status,
|
||||||
|
title, description, contact_email, environment_json,
|
||||||
|
screenshot_file, screenshot_mime, screenshot_size,
|
||||||
|
screenshot_width, screenshot_height, client_request_id,
|
||||||
|
installation_hash, source_ip_hash, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, 'new', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
input.id,
|
||||||
|
reference,
|
||||||
|
next.value,
|
||||||
|
product.id,
|
||||||
|
input.payload.category,
|
||||||
|
input.payload.title,
|
||||||
|
input.payload.description,
|
||||||
|
input.payload.contactEmail ?? null,
|
||||||
|
input.payload.environment
|
||||||
|
? JSON.stringify(input.payload.environment)
|
||||||
|
: null,
|
||||||
|
input.screenshot?.relativePath ?? null,
|
||||||
|
input.screenshot?.mimeType ?? null,
|
||||||
|
input.screenshot?.size ?? null,
|
||||||
|
input.screenshot?.width ?? null,
|
||||||
|
input.screenshot?.height ?? null,
|
||||||
|
input.payload.clientRequestId,
|
||||||
|
input.installationHash,
|
||||||
|
input.sourceIpHash,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
return { reference }
|
||||||
|
})
|
||||||
|
return transaction()
|
||||||
|
}
|
||||||
|
|
||||||
|
listFeedback(query: {
|
||||||
|
productKey?: string | undefined
|
||||||
|
status?: FeedbackStatus | undefined
|
||||||
|
category?: FeedbackCategory | undefined
|
||||||
|
search?: string | undefined
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
}): { items: FeedbackListItem[]; total: number; page: number; limit: number } {
|
||||||
|
const clauses: string[] = []
|
||||||
|
const parameters: unknown[] = []
|
||||||
|
if (query.productKey) {
|
||||||
|
clauses.push('p.public_key = ?')
|
||||||
|
parameters.push(query.productKey)
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
clauses.push('f.status = ?')
|
||||||
|
parameters.push(query.status)
|
||||||
|
}
|
||||||
|
if (query.category) {
|
||||||
|
clauses.push('f.category = ?')
|
||||||
|
parameters.push(query.category)
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
clauses.push(
|
||||||
|
`(f.reference LIKE ? ESCAPE '\\' OR f.title LIKE ? ESCAPE '\\' OR f.description LIKE ? ESCAPE '\\')`
|
||||||
|
)
|
||||||
|
const pattern = searchPattern(query.search)
|
||||||
|
parameters.push(pattern, pattern, pattern)
|
||||||
|
}
|
||||||
|
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
|
||||||
|
const total = (
|
||||||
|
this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS value
|
||||||
|
FROM feedback f
|
||||||
|
JOIN products p ON p.id = f.product_id
|
||||||
|
${where}`
|
||||||
|
)
|
||||||
|
.get(...parameters) as { value: number }
|
||||||
|
).value
|
||||||
|
const rows = this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
f.id, f.reference, f.product_id, p.public_key AS product_key,
|
||||||
|
p.name AS product_name, f.category, f.status, f.title,
|
||||||
|
f.screenshot_file, f.created_at, f.updated_at
|
||||||
|
FROM feedback f
|
||||||
|
JOIN products p ON p.id = f.product_id
|
||||||
|
${where}
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.all(
|
||||||
|
...parameters,
|
||||||
|
query.limit,
|
||||||
|
(query.page - 1) * query.limit
|
||||||
|
) as FeedbackListRow[]
|
||||||
|
return {
|
||||||
|
items: rows.map(feedbackListItem),
|
||||||
|
total,
|
||||||
|
page: query.page,
|
||||||
|
limit: query.limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFeedback(id: string): FeedbackDetail | undefined {
|
||||||
|
const row = this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
f.id, f.reference, f.product_id, p.public_key AS product_key,
|
||||||
|
p.name AS product_name, f.category, f.status, f.title,
|
||||||
|
f.description, f.contact_email, f.environment_json,
|
||||||
|
f.screenshot_file, f.screenshot_mime, f.screenshot_size,
|
||||||
|
f.screenshot_width, f.screenshot_height, f.created_at, f.updated_at
|
||||||
|
FROM feedback f
|
||||||
|
JOIN products p ON p.id = f.product_id
|
||||||
|
WHERE f.id = ?`
|
||||||
|
)
|
||||||
|
.get(id) as FeedbackRow | undefined
|
||||||
|
if (!row) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const notes = this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT n.id, n.body, a.display_name AS author_name, n.created_at
|
||||||
|
FROM feedback_notes n
|
||||||
|
JOIN admins a ON a.id = n.admin_id
|
||||||
|
WHERE n.feedback_id = ?
|
||||||
|
ORDER BY n.created_at ASC`
|
||||||
|
)
|
||||||
|
.all(id) as NoteRow[]
|
||||||
|
return {
|
||||||
|
...feedbackListItem(row),
|
||||||
|
description: row.description,
|
||||||
|
...(row.contact_email ? { contactEmail: row.contact_email } : {}),
|
||||||
|
...(row.environment_json
|
||||||
|
? {
|
||||||
|
environment: JSON.parse(
|
||||||
|
row.environment_json
|
||||||
|
) as FeedbackEnvironment
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(row.screenshot_file &&
|
||||||
|
row.screenshot_mime &&
|
||||||
|
row.screenshot_size !== null &&
|
||||||
|
row.screenshot_width !== null &&
|
||||||
|
row.screenshot_height !== null
|
||||||
|
? {
|
||||||
|
screenshot: {
|
||||||
|
mimeType: row.screenshot_mime,
|
||||||
|
size: row.screenshot_size,
|
||||||
|
width: row.screenshot_width,
|
||||||
|
height: row.screenshot_height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
notes: notes.map((note) => ({
|
||||||
|
id: note.id,
|
||||||
|
body: note.body,
|
||||||
|
authorName: note.author_name,
|
||||||
|
createdAt: note.created_at
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getScreenshotPath(id: string): string | undefined {
|
||||||
|
const row = this.database
|
||||||
|
.prepare('SELECT screenshot_file FROM feedback WHERE id = ?')
|
||||||
|
.get(id) as { screenshot_file: string | null } | undefined
|
||||||
|
return row?.screenshot_file ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(id: string, status: FeedbackStatus): FeedbackDetail | undefined {
|
||||||
|
const result = this.database
|
||||||
|
.prepare('UPDATE feedback SET status = ?, updated_at = ? WHERE id = ?')
|
||||||
|
.run(status, new Date().toISOString(), id)
|
||||||
|
return result.changes === 0 ? undefined : this.getFeedback(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
addNote(
|
||||||
|
feedbackId: string,
|
||||||
|
adminId: string,
|
||||||
|
body: string
|
||||||
|
): FeedbackDetail | undefined {
|
||||||
|
const feedbackExists = this.database
|
||||||
|
.prepare('SELECT 1 AS value FROM feedback WHERE id = ?')
|
||||||
|
.get(feedbackId) as { value: number } | undefined
|
||||||
|
if (!feedbackExists) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
this.database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO feedback_notes (
|
||||||
|
id, feedback_id, admin_id, body, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(randomUUID(), feedbackId, adminId, body, now)
|
||||||
|
this.database
|
||||||
|
.prepare('UPDATE feedback SET updated_at = ? WHERE id = ?')
|
||||||
|
.run(now, feedbackId)
|
||||||
|
return this.getFeedback(feedbackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary(productKey?: string): {
|
||||||
|
total: number
|
||||||
|
byStatus: Record<FeedbackStatus, number>
|
||||||
|
} {
|
||||||
|
const parameters: unknown[] = []
|
||||||
|
const where = productKey ? 'WHERE p.public_key = ?' : ''
|
||||||
|
if (productKey) {
|
||||||
|
parameters.push(productKey)
|
||||||
|
}
|
||||||
|
const rows = this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT f.status, COUNT(*) AS value
|
||||||
|
FROM feedback f
|
||||||
|
JOIN products p ON p.id = f.product_id
|
||||||
|
${where}
|
||||||
|
GROUP BY f.status`
|
||||||
|
)
|
||||||
|
.all(...parameters) as { status: FeedbackStatus; value: number }[]
|
||||||
|
const byStatus: Record<FeedbackStatus, number> = {
|
||||||
|
new: 0,
|
||||||
|
triaged: 0,
|
||||||
|
'in-progress': 0,
|
||||||
|
resolved: 0,
|
||||||
|
closed: 0
|
||||||
|
}
|
||||||
|
let total = 0
|
||||||
|
for (const row of rows) {
|
||||||
|
byStatus[row.status] = row.value
|
||||||
|
total += row.value
|
||||||
|
}
|
||||||
|
return { total, byStatus }
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProduct(id: string): ProductView {
|
||||||
|
const row = this.database
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, public_key, name, enabled, created_at, updated_at
|
||||||
|
FROM products WHERE id = ?`
|
||||||
|
)
|
||||||
|
.get(id) as ProductRow | undefined
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Product not found')
|
||||||
|
}
|
||||||
|
return productView(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
type Counter = {
|
||||||
|
count: number
|
||||||
|
resetAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const maximumCounters = 10_000
|
||||||
|
|
||||||
|
export class InstallationLimiter {
|
||||||
|
private readonly counters = new Map<string, Counter>()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly maximum: number,
|
||||||
|
private readonly windowMs = 60 * 60 * 1_000
|
||||||
|
) {}
|
||||||
|
|
||||||
|
consume(key: string, now = Date.now()): boolean {
|
||||||
|
if (!this.counters.has(key) && this.counters.size >= maximumCounters) {
|
||||||
|
this.prune(now)
|
||||||
|
if (this.counters.size >= maximumCounters) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existing = this.counters.get(key)
|
||||||
|
if (!existing || existing.resetAt <= now) {
|
||||||
|
this.counters.set(key, {
|
||||||
|
count: 1,
|
||||||
|
resetAt: now + this.windowMs
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (existing.count >= this.maximum) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
existing.count += 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private prune(now: number): void {
|
||||||
|
for (const [key, counter] of this.counters) {
|
||||||
|
if (counter.resetAt <= now) {
|
||||||
|
this.counters.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { mkdir } from 'node:fs/promises'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { buildApp } from './app.js'
|
||||||
|
import { loadConfig } from './config.js'
|
||||||
|
import { openDatabase } from './database.js'
|
||||||
|
|
||||||
|
process.umask(0o077)
|
||||||
|
|
||||||
|
const config = loadConfig()
|
||||||
|
await mkdir(config.dataDirectory, { recursive: true, mode: 0o700 })
|
||||||
|
const database = openDatabase(join(config.dataDirectory, 'product-issue.sqlite'))
|
||||||
|
const adminDirectory = resolve('dist/admin')
|
||||||
|
const serveAdmin =
|
||||||
|
config.nodeEnv === 'production' ||
|
||||||
|
existsSync(join(adminDirectory, 'index.html'))
|
||||||
|
const app = await buildApp({
|
||||||
|
config,
|
||||||
|
database,
|
||||||
|
...(serveAdmin ? { adminDirectory } : {})
|
||||||
|
})
|
||||||
|
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
app.log.info({ signal }, 'Shutting down')
|
||||||
|
await app.close()
|
||||||
|
process.exitCode = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once('SIGINT', () => {
|
||||||
|
void shutdown('SIGINT')
|
||||||
|
})
|
||||||
|
process.once('SIGTERM', () => {
|
||||||
|
void shutdown('SIGTERM')
|
||||||
|
})
|
||||||
|
|
||||||
|
await app.listen({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port
|
||||||
|
})
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
|
||||||
|
const maximumDimension = 8_192
|
||||||
|
const maximumPixels = 32_000_000
|
||||||
|
const supportedInputFormats = new Set(['png', 'jpeg', 'webp'])
|
||||||
|
const pngSignature = Buffer.from([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||||
|
])
|
||||||
|
|
||||||
|
function hasSupportedSignature(input: Buffer): boolean {
|
||||||
|
return (
|
||||||
|
(input.length >= 8 &&
|
||||||
|
input.subarray(0, 8).equals(pngSignature)) ||
|
||||||
|
(input.length >= 3 &&
|
||||||
|
input[0] === 0xff &&
|
||||||
|
input[1] === 0xd8 &&
|
||||||
|
input[2] === 0xff) ||
|
||||||
|
(input.length >= 12 &&
|
||||||
|
input.toString('ascii', 0, 4) === 'RIFF' &&
|
||||||
|
input.toString('ascii', 8, 12) === 'WEBP')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScreenshotValidationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ScreenshotValidationError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoredScreenshot = {
|
||||||
|
relativePath: string
|
||||||
|
mimeType: 'image/png'
|
||||||
|
size: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScreenshotStorage {
|
||||||
|
private readonly root: string
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
dataDirectory: string,
|
||||||
|
private readonly maximumStoredBytes: number
|
||||||
|
) {
|
||||||
|
this.root = resolve(dataDirectory, 'attachments')
|
||||||
|
}
|
||||||
|
|
||||||
|
async store(
|
||||||
|
feedbackId: string,
|
||||||
|
input: Buffer
|
||||||
|
): Promise<StoredScreenshot> {
|
||||||
|
const output = await this.normalize(input)
|
||||||
|
if (output.data.byteLength > this.maximumStoredBytes) {
|
||||||
|
throw new ScreenshotValidationError(
|
||||||
|
'Screenshot is too large after normalization'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const directory = join(this.root, feedbackId)
|
||||||
|
const relativePath = `${feedbackId}/screenshot.png`
|
||||||
|
const target = join(directory, 'screenshot.png')
|
||||||
|
const temporary = `${target}.${process.pid}.tmp`
|
||||||
|
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||||
|
try {
|
||||||
|
await writeFile(temporary, output.data, {
|
||||||
|
mode: 0o600,
|
||||||
|
flag: 'wx'
|
||||||
|
})
|
||||||
|
await rename(temporary, target)
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporary, { force: true })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
relativePath,
|
||||||
|
mimeType: 'image/png',
|
||||||
|
size: output.data.byteLength,
|
||||||
|
width: output.info.width,
|
||||||
|
height: output.info.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(relativePath: string): Promise<Buffer> {
|
||||||
|
const target = resolve(this.root, relativePath.replaceAll('\\', '/'))
|
||||||
|
if (
|
||||||
|
target !== this.root &&
|
||||||
|
!target.startsWith(`${this.root}${process.platform === 'win32' ? '\\' : '/'}`)
|
||||||
|
) {
|
||||||
|
throw new Error('Screenshot path is outside storage')
|
||||||
|
}
|
||||||
|
return readFile(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(feedbackId: string): Promise<void> {
|
||||||
|
await rm(join(this.root, feedbackId), {
|
||||||
|
recursive: true,
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async normalize(input: Buffer) {
|
||||||
|
if (!hasSupportedSignature(input)) {
|
||||||
|
throw new ScreenshotValidationError(
|
||||||
|
'Screenshot format is not supported'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const image = sharp(input, {
|
||||||
|
failOn: 'warning',
|
||||||
|
limitInputPixels: maximumPixels
|
||||||
|
})
|
||||||
|
const metadata = await image.metadata()
|
||||||
|
if (
|
||||||
|
!metadata.format ||
|
||||||
|
!supportedInputFormats.has(metadata.format) ||
|
||||||
|
(metadata.pages ?? 1) !== 1 ||
|
||||||
|
!metadata.width ||
|
||||||
|
!metadata.height ||
|
||||||
|
metadata.width > maximumDimension ||
|
||||||
|
metadata.height > maximumDimension
|
||||||
|
) {
|
||||||
|
throw new ScreenshotValidationError(
|
||||||
|
'Screenshot dimensions are not supported'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return await image
|
||||||
|
.rotate()
|
||||||
|
.png({
|
||||||
|
compressionLevel: 9,
|
||||||
|
adaptiveFiltering: true
|
||||||
|
})
|
||||||
|
.toBuffer({ resolveWithObject: true })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ScreenshotValidationError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw new ScreenshotValidationError(
|
||||||
|
'Screenshot could not be decoded'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
createHmac,
|
||||||
|
randomBytes,
|
||||||
|
scrypt as nodeScrypt,
|
||||||
|
timingSafeEqual,
|
||||||
|
type ScryptOptions
|
||||||
|
} from 'node:crypto'
|
||||||
|
|
||||||
|
const passwordKeyLength = 64
|
||||||
|
const scryptCost = 16_384
|
||||||
|
const scryptBlockSize = 8
|
||||||
|
const scryptParallelization = 1
|
||||||
|
const scryptMaximumMemory = 64 * 1_024 * 1_024
|
||||||
|
|
||||||
|
function deriveKey(
|
||||||
|
password: string,
|
||||||
|
salt: Buffer,
|
||||||
|
options: ScryptOptions
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
nodeScrypt(
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
passwordKeyLength,
|
||||||
|
options,
|
||||||
|
(error, derived) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(derived)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
const salt = randomBytes(16)
|
||||||
|
const derived = await deriveKey(password, salt, {
|
||||||
|
N: scryptCost,
|
||||||
|
r: scryptBlockSize,
|
||||||
|
p: scryptParallelization,
|
||||||
|
maxmem: scryptMaximumMemory
|
||||||
|
})
|
||||||
|
return [
|
||||||
|
'scrypt',
|
||||||
|
scryptCost,
|
||||||
|
scryptBlockSize,
|
||||||
|
scryptParallelization,
|
||||||
|
salt.toString('base64url'),
|
||||||
|
derived.toString('base64url')
|
||||||
|
].join('$')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
encoded: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const [algorithm, cost, blockSize, parallelization, saltText, hashText] =
|
||||||
|
encoded.split('$')
|
||||||
|
if (
|
||||||
|
algorithm !== 'scrypt' ||
|
||||||
|
Number(cost) !== scryptCost ||
|
||||||
|
Number(blockSize) !== scryptBlockSize ||
|
||||||
|
Number(parallelization) !== scryptParallelization ||
|
||||||
|
!saltText ||
|
||||||
|
!hashText
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const expected = Buffer.from(hashText, 'base64url')
|
||||||
|
if (expected.length !== passwordKeyLength) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const actual = await deriveKey(
|
||||||
|
password,
|
||||||
|
Buffer.from(saltText, 'base64url'),
|
||||||
|
{
|
||||||
|
N: Number(cost),
|
||||||
|
r: Number(blockSize),
|
||||||
|
p: Number(parallelization),
|
||||||
|
maxmem: scryptMaximumMemory
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return timingSafeEqual(expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomToken(bytes = 32): string {
|
||||||
|
return randomBytes(bytes).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyedDigest(secret: string, value: string): string {
|
||||||
|
return createHmac('sha256', secret).update(value).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeTokenEquals(left: string, right: string): boolean {
|
||||||
|
const leftBuffer = Buffer.from(left)
|
||||||
|
const rightBuffer = Buffer.from(right)
|
||||||
|
return (
|
||||||
|
leftBuffer.length === rightBuffer.length &&
|
||||||
|
timingSafeEqual(leftBuffer, rightBuffer)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const feedbackCategories = [
|
||||||
|
'bug',
|
||||||
|
'feature',
|
||||||
|
'experience',
|
||||||
|
'other'
|
||||||
|
] as const
|
||||||
|
export const feedbackStatuses = [
|
||||||
|
'new',
|
||||||
|
'triaged',
|
||||||
|
'in-progress',
|
||||||
|
'resolved',
|
||||||
|
'closed'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const productKeySchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2)
|
||||||
|
.max(64)
|
||||||
|
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u)
|
||||||
|
|
||||||
|
export const feedbackEnvironmentSchema = z
|
||||||
|
.object({
|
||||||
|
appVersion: z.string().trim().min(1).max(64),
|
||||||
|
platform: z.enum(['windows', 'macos', 'linux', 'unknown']),
|
||||||
|
architecture: z.enum(['x64', 'arm64', 'unknown']),
|
||||||
|
locale: z.string().trim().min(2).max(32).optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const publicFeedbackPayloadSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(1),
|
||||||
|
productKey: productKeySchema,
|
||||||
|
category: z.enum(feedbackCategories),
|
||||||
|
title: z.string().trim().min(1).max(120),
|
||||||
|
description: z.string().trim().min(10).max(5_000),
|
||||||
|
contactEmail: z.email().max(254).optional(),
|
||||||
|
environment: feedbackEnvironmentSchema.optional(),
|
||||||
|
installationId: z.uuid(),
|
||||||
|
clientRequestId: z.uuid(),
|
||||||
|
website: z.literal('').optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const publicFeedbackResponseSchema = z
|
||||||
|
.object({
|
||||||
|
reference: z.string().regex(/^[A-Z0-9]{2,12}-\d{6,}$/u),
|
||||||
|
duplicate: z.boolean()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const adminLoginSchema = z
|
||||||
|
.object({
|
||||||
|
email: z.email().max(254),
|
||||||
|
password: z.string().min(12).max(128)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const productCreateSchema = z
|
||||||
|
.object({
|
||||||
|
key: productKeySchema,
|
||||||
|
name: z.string().trim().min(1).max(120)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const productUpdateSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(120).optional(),
|
||||||
|
enabled: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((value) => Object.keys(value).length > 0)
|
||||||
|
|
||||||
|
export const feedbackListQuerySchema = z
|
||||||
|
.object({
|
||||||
|
productKey: productKeySchema.optional(),
|
||||||
|
status: z.enum(feedbackStatuses).optional(),
|
||||||
|
category: z.enum(feedbackCategories).optional(),
|
||||||
|
search: z.string().trim().max(120).optional(),
|
||||||
|
page: z.coerce.number().int().min(1).max(10_000).default(1),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(30)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const summaryQuerySchema = z
|
||||||
|
.object({
|
||||||
|
productKey: productKeySchema.optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const feedbackStatusUpdateSchema = z
|
||||||
|
.object({
|
||||||
|
status: z.enum(feedbackStatuses)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const feedbackNoteCreateSchema = z
|
||||||
|
.object({
|
||||||
|
body: z.string().trim().min(1).max(5_000)
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type FeedbackCategory = (typeof feedbackCategories)[number]
|
||||||
|
export type FeedbackStatus = (typeof feedbackStatuses)[number]
|
||||||
|
export type PublicFeedbackPayload = z.infer<
|
||||||
|
typeof publicFeedbackPayloadSchema
|
||||||
|
>
|
||||||
|
export type FeedbackEnvironment = z.infer<
|
||||||
|
typeof feedbackEnvironmentSchema
|
||||||
|
>
|
||||||
|
|
||||||
|
export type AdminSessionView = {
|
||||||
|
admin: {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
|
csrfToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductView = {
|
||||||
|
id: string
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedbackListItem = {
|
||||||
|
id: string
|
||||||
|
reference: string
|
||||||
|
product: Pick<ProductView, 'id' | 'key' | 'name'>
|
||||||
|
category: FeedbackCategory
|
||||||
|
status: FeedbackStatus
|
||||||
|
title: string
|
||||||
|
hasScreenshot: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedbackNoteView = {
|
||||||
|
id: string
|
||||||
|
body: string
|
||||||
|
authorName: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedbackDetail = FeedbackListItem & {
|
||||||
|
description: string
|
||||||
|
contactEmail?: string
|
||||||
|
environment?: FeedbackEnvironment
|
||||||
|
screenshot?: {
|
||||||
|
mimeType: 'image/png'
|
||||||
|
size: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
notes: FeedbackNoteView[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { buildApp } from '../src/server/app.js'
|
||||||
|
import { AuthService } from '../src/server/auth-service.js'
|
||||||
|
import type { AppConfig } from '../src/server/config.js'
|
||||||
|
import { openDatabase } from '../src/server/database.js'
|
||||||
|
|
||||||
|
const applications: Awaited<ReturnType<typeof buildApp>>[] = []
|
||||||
|
const directories: string[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
while (applications.length > 0) {
|
||||||
|
await applications.pop()?.close()
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
directories.splice(0).map((directory) =>
|
||||||
|
rm(directory, { recursive: true, force: true })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function harness(
|
||||||
|
options: {
|
||||||
|
adminBundle?: boolean
|
||||||
|
nodeEnv?: AppConfig['nodeEnv']
|
||||||
|
publicOrigin?: string
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'product-issue-api-'))
|
||||||
|
directories.push(directory)
|
||||||
|
const config: AppConfig = {
|
||||||
|
nodeEnv: options.nodeEnv ?? 'test',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 3_000,
|
||||||
|
dataDirectory: directory,
|
||||||
|
appSecret: 'test-secret-that-is-at-least-thirty-two-characters',
|
||||||
|
sessionTtlMs: 60 * 60 * 1_000,
|
||||||
|
trustProxy: false,
|
||||||
|
publicRateLimitPerHour: 100,
|
||||||
|
publicInstallationLimitPerHour: 100,
|
||||||
|
maxScreenshotBytes: 5 * 1_024 * 1_024,
|
||||||
|
...(options.publicOrigin
|
||||||
|
? { publicOrigin: options.publicOrigin }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
const database = openDatabase(join(directory, 'database.sqlite'))
|
||||||
|
const auth = new AuthService(database, config.appSecret, config.sessionTtlMs)
|
||||||
|
await auth.createAdmin({
|
||||||
|
email: 'admin@example.com',
|
||||||
|
displayName: 'Administrator',
|
||||||
|
password: 'correct horse battery staple'
|
||||||
|
})
|
||||||
|
let adminDirectory: string | undefined
|
||||||
|
if (options.adminBundle) {
|
||||||
|
adminDirectory = join(directory, 'admin')
|
||||||
|
await mkdir(join(adminDirectory, 'assets'), { recursive: true })
|
||||||
|
await writeFile(
|
||||||
|
join(adminDirectory, 'index.html'),
|
||||||
|
'<!doctype html><title>Feedback admin</title><div id="root"></div>'
|
||||||
|
)
|
||||||
|
await writeFile(
|
||||||
|
join(adminDirectory, 'assets', 'app.js'),
|
||||||
|
'document.title = "Loaded feedback admin"'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const app = await buildApp({
|
||||||
|
config,
|
||||||
|
database,
|
||||||
|
...(adminDirectory ? { adminDirectory } : {})
|
||||||
|
})
|
||||||
|
applications.push(app)
|
||||||
|
return { app }
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedbackPayload(clientRequestId: string) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
productKey: 'goodbuddy',
|
||||||
|
category: 'bug',
|
||||||
|
title: '魔法笔记页签不可见',
|
||||||
|
description: '平台功能中的魔法笔记页签被布局压缩,无法看到或点击。',
|
||||||
|
contactEmail: 'user@example.com',
|
||||||
|
environment: {
|
||||||
|
appVersion: '0.11.0',
|
||||||
|
platform: 'windows',
|
||||||
|
architecture: 'x64',
|
||||||
|
locale: 'zh-CN'
|
||||||
|
},
|
||||||
|
installationId: '00000000-0000-4000-8000-000000000101',
|
||||||
|
clientRequestId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function multipartBody(input: {
|
||||||
|
payload: unknown
|
||||||
|
screenshot: Buffer
|
||||||
|
}): { payload: Buffer; contentType: string } {
|
||||||
|
const boundary = '----product-issue-test-boundary'
|
||||||
|
const start = Buffer.from(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
'Content-Disposition: form-data; name="payload"\r\n\r\n' +
|
||||||
|
`${JSON.stringify(input.payload)}\r\n` +
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
'Content-Disposition: form-data; name="screenshot"; filename="capture.jpg"\r\n' +
|
||||||
|
'Content-Type: image/jpeg\r\n\r\n'
|
||||||
|
)
|
||||||
|
const end = Buffer.from(`\r\n--${boundary}--\r\n`)
|
||||||
|
return {
|
||||||
|
payload: Buffer.concat([start, input.screenshot, end]),
|
||||||
|
contentType: `multipart/form-data; boundary=${boundary}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(
|
||||||
|
app: Awaited<ReturnType<typeof buildApp>>
|
||||||
|
): Promise<{ cookie: string; csrfToken: string }> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/admin/login',
|
||||||
|
payload: {
|
||||||
|
email: 'admin@example.com',
|
||||||
|
password: 'correct horse battery staple'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(response.statusCode).toBe(200)
|
||||||
|
const setCookie = response.headers['set-cookie']
|
||||||
|
const rawCookie = Array.isArray(setCookie) ? setCookie[0] : setCookie
|
||||||
|
const cookie = rawCookie?.split(';')[0]
|
||||||
|
if (!cookie) {
|
||||||
|
throw new Error('Missing session cookie')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cookie,
|
||||||
|
csrfToken: response.json<{ csrfToken: string }>().csrfToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('feedback platform API', () => {
|
||||||
|
it('uses a hardened production cookie and enforces browser origins', async () => {
|
||||||
|
const { app } = await harness({
|
||||||
|
nodeEnv: 'production',
|
||||||
|
publicOrigin: 'https://feedback.example.com'
|
||||||
|
})
|
||||||
|
const loginResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/admin/login',
|
||||||
|
payload: {
|
||||||
|
email: 'admin@example.com',
|
||||||
|
password: 'correct horse battery staple'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(loginResponse.statusCode).toBe(200)
|
||||||
|
const setCookie = loginResponse.headers['set-cookie']
|
||||||
|
const rawCookie = Array.isArray(setCookie) ? setCookie[0] : setCookie
|
||||||
|
expect(rawCookie).toMatch(
|
||||||
|
/^__Host-product_issue_session=.*;\s*Max-Age=.*;\s*Path=\/;\s*HttpOnly;\s*Secure;\s*SameSite=Strict$/u
|
||||||
|
)
|
||||||
|
|
||||||
|
const rejected = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/feedback',
|
||||||
|
headers: { origin: 'https://untrusted.example' },
|
||||||
|
payload: feedbackPayload(
|
||||||
|
'00000000-0000-4000-8000-000000000105'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
expect(rejected.statusCode).toBe(403)
|
||||||
|
const accepted = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/feedback',
|
||||||
|
headers: { origin: 'https://feedback.example.com' },
|
||||||
|
payload: feedbackPayload(
|
||||||
|
'00000000-0000-4000-8000-000000000106'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
expect(accepted.statusCode).toBe(201)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serves the admin SPA only under the admin path', async () => {
|
||||||
|
const { app } = await harness({ adminBundle: true })
|
||||||
|
const root = await app.inject({ method: 'GET', url: '/' })
|
||||||
|
expect(root.statusCode).toBe(302)
|
||||||
|
expect(root.headers.location).toBe('/admin/')
|
||||||
|
const admin = await app.inject({ method: 'GET', url: '/admin/' })
|
||||||
|
expect(admin.statusCode).toBe(200)
|
||||||
|
expect(admin.body).toContain('Feedback admin')
|
||||||
|
expect(admin.headers['cache-control']).toBe('no-cache')
|
||||||
|
const nested = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/feedback/example'
|
||||||
|
})
|
||||||
|
expect(nested.statusCode).toBe(200)
|
||||||
|
expect(nested.body).toContain('Feedback admin')
|
||||||
|
const asset = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/assets/app.js'
|
||||||
|
})
|
||||||
|
expect(asset.statusCode).toBe(200)
|
||||||
|
expect(asset.body).toContain('Loaded feedback admin')
|
||||||
|
expect(asset.headers['cache-control']).toBe(
|
||||||
|
'public, max-age=31536000, immutable'
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin/assets/missing.js'
|
||||||
|
})
|
||||||
|
).statusCode
|
||||||
|
).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts idempotent public submissions and exposes them only to admins', async () => {
|
||||||
|
const { app } = await harness()
|
||||||
|
const clientRequestId = '00000000-0000-4000-8000-000000000102'
|
||||||
|
const first = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/feedback',
|
||||||
|
payload: feedbackPayload(clientRequestId)
|
||||||
|
})
|
||||||
|
expect(first.statusCode).toBe(201)
|
||||||
|
expect(first.headers['cache-control']).toBe('no-store')
|
||||||
|
expect(first.json()).toEqual({
|
||||||
|
reference: 'GOODBUDDY-000001',
|
||||||
|
duplicate: false
|
||||||
|
})
|
||||||
|
const duplicate = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/feedback',
|
||||||
|
payload: feedbackPayload(clientRequestId)
|
||||||
|
})
|
||||||
|
expect(duplicate.statusCode).toBe(200)
|
||||||
|
expect(duplicate.json()).toEqual({
|
||||||
|
reference: 'GOODBUDDY-000001',
|
||||||
|
duplicate: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/feedback'
|
||||||
|
})
|
||||||
|
).statusCode
|
||||||
|
).toBe(401)
|
||||||
|
|
||||||
|
const session = await login(app)
|
||||||
|
const list = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/feedback?productKey=goodbuddy&search=魔法',
|
||||||
|
headers: { cookie: session.cookie }
|
||||||
|
})
|
||||||
|
expect(list.statusCode).toBe(200)
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/summary?productKey=Invalid',
|
||||||
|
headers: { cookie: session.cookie }
|
||||||
|
})
|
||||||
|
).statusCode
|
||||||
|
).toBe(400)
|
||||||
|
const item = list.json<{ items: { id: string; reference: string }[] }>()
|
||||||
|
.items[0]
|
||||||
|
expect(item).toMatchObject({ reference: 'GOODBUDDY-000001' })
|
||||||
|
if (!item) {
|
||||||
|
throw new Error('Missing feedback item')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectedStatus = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/feedback/${item.id}/status`,
|
||||||
|
headers: { cookie: session.cookie },
|
||||||
|
payload: { status: 'in-progress' }
|
||||||
|
})
|
||||||
|
expect(rejectedStatus.statusCode).toBe(403)
|
||||||
|
|
||||||
|
const changedStatus = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/feedback/${item.id}/status`,
|
||||||
|
headers: {
|
||||||
|
cookie: session.cookie,
|
||||||
|
'x-csrf-token': session.csrfToken
|
||||||
|
},
|
||||||
|
payload: { status: 'in-progress' }
|
||||||
|
})
|
||||||
|
expect(changedStatus.statusCode).toBe(200)
|
||||||
|
expect(changedStatus.json()).toMatchObject({ status: 'in-progress' })
|
||||||
|
|
||||||
|
const note = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/admin/feedback/${item.id}/notes`,
|
||||||
|
headers: {
|
||||||
|
cookie: session.cookie,
|
||||||
|
'x-csrf-token': session.csrfToken
|
||||||
|
},
|
||||||
|
payload: { body: '已在开发版本中复现,安排修复。' }
|
||||||
|
})
|
||||||
|
expect(note.statusCode).toBe(200)
|
||||||
|
expect(note.json()).toMatchObject({
|
||||||
|
notes: [
|
||||||
|
{
|
||||||
|
body: '已在开发版本中复现,安排修复。',
|
||||||
|
authorName: 'Administrator'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const rejectedLogout = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/admin/logout',
|
||||||
|
headers: { cookie: session.cookie }
|
||||||
|
})
|
||||||
|
expect(rejectedLogout.statusCode).toBe(403)
|
||||||
|
const logout = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/admin/logout',
|
||||||
|
headers: {
|
||||||
|
cookie: session.cookie,
|
||||||
|
'x-csrf-token': session.csrfToken
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(logout.statusCode).toBe(204)
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/session',
|
||||||
|
headers: { cookie: session.cookie }
|
||||||
|
})
|
||||||
|
).statusCode
|
||||||
|
).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes screenshots to private PNG assets', async () => {
|
||||||
|
const { app } = await harness()
|
||||||
|
const jpeg = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 48,
|
||||||
|
height: 32,
|
||||||
|
channels: 3,
|
||||||
|
background: '#4f8fe8'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.jpeg()
|
||||||
|
.toBuffer()
|
||||||
|
const body = multipartBody({
|
||||||
|
payload: {
|
||||||
|
...feedbackPayload(
|
||||||
|
'00000000-0000-4000-8000-000000000103'
|
||||||
|
),
|
||||||
|
description: '问'.repeat(5_000)
|
||||||
|
},
|
||||||
|
screenshot: jpeg
|
||||||
|
})
|
||||||
|
const submitted = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/feedback',
|
||||||
|
headers: { 'content-type': body.contentType },
|
||||||
|
payload: body.payload
|
||||||
|
})
|
||||||
|
expect(submitted.statusCode).toBe(201)
|
||||||
|
|
||||||
|
const session = await login(app)
|
||||||
|
const list = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/feedback',
|
||||||
|
headers: { cookie: session.cookie }
|
||||||
|
})
|
||||||
|
const item = list.json<{ items: { id: string; hasScreenshot: boolean }[] }>()
|
||||||
|
.items[0]
|
||||||
|
expect(item?.hasScreenshot).toBe(true)
|
||||||
|
if (!item) {
|
||||||
|
throw new Error('Missing screenshot feedback')
|
||||||
|
}
|
||||||
|
const screenshot = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/admin/feedback/${item.id}/screenshot`,
|
||||||
|
headers: { cookie: session.cookie }
|
||||||
|
})
|
||||||
|
expect(screenshot.statusCode).toBe(200)
|
||||||
|
expect(screenshot.headers['content-type']).toMatch(/^image\/png/u)
|
||||||
|
expect(screenshot.headers['cache-control']).toBe('private, no-store')
|
||||||
|
const metadata = await sharp(screenshot.rawPayload).metadata()
|
||||||
|
expect(metadata).toMatchObject({
|
||||||
|
format: 'png',
|
||||||
|
width: 48,
|
||||||
|
height: 32
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('manages products and rejects submissions after a product is disabled', async () => {
|
||||||
|
const { app } = await harness()
|
||||||
|
const session = await login(app)
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/admin/products',
|
||||||
|
headers: {
|
||||||
|
cookie: session.cookie,
|
||||||
|
'x-csrf-token': session.csrfToken
|
||||||
|
},
|
||||||
|
payload: { key: 'mesa-writer', name: 'MesaWriter' }
|
||||||
|
})
|
||||||
|
expect(created.statusCode).toBe(201)
|
||||||
|
const product = created.json<{ id: string }>()
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/products/${product.id}`,
|
||||||
|
headers: {
|
||||||
|
cookie: session.cookie,
|
||||||
|
'x-csrf-token': session.csrfToken
|
||||||
|
},
|
||||||
|
payload: { enabled: false }
|
||||||
|
})
|
||||||
|
).statusCode
|
||||||
|
).toBe(200)
|
||||||
|
const submission = feedbackPayload(
|
||||||
|
'00000000-0000-4000-8000-000000000104'
|
||||||
|
)
|
||||||
|
const rejected = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/feedback',
|
||||||
|
payload: {
|
||||||
|
...submission,
|
||||||
|
productKey: 'mesa-writer'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(rejected.statusCode).toBe(404)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { AuthService } from '../src/server/auth-service.js'
|
||||||
|
import { openDatabase, type AppDatabase } from '../src/server/database.js'
|
||||||
|
|
||||||
|
const directories: string[] = []
|
||||||
|
const databases: AppDatabase[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
while (databases.length > 0) {
|
||||||
|
databases.pop()?.close()
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
directories.splice(0).map((directory) =>
|
||||||
|
rm(directory, { recursive: true, force: true })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
it('invalidates existing sessions when an administrator is reset', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'product-issue-auth-'))
|
||||||
|
directories.push(directory)
|
||||||
|
const database = openDatabase(join(directory, 'database.sqlite'))
|
||||||
|
databases.push(database)
|
||||||
|
const auth = new AuthService(
|
||||||
|
database,
|
||||||
|
'test-secret-that-is-at-least-thirty-two-characters',
|
||||||
|
60 * 60 * 1_000
|
||||||
|
)
|
||||||
|
await auth.createAdmin({
|
||||||
|
email: 'admin@example.com',
|
||||||
|
displayName: 'First name',
|
||||||
|
password: 'correct horse battery staple'
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
auth.login('unknown@example.com', 'an incorrect password')
|
||||||
|
).resolves.toBeUndefined()
|
||||||
|
const session = await auth.login(
|
||||||
|
'admin@example.com',
|
||||||
|
'correct horse battery staple'
|
||||||
|
)
|
||||||
|
expect(session).toBeDefined()
|
||||||
|
|
||||||
|
await auth.createAdmin({
|
||||||
|
email: 'admin@example.com',
|
||||||
|
displayName: 'Updated name',
|
||||||
|
password: 'a different secure password'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(auth.authenticate(session?.token)).toBeUndefined()
|
||||||
|
await expect(
|
||||||
|
auth.login('admin@example.com', 'correct horse battery staple')
|
||||||
|
).resolves.toBeUndefined()
|
||||||
|
await expect(
|
||||||
|
auth.login('admin@example.com', 'a different secure password')
|
||||||
|
).resolves.toMatchObject({ displayName: 'Updated name' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { loadConfig } from '../src/server/config.js'
|
||||||
|
|
||||||
|
const secret = 'test-secret-that-is-at-least-thirty-two-characters'
|
||||||
|
|
||||||
|
describe('loadConfig', () => {
|
||||||
|
it('normalizes a valid production origin', () => {
|
||||||
|
expect(
|
||||||
|
loadConfig({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
APP_SECRET: secret,
|
||||||
|
PUBLIC_ORIGIN: 'https://feedback.example.com/'
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
nodeEnv: 'production',
|
||||||
|
publicOrigin: 'https://feedback.example.com'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires a production HTTPS origin without paths or credentials', () => {
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
APP_SECRET: secret
|
||||||
|
})
|
||||||
|
).toThrow()
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
APP_SECRET: secret,
|
||||||
|
PUBLIC_ORIGIN: 'http://feedback.example.com'
|
||||||
|
})
|
||||||
|
).toThrow()
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
APP_SECRET: secret,
|
||||||
|
PUBLIC_ORIGIN: 'https://feedback.example.com/admin'
|
||||||
|
})
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects the example placeholder secret', () => {
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({
|
||||||
|
APP_SECRET: 'replace-with-at-least-32-random-characters'
|
||||||
|
})
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
productKeySchema,
|
||||||
|
publicFeedbackPayloadSchema,
|
||||||
|
publicFeedbackResponseSchema
|
||||||
|
} from '../src/shared/contracts.js'
|
||||||
|
|
||||||
|
const validFeedback = {
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
productKey: 'goodbuddy',
|
||||||
|
category: 'bug' as const,
|
||||||
|
title: '平台功能页签不可见',
|
||||||
|
description: '打开设置中心的平台功能后,无法看到魔法笔记页签。',
|
||||||
|
contactEmail: 'user@example.com',
|
||||||
|
environment: {
|
||||||
|
appVersion: '0.11.0',
|
||||||
|
platform: 'windows' as const,
|
||||||
|
architecture: 'x64' as const,
|
||||||
|
locale: 'zh-CN'
|
||||||
|
},
|
||||||
|
installationId: '00000000-0000-4000-8000-000000000001',
|
||||||
|
clientRequestId: '00000000-0000-4000-8000-000000000002'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('feedback contracts', () => {
|
||||||
|
it('accepts a bounded versioned GoodBuddy submission', () => {
|
||||||
|
expect(publicFeedbackPayloadSchema.parse(validFeedback)).toEqual(
|
||||||
|
validFeedback
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown fields and oversized or malformed input', () => {
|
||||||
|
expect(
|
||||||
|
publicFeedbackPayloadSchema.safeParse({
|
||||||
|
...validFeedback,
|
||||||
|
secret: 'must not be accepted'
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
publicFeedbackPayloadSchema.safeParse({
|
||||||
|
...validFeedback,
|
||||||
|
description: 'too short'
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
publicFeedbackPayloadSchema.safeParse({
|
||||||
|
...validFeedback,
|
||||||
|
contactEmail: 'not-an-email'
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses stable lowercase product keys', () => {
|
||||||
|
expect(productKeySchema.parse('mesa-writer')).toBe('mesa-writer')
|
||||||
|
for (const invalid of ['GoodBuddy', '-goodbuddy', 'goodbuddy-', 'a', 'x_y']) {
|
||||||
|
expect(productKeySchema.safeParse(invalid).success).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts references after the sequence exceeds six digits', () => {
|
||||||
|
expect(
|
||||||
|
publicFeedbackResponseSchema.parse({
|
||||||
|
reference: 'GOODBUDDY-1000000',
|
||||||
|
duplicate: false
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
reference: 'GOODBUDDY-1000000',
|
||||||
|
duplicate: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { InstallationLimiter } from '../src/server/installation-limiter.js'
|
||||||
|
|
||||||
|
describe('InstallationLimiter', () => {
|
||||||
|
it('enforces a bounded window independently per installation', () => {
|
||||||
|
const limiter = new InstallationLimiter(2, 1_000)
|
||||||
|
expect(limiter.consume('first', 0)).toBe(true)
|
||||||
|
expect(limiter.consume('first', 1)).toBe(true)
|
||||||
|
expect(limiter.consume('first', 2)).toBe(false)
|
||||||
|
expect(limiter.consume('second', 2)).toBe(true)
|
||||||
|
expect(limiter.consume('first', 1_000)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails closed when too many active installation counters exist', () => {
|
||||||
|
const limiter = new InstallationLimiter(2, 1_000)
|
||||||
|
for (let index = 0; index < 10_000; index += 1) {
|
||||||
|
expect(limiter.consume(`installation-${index}`, 0)).toBe(true)
|
||||||
|
}
|
||||||
|
expect(limiter.consume('overflow', 1)).toBe(false)
|
||||||
|
expect(limiter.consume('installation-0', 1)).toBe(true)
|
||||||
|
expect(limiter.consume('overflow', 1_000)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { openDatabase, type AppDatabase } from '../src/server/database.js'
|
||||||
|
import {
|
||||||
|
FeedbackRepository,
|
||||||
|
ProductNotFoundError
|
||||||
|
} from '../src/server/feedback-repository.js'
|
||||||
|
|
||||||
|
const directories: string[] = []
|
||||||
|
const databases: AppDatabase[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
while (databases.length > 0) {
|
||||||
|
databases.pop()?.close()
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
directories.splice(0).map((directory) =>
|
||||||
|
rm(directory, { recursive: true, force: true })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function repository(): Promise<FeedbackRepository> {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'product-issue-repository-'))
|
||||||
|
directories.push(directory)
|
||||||
|
const database = openDatabase(join(directory, 'database.sqlite'))
|
||||||
|
databases.push(database)
|
||||||
|
return new FeedbackRepository(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FeedbackRepository', () => {
|
||||||
|
it('seeds GoodBuddy and stores searchable feedback with stable references', async () => {
|
||||||
|
const store = await repository()
|
||||||
|
expect(store.listProducts()).toMatchObject([
|
||||||
|
{ key: 'goodbuddy', name: 'GoodBuddy', enabled: true }
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = store.createFeedback({
|
||||||
|
id: '00000000-0000-4000-8000-000000000010',
|
||||||
|
payload: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
productKey: 'goodbuddy',
|
||||||
|
category: 'feature',
|
||||||
|
title: '增加应用内反馈',
|
||||||
|
description: '希望可以在关于页面直接提交产品建议和截图。',
|
||||||
|
installationId: '00000000-0000-4000-8000-000000000011',
|
||||||
|
clientRequestId: '00000000-0000-4000-8000-000000000012'
|
||||||
|
},
|
||||||
|
installationHash: 'installation',
|
||||||
|
sourceIpHash: 'ip'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.reference).toBe('GOODBUDDY-000001')
|
||||||
|
expect(
|
||||||
|
store.getDuplicate(
|
||||||
|
'goodbuddy',
|
||||||
|
'00000000-0000-4000-8000-000000000012'
|
||||||
|
)
|
||||||
|
).toMatchObject({ reference: result.reference })
|
||||||
|
expect(
|
||||||
|
store.listFeedback({
|
||||||
|
search: '应用内',
|
||||||
|
page: 1,
|
||||||
|
limit: 30
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
total: 1,
|
||||||
|
items: [{ reference: result.reference, category: 'feature' }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails closed for unknown or disabled products', async () => {
|
||||||
|
const store = await repository()
|
||||||
|
const product = store.createProduct('another-product', 'Another Product')
|
||||||
|
store.updateProduct(product.id, { enabled: false })
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
store.createFeedback({
|
||||||
|
id: '00000000-0000-4000-8000-000000000020',
|
||||||
|
payload: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
productKey: 'another-product',
|
||||||
|
category: 'other',
|
||||||
|
title: 'A valid feedback title',
|
||||||
|
description: 'A sufficiently descriptive feedback body.',
|
||||||
|
installationId: '00000000-0000-4000-8000-000000000021',
|
||||||
|
clientRequestId: '00000000-0000-4000-8000-000000000022'
|
||||||
|
},
|
||||||
|
installationHash: 'installation',
|
||||||
|
sourceIpHash: 'ip'
|
||||||
|
})
|
||||||
|
).toThrow(ProductNotFoundError)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
ScreenshotStorage,
|
||||||
|
ScreenshotValidationError
|
||||||
|
} from '../src/server/screenshot-storage.js'
|
||||||
|
|
||||||
|
const directories: string[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
directories.splice(0).map((directory) =>
|
||||||
|
rm(directory, { recursive: true, force: true })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ScreenshotStorage', () => {
|
||||||
|
it('rejects decodable formats outside PNG, JPEG, and WebP', async () => {
|
||||||
|
const directory = await mkdtemp(
|
||||||
|
join(tmpdir(), 'product-issue-screenshot-')
|
||||||
|
)
|
||||||
|
directories.push(directory)
|
||||||
|
const storage = new ScreenshotStorage(directory, 64 * 1_024)
|
||||||
|
await expect(
|
||||||
|
storage.store(
|
||||||
|
'00000000-0000-4000-8000-000000000000',
|
||||||
|
Buffer.from(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).rejects.toBeInstanceOf(ScreenshotValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects images whose normalized private asset exceeds the limit', async () => {
|
||||||
|
const directory = await mkdtemp(
|
||||||
|
join(tmpdir(), 'product-issue-screenshot-')
|
||||||
|
)
|
||||||
|
directories.push(directory)
|
||||||
|
const jpeg = await sharp(randomBytes(512 * 512 * 3), {
|
||||||
|
raw: {
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
channels: 3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 1 })
|
||||||
|
.toBuffer()
|
||||||
|
expect(jpeg.byteLength).toBeLessThan(64 * 1_024)
|
||||||
|
|
||||||
|
const storage = new ScreenshotStorage(directory, 64 * 1_024)
|
||||||
|
await expect(
|
||||||
|
storage.store(
|
||||||
|
'00000000-0000-4000-8000-000000000001',
|
||||||
|
jpeg
|
||||||
|
)
|
||||||
|
).rejects.toBeInstanceOf(ScreenshotValidationError)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
keyedDigest,
|
||||||
|
randomToken,
|
||||||
|
safeTokenEquals,
|
||||||
|
verifyPassword
|
||||||
|
} from '../src/server/security.js'
|
||||||
|
|
||||||
|
describe('security helpers', () => {
|
||||||
|
it('round-trips scrypt password hashes without storing plaintext', async () => {
|
||||||
|
const password = 'correct horse battery staple'
|
||||||
|
const encoded = await hashPassword(password)
|
||||||
|
|
||||||
|
expect(encoded).toMatch(/^scrypt\$/u)
|
||||||
|
expect(encoded).not.toContain(password)
|
||||||
|
await expect(verifyPassword(password, encoded)).resolves.toBe(true)
|
||||||
|
await expect(verifyPassword('wrong password', encoded)).resolves.toBe(false)
|
||||||
|
await expect(verifyPassword(password, 'invalid')).resolves.toBe(false)
|
||||||
|
await expect(
|
||||||
|
verifyPassword(password, encoded.replace('$16384$', '$1048576$'))
|
||||||
|
).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates random tokens and stable keyed digests', () => {
|
||||||
|
const first = randomToken()
|
||||||
|
const second = randomToken()
|
||||||
|
expect(first).not.toBe(second)
|
||||||
|
expect(keyedDigest('secret', 'value')).toBe(
|
||||||
|
keyedDigest('secret', 'value')
|
||||||
|
)
|
||||||
|
expect(keyedDigest('secret', 'value')).not.toBe(
|
||||||
|
keyedDigest('other', 'value')
|
||||||
|
)
|
||||||
|
expect(safeTokenEquals('same', 'same')).toBe(true)
|
||||||
|
expect(safeTokenEquals('same', 'different')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/admin/**/*.ts", "src/admin/**/*.tsx", "src/shared/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"useUnknownInCatchVariables": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist/server",
|
||||||
|
"types": ["node"],
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": ["src/server/**/*.ts", "src/shared/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"types": ["node", "vitest/globals"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"tests/**/*.ts",
|
||||||
|
"src/server/fastify.d.ts",
|
||||||
|
"vite.config.ts",
|
||||||
|
"vitest.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'src/admin',
|
||||||
|
base: '/admin/',
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: '../../dist/admin',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: false
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:3000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json-summary'],
|
||||||
|
include: ['src/server/**/*.ts', 'src/shared/**/*.ts']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user