Initial commit

This commit is contained in:
lofyer
2026-08-24 17:27:41 +08:00
commit 5ff80caf7e
45 changed files with 11485 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
.env
.env.*
!.env.example
node_modules
dist
coverage
data
*.log
*.tsbuildinfo
+6
View File
@@ -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
View File
@@ -0,0 +1,11 @@
node_modules/
dist/
coverage/
.env
.env.*
!.env.example
data/
*.log
*.tsbuildinfo
.DS_Store
Thumbs.db
+11
View File
@@ -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
View File
@@ -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"]
+133
View File
@@ -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 域名确定后实现。
+73
View File
@@ -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
View File
@@ -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. 仅在用户明确选择后添加截图。
+63
View File
@@ -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)。
+152
View File
@@ -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。
## 数据保留
当前版本不自动删除反馈或附件。部署方应根据实际支持流程定义保留期限,
并将数据库行和对应附件视为同一条记录处理。任何直接数据库维护都必须先
停止写入、完成备份,并在隔离副本上验证。
+75
View File
@@ -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. 不会自动附加对话、日志、文件、项目路径、模型配置或凭据。
+48
View File
@@ -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
}
]
}
}
)
+5470
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -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()}`)
}
}
+14
View File
@@ -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>
+15
View File
@@ -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>
)
+1018
View File
File diff suppressed because it is too large Load Diff
+649
View File
@@ -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
}
+188
View File
@@ -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())
}
}
}
+49
View File
@@ -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()
}
+128
View File
@@ -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
}
}
+102
View File
@@ -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)
}
+7
View File
@@ -0,0 +1,7 @@
import type { AuthenticatedAdmin } from './auth-service.js'
declare module 'fastify' {
interface FastifyRequest {
admin?: AuthenticatedAdmin
}
}
+447
View File
@@ -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)
}
}
+45
View File
@@ -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)
}
}
}
}
+39
View File
@@ -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
})
+144
View File
@@ -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'
)
}
}
}
+103
View File
@@ -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)
)
}
+163
View File
@@ -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[]
}
+435
View File
@@ -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)
})
})
+61
View File
@@ -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' })
})
})
+50
View File
@@ -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()
})
})
+71
View File
@@ -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
})
})
})
+23
View File
@@ -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)
})
})
+96
View File
@@ -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)
})
})
+63
View File
@@ -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)
})
})
+38
View File
@@ -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)
})
})
+12
View File
@@ -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"]
}
+20
View File
@@ -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
}
}
+13
View File
@@ -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"]
}
+15
View File
@@ -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"
]
}
+19
View File
@@ -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'
}
}
})
+13
View File
@@ -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']
}
}
})