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