修复固件哈希兼容性并完善部署配置

This commit is contained in:
mid
2026-08-08 16:18:41 +08:00
parent 290da46567
commit 34503f2638
13 changed files with 271 additions and 25 deletions
+1 -1
View File
@@ -1 +1 @@
WEB_TOOLS_PORT=8080 WEB_TOOLS_PORT=9000
+15
View File
@@ -2,6 +2,21 @@
Before changing this project, read `README.zh-CN.md` in full. Its architecture constraints, extension procedure, validation checklist, and release steps are authoritative. Before changing this project, read `README.zh-CN.md` in full. Its architecture constraints, extension procedure, validation checklist, and release steps are authoritative.
## Project identity
- This is a self-hosted Vue 3 + TypeScript browser-local tools application with a zero-dependency Node.js static/configuration server.
- `package.json` is the authoritative application version. The default production port is `9000`.
- `shared/tool-catalog.json` is the authoritative tool catalog; `shared/default-navigation.json` defines first-run ordering.
- `README.zh-CN.md` is the complete product, architecture, deployment, extension, and AI handoff document. `README.md` is only the universal entry point.
- Windows Server uses `web-tools.ps1`; containers use `Dockerfile` and `docker-compose.yml`.
## First actions in a new session
1. Read `README.zh-CN.md`, then inspect `git status --short --branch` and preserve all existing worktree changes.
2. Read the closest existing tool, its `src/lib/` logic, and related tests before adding or changing behavior.
3. Keep default production port references synchronized in `server/index.mjs`, `web-tools.ps1`, `Dockerfile`, `docker-compose.yml`, `.env.example`, and deployment documentation.
4. Run `npm run check`; for deployment changes also validate PowerShell syntax and Docker Compose configuration when those tools are available.
Key rules: Key rules:
- Preserve the anonymous, no-login, no-database design. Tool calculations are isolated in each browser tab; only navigation order is shared globally. - Preserve the anonymous, no-login, no-database design. Tool calculations are isolated in each browser tab; only navigation order is shared globally.
+9 -1
View File
@@ -14,10 +14,18 @@
### Fixed ### Fixed
- Intel HEX 解析器增加全部非空记录上限、显式 uint32 地址空间检查和无符号扩展地址运算,拒绝越过 `0xFFFFFFFF` 的数据。 - Intel HEX 解析器增加全部非空记录上限、显式 uint32 地址空间检查和无符号扩展地址运算,拒绝越过 `0xFFFFFFFF` 的数据。
- 固件 Hash 在普通 HTTP 局域网访问时增加本地 SHA-256/SHA-512 回退,不再依赖 `SubtleCrypto` 必须可用。
- 生产服务在监听端口前检查 `dist/index.html`,缺少构建产物时立即给出构建命令,不再启动后按请求重复返回 500。
### Documentation
- 补充 `dist/index.html` 缺失、Node 进程未重启、浏览器页面未刷新和前台 PowerShell 退出等启动故障排查说明。
- 将 Node、Docker Compose、容器健康检查、Windows 脚本和示例配置的生产默认端口统一为 `9000`。
- 增加通用 `README.md` 入口并强化 `AGENTS.md` 新会话接手流程,便于其他 AI 在不依赖历史对话的情况下继续维护。
### Verification ### Verification
- 68 条 Vitest 测试通过,新增标准 SHA 向量、Block 头大小端、地址绑定、最高 uint32 地址、地址溢出和控制记录边界覆盖。 - 74 条 Vitest 测试通过,新增标准 SHA 向量、Block 头大小端、地址绑定、最高 uint32 地址、地址溢出、控制记录边界、无 Web Crypto 回退、生产构建预检和部署配置一致性覆盖。
- Vue/TypeScript 类型检查和生产构建通过。 - Vue/TypeScript 类型检查和生产构建通过。
## 0.0.5 - 2026-08-07 ## 0.0.5 - 2026-08-07
+3 -3
View File
@@ -14,7 +14,7 @@ FROM node:22-alpine AS runtime
ENV NODE_ENV=production \ ENV NODE_ENV=production \
HOST=0.0.0.0 \ HOST=0.0.0.0 \
PORT=8080 \ PORT=9000 \
DATA_DIR=/data DATA_DIR=/data
WORKDIR /app WORKDIR /app
@@ -24,9 +24,9 @@ COPY --chown=node:node server ./server
COPY --chown=node:node shared ./shared COPY --chown=node:node shared ./shared
USER node USER node
EXPOSE 8080 EXPOSE 9000
VOLUME ["/data"] VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:8080/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" CMD node -e "fetch('http://127.0.0.1:9000/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
CMD ["node", "server/index.mjs"] CMD ["node", "server/index.mjs"]
+29
View File
@@ -0,0 +1,29 @@
# Web Tools
Self-hosted browser-local utilities built with Vue 3, TypeScript, and a small Node.js server. Tool inputs and results stay in each browser tab; the server only persists the shared navigation order.
Default production address: `http://localhost:9000`
## Documentation
- Complete Chinese documentation: [README.zh-CN.md](README.zh-CN.md)
- AI/coding-agent instructions: [AGENTS.md](AGENTS.md)
- Release history: [CHANGELOG.md](CHANGELOG.md)
Any AI or coding agent starting in a new conversation must read `AGENTS.md` and `README.zh-CN.md` before editing the project, then inspect the current Git worktree instead of assuming it is clean.
## Quick start
Docker Compose:
```bash
docker compose up -d --build
```
Windows Server after the first build:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 start
```
See `README.zh-CN.md` for first installation, updates, Windows startup, security boundaries, tool extension rules, tests, and packaging.
+43 -9
View File
@@ -86,12 +86,18 @@ CHANGELOG.md 版本变更、修复和验证记录
docker compose up -d --build docker compose up -d --build
``` ```
默认访问地址:`http://服务器地址:8080` 默认访问地址:`http://服务器地址:9000`
自定义端口: 自定义宿主机端口时,在项目根目录创建 `.env`(可从 `.env.example` 复制),例如改为 `9100`:
```dotenv
WEB_TOOLS_PORT=9100
```
然后正常启动:
```bash ```bash
WEB_TOOLS_PORT=9000 docker compose up -d --build docker compose up -d --build
``` ```
检查状态: 检查状态:
@@ -109,7 +115,7 @@ docker compose down
导航配置保存在 Docker 卷 `web-tools-data` 中的 `/data/navigation.json`。全新部署且从未保存过排序时,应用直接使用内置默认配置,该文件可能尚不存在;首次保存后会创建。删除或重建容器不会丢失已有配置,执行 `docker compose down -v` 才会删除该卷。 导航配置保存在 Docker 卷 `web-tools-data` 中的 `/data/navigation.json`。全新部署且从未保存过排序时,应用直接使用内置默认配置,该文件可能尚不存在;首次保存后会创建。删除或重建容器不会丢失已有配置,执行 `docker compose down -v` 才会删除该卷。
健康检查地址:`http://服务器地址:8080/healthz` 健康检查地址:`http://服务器地址:9000/healthz`
## Windows Server 部署(不使用 Docker) ## Windows Server 部署(不使用 Docker)
@@ -134,10 +140,19 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 start
```powershell ```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 start ` powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 start `
-ListenAddress 127.0.0.1 ` -ListenAddress 127.0.0.1 `
-Port 8080 ` -Port 9100 `
-DataDir C:\ProgramData\WebTools\data -DataDir C:\ProgramData\WebTools\data
``` ```
脚本不会自动修改 Windows 防火墙。需要从局域网访问默认端口时,可由管理员仅对专用网络配置入站规则:
```powershell
New-NetFirewallRule -DisplayName 'Web Tools 9000' -Direction Inbound -Action Allow `
-Protocol TCP -LocalPort 9000 -Profile Private
```
只允许本机或由反向代理访问时,使用 `-ListenAddress 127.0.0.1`,无需开放该端口。
从 Git `main` 分支更新时,先在运行窗口按 `Ctrl+C` 停止服务,再执行: 从 Git `main` 分支更新时,先在运行窗口按 `Ctrl+C` 停止服务,再执行:
```powershell ```powershell
@@ -148,12 +163,19 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 update
脚本不会读取、覆盖或删除 `C:\ProgramData\WebTools\data`。PowerShell 窗口关闭后前台服务会停止;如使用 Windows 计划任务,应让任务执行 `web-tools.ps1 start`,更新时停止任务、运行 `web-tools.ps1 update -NoStart`,然后重新启动任务。 脚本不会读取、覆盖或删除 `C:\ProgramData\WebTools\data`。PowerShell 窗口关闭后前台服务会停止;如使用 Windows 计划任务,应让任务执行 `web-tools.ps1 start`,更新时停止任务、运行 `web-tools.ps1 update -NoStart`,然后重新启动任务。
## 启动故障排查
- `ENOENT ... dist\\index.html`:当前目录只有源码或尚未生成生产前端。先执行 `npm.cmd ci --include=dev` 和 `npm.cmd run build`,再启动服务;直接执行 `node server\\index.mjs` 不会替代构建步骤。
- 页面能打开但左侧没有新工具:仅重新构建 `dist` 不会更新已经运行的 Node 进程。停止旧进程后重新启动,并在浏览器执行一次强制刷新(`Ctrl+F5`)。
- 新工具仍未出现:确认服务输出的 `Navigation config` 路径与实际数据目录一致。旧的 `navigation.json` 会保留用户排序,只在服务重启后首次读取 API 时补入新工具。
- 关闭 PowerShell 窗口后服务消失:前台启动是预期行为。需要长期运行时使用 Windows 计划任务或服务管理器,让任务执行 `web-tools.ps1 start`,不要依赖打开的交互窗口。
## 工具管理 ## 工具管理
管理入口不显示在页面中,直接访问: 管理入口不显示在页面中,直接访问:
```text ```text
http://服务器地址:8080/setting http://服务器地址:9000/setting
``` ```
可以调整分类顺序、分类内工具顺序及工具所属分类。保存时采用同目录临时文件和原子替换。 可以调整分类顺序、分类内工具顺序及工具所属分类。保存时采用同目录临时文件和原子替换。
@@ -192,7 +214,15 @@ docker compose up -d --no-build
npm ci npm ci
``` ```
终端一运行配置服务: 终端一运行开发配置服务。这里的 `8081` 仅用于 Vite 开发代理,不是生产默认端口。PowerShell:
```powershell
$env:PORT = '8081'
$env:DATA_DIR = (Join-Path (Get-Location) 'data')
node .\server\index.mjs
```
Bash:
```bash ```bash
PORT=8081 DATA_DIR=./data node server/index.mjs PORT=8081 DATA_DIR=./data node server/index.mjs
@@ -211,7 +241,9 @@ npm run build
npm start npm start
``` ```
生产服务默认监听 `0.0.0.0:8080`。 生产服务默认监听 `0.0.0.0:9000`。
新增工具或修改 `shared/tool-catalog.json`、`shared/default-navigation.json` 后,不能只执行 `npm run build`。Node 服务会在启动时加载工具目录;必须先停止旧进程,再重新执行 `npm start`(Windows Server 使用 `web-tools.ps1 start`,Docker 使用 `docker compose up -d --build`)。服务重启后,已打开的浏览器页面还需要刷新,才会重新读取导航 API;已有 `data/navigation.json` 不会被覆盖,只会自动补入缺少的新工具。
完整工程检查: 完整工程检查:
@@ -230,6 +262,8 @@ npm audit --audit-level=low
固件 Hash 与 CRC 共用经过校验的 Intel HEX 解析器,但使用独立纯逻辑、Worker 和 Vue 页面。解析器处理扩展段/线性地址,将数据按 32 位绝对地址排序,合并连续记录,去重内容一致的重叠记录,并拒绝冲突或越过 `0xFFFFFFFF` 的数据。记录文本、记录校验和、EOF 和启动地址不参与 Hash。 固件 Hash 与 CRC 共用经过校验的 Intel HEX 解析器,但使用独立纯逻辑、Worker 和 Vue 页面。解析器处理扩展段/线性地址,将数据按 32 位绝对地址排序,合并连续记录,去重内容一致的重叠记录,并拒绝冲突或越过 `0xFFFFFFFF` 的数据。记录文本、记录校验和、EOF 和启动地址不参与 Hash。
摘要计算优先使用浏览器原生 Web Crypto;通过普通 HTTP 局域网地址访问而无法使用 `SubtleCrypto` 时,自动回退到项目内已有的 `node-forge` SHA-256/SHA-512 实现。两条路径都在 Web Worker 中本地处理,不会把固件数据发送到服务端。
“有效数据”模式的总输入为所有 block 数据按地址升序直接拼接,不填充地址空洞。“Block 校验头”模式在每个 block 前加入 8 个二进制字节: “有效数据”模式的总输入为所有 block 数据按地址升序直接拼接,不填充地址空洞。“Block 校验头”模式在每个 block 前加入 8 个二进制字节:
```text ```text
@@ -311,7 +345,7 @@ npm audit --audit-level=low
- 新工具的默认路径 `/tools/<id>` 可以直接打开和刷新。 - 新工具的默认路径 `/tools/<id>` 可以直接打开和刷新。
- 左侧导航、搜索和 `/setting` 正常显示新工具。 - 左侧导航、搜索和 `/setting` 正常显示新工具。
- 已保存的旧 `navigation.json` 启动后会自动补入新工具。 - 已保存的旧 `navigation.json` 在服务重启并首次读取后会自动补入新工具;仅重新构建 `dist` 不会刷新已经运行的 Node 进程。
- 桌面端和约 `390 x 844` 的移动端竖屏布局正常。 - 桌面端和约 `390 x 844` 的移动端竖屏布局正常。
- 浏览器控制台无错误,工具内容没有发往服务端。 - 浏览器控制台无错误,工具内容没有发往服务端。
+1 -1
View File
@@ -8,7 +8,7 @@ services:
restart: unless-stopped restart: unless-stopped
init: true init: true
ports: ports:
- "${WEB_TOOLS_PORT:-8080}:8080" - "${WEB_TOOLS_PORT:-9000}:9000"
volumes: volumes:
- web-tools-data:/data - web-tools-data:/data
read_only: true read_only: true
+18 -2
View File
@@ -16,6 +16,7 @@ const serverFile = fileURLToPath(import.meta.url)
const serverDir = path.dirname(serverFile) const serverDir = path.dirname(serverFile)
const projectRoot = path.resolve(serverDir, '..') const projectRoot = path.resolve(serverDir, '..')
export const MAX_JSON_BODY_SIZE = 64 * 1024 export const MAX_JSON_BODY_SIZE = 64 * 1024
export const DEFAULT_PORT = 9000
const mimeTypes = new Map([ const mimeTypes = new Map([
['.css', 'text/css; charset=utf-8'], ['.css', 'text/css; charset=utf-8'],
@@ -76,6 +77,19 @@ function isMissingFile(error) {
return error?.code === 'ENOENT' || error?.code === 'ENOTDIR' return error?.code === 'ENOENT' || error?.code === 'ENOTDIR'
} }
export async function assertProductionBuild(staticRoot = path.join(projectRoot, 'dist')) {
const indexPath = path.join(path.resolve(staticRoot), 'index.html')
let details
try {
details = await stat(indexPath)
} catch (error) {
if (!isMissingFile(error)) throw error
}
if (!details?.isFile()) {
throw new Error(`Production build not found: ${indexPath}. Run "npm.cmd ci --include=dev" and "npm.cmd run build" before starting the server.`)
}
}
function acceptsHtmlNavigation(request, pathname) { function acceptsHtmlNavigation(request, pathname) {
if (path.extname(pathname)) return false if (path.extname(pathname)) return false
return String(request.headers.accept || '').split(',').some((value) => value.trim().toLowerCase().startsWith('text/html')) return String(request.headers.accept || '').split(',').some((value) => value.trim().toLowerCase().startsWith('text/html'))
@@ -186,8 +200,10 @@ export async function createWebToolsServer(options = {}) {
export async function startWebToolsServer() { export async function startWebToolsServer() {
const host = process.env.HOST || '0.0.0.0' const host = process.env.HOST || '0.0.0.0'
const port = Number(process.env.PORT || 8080) const port = Number(process.env.PORT || DEFAULT_PORT)
const server = await createWebToolsServer() const staticRoot = path.join(projectRoot, 'dist')
await assertProductionBuild(staticRoot)
const server = await createWebToolsServer({ staticRoot })
server.listen(port, host, () => { server.listen(port, host, () => {
console.log(`Web Tools listening on http://${host}:${port}`) console.log(`Web Tools listening on http://${host}:${port}`)
console.log(`Navigation config: ${path.resolve(process.env.DATA_DIR || path.join(projectRoot, 'data'), 'navigation.json')}`) console.log(`Navigation config: ${path.resolve(process.env.DATA_DIR || path.join(projectRoot, 'data'), 'navigation.json')}`)
+28 -6
View File
@@ -24,7 +24,9 @@ export interface FirmwareHashReport {
headerBytes: number headerBytes: number
} }
export type DigestBytes = (algorithm: 'SHA-256' | 'SHA-512', data: Uint8Array) => Promise<string> export type DigestAlgorithm = 'SHA-256' | 'SHA-512'
export type DigestBytes = (algorithm: DigestAlgorithm, data: Uint8Array) => Promise<string>
type SubtleDigestProvider = Pick<SubtleCrypto, 'digest'>
function validateUint32(value: number, label: string): void { function validateUint32(value: number, label: string): void {
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_UINT32_ADDRESS) { if (!Number.isSafeInteger(value) || value < 0 || value > MAX_UINT32_ADDRESS) {
@@ -123,13 +125,33 @@ export function buildFirmwareHashInput(
return input return input
} }
async function webCryptoDigest(algorithm: 'SHA-256' | 'SHA-512', data: Uint8Array): Promise<string> { async function forgeDigest(algorithm: DigestAlgorithm, data: Uint8Array): Promise<string> {
if (!globalThis.crypto?.subtle) throw new Error('当前浏览器不支持 Web Crypto Hash') const { default: forge } = await import('node-forge')
const digest = algorithm === 'SHA-256' ? forge.md.sha256.create() : forge.md.sha512.create()
const chunkSize = 0x8000
for (let offset = 0; offset < data.length; offset += chunkSize) {
digest.update(String.fromCharCode(...data.subarray(offset, Math.min(offset + chunkSize, data.length))))
}
return digest.digest().toHex().toUpperCase()
}
export async function digestFirmwareBytes(
algorithm: DigestAlgorithm,
data: Uint8Array,
subtle: SubtleDigestProvider | undefined = globalThis.crypto?.subtle,
): Promise<string> {
const input = data.buffer instanceof ArrayBuffer && data.byteOffset === 0 && data.byteLength === data.buffer.byteLength const input = data.buffer instanceof ArrayBuffer && data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
? data.buffer ? data.buffer
: data.slice().buffer : data.slice().buffer
const digest = await globalThis.crypto.subtle.digest(algorithm, input) if (subtle) {
return formatBytes(new Uint8Array(digest), { format: 'continuous-hex' }) try {
const digest = await subtle.digest(algorithm, input)
return formatBytes(new Uint8Array(digest), { format: 'continuous-hex' })
} catch {
// Some browsers expose crypto but disable SubtleCrypto on plain HTTP LAN origins.
}
}
return forgeDigest(algorithm, data)
} }
async function calculateDigestPair(data: Uint8Array, digestBytes: DigestBytes): Promise<FirmwareHashDigest> { async function calculateDigestPair(data: Uint8Array, digestBytes: DigestBytes): Promise<FirmwareHashDigest> {
@@ -142,7 +164,7 @@ export async function calculateFirmwareHashReport(
blocks: DataBlock[], blocks: DataBlock[],
mode: FirmwareHashMode, mode: FirmwareHashMode,
byteOrder: FirmwareHashByteOrder = 'big-endian', byteOrder: FirmwareHashByteOrder = 'big-endian',
digestBytes: DigestBytes = webCryptoDigest, digestBytes: DigestBytes = digestFirmwareBytes,
): Promise<FirmwareHashReport> { ): Promise<FirmwareHashReport> {
const sorted = validateAndSortBlocks(blocks) const sorted = validateAndSortBlocks(blocks)
const combinedInput = buildFirmwareHashInput(sorted, mode, byteOrder) const combinedInput = buildFirmwareHashInput(sorted, mode, byteOrder)
+66
View File
@@ -0,0 +1,66 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { DEFAULT_PORT } from '../server/index.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
async function readProjectFile(relativePath) {
return readFile(path.join(projectRoot, relativePath), 'utf8')
}
describe('deployment configuration', () => {
it('keeps the default production port synchronized', async () => {
const [dockerfile, compose, environment, script, readme] = await Promise.all([
readProjectFile('Dockerfile'),
readProjectFile('docker-compose.yml'),
readProjectFile('.env.example'),
readProjectFile('web-tools.ps1'),
readProjectFile('README.zh-CN.md'),
])
expect(DEFAULT_PORT).toBe(9000)
expect(dockerfile).toContain('PORT=9000')
expect(dockerfile).toContain('EXPOSE 9000')
expect(dockerfile).toContain('http://127.0.0.1:9000/healthz')
expect(compose).toContain('${WEB_TOOLS_PORT:-9000}:9000')
expect(environment.trim()).toBe('WEB_TOOLS_PORT=9000')
expect(script).toMatch(/\[int\]\$Port = 9000/)
expect(readme).toContain('默认访问地址:`http://服务器地址:9000`')
expect(readme).toContain('生产服务默认监听 `0.0.0.0:9000`')
})
it('preserves the Windows and container production safeguards', async () => {
const [dockerfile, compose, dockerignore, script] = await Promise.all([
readProjectFile('Dockerfile'),
readProjectFile('docker-compose.yml'),
readProjectFile('.dockerignore'),
readProjectFile('web-tools.ps1'),
])
expect(dockerfile).toContain('USER node')
expect(compose).toContain('read_only: true')
expect(compose).toContain('no-new-privileges:true')
expect(compose).toContain('cap_drop:')
expect(dockerignore).toContain('node_modules')
expect(dockerignore).toContain('data')
expect(script).toContain('#Requires -Version 5.1')
expect(script).toContain("@('pull', '--ff-only', $Remote, $Branch)")
expect(script).toContain("@('ci', '--include=dev')")
})
it('provides standard entry points for a new AI session', async () => {
const [entryReadme, agentGuide, completeReadme] = await Promise.all([
readProjectFile('README.md'),
readProjectFile('AGENTS.md'),
readProjectFile('README.zh-CN.md'),
])
expect(entryReadme).toContain('[README.zh-CN.md](README.zh-CN.md)')
expect(entryReadme).toContain('[AGENTS.md](AGENTS.md)')
expect(agentGuide).toContain('## First actions in a new session')
expect(agentGuide).toContain('git status --short --branch')
expect(completeReadme).toContain('## AI 接手清单')
})
})
+9
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import { import {
buildFirmwareHashInput, buildFirmwareHashInput,
calculateFirmwareHashReport, calculateFirmwareHashReport,
digestFirmwareBytes,
encodeFirmwareBlockHeader, encodeFirmwareBlockHeader,
type FirmwareHashByteOrder, type FirmwareHashByteOrder,
type FirmwareHashMode, type FirmwareHashMode,
@@ -131,3 +132,11 @@ describe('firmware SHA report', () => {
expect(relocatedWithHeaders.combined).not.toEqual(withHeaders.combined) expect(relocatedWithHeaders.combined).not.toEqual(withHeaders.combined)
}) })
}) })
describe('firmware digest provider', () => {
it('falls back to the local implementation when SubtleCrypto is unavailable', async () => {
const data = textToBytes('plain HTTP LAN fallback')
expect(await digestFirmwareBytes('SHA-256', data, undefined)).toBe(hash('sha256', data))
expect(await digestFirmwareBytes('SHA-512', data, undefined)).toBe(hash('sha512', data))
})
})
+48 -1
View File
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { createWebToolsServer, MAX_JSON_BODY_SIZE } from '../server/index.mjs' import { assertProductionBuild, createWebToolsServer, MAX_JSON_BODY_SIZE } from '../server/index.mjs'
const temporaryDirectories = [] const temporaryDirectories = []
const servers = [] const servers = []
@@ -47,6 +47,12 @@ async function createFixtureServer() {
} }
describe('HTTP server', () => { describe('HTTP server', () => {
it('fails before listening when the production build is missing', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-missing-build-'))
temporaryDirectories.push(directory)
await expect(assertProductionBuild(path.join(directory, 'dist'))).rejects.toThrow('npm.cmd run build')
})
it('serves direct SPA navigation but returns 404 for missing assets', async () => { it('serves direct SPA navigation but returns 404 for missing assets', async () => {
const { baseUrl } = await createFixtureServer() const { baseUrl } = await createFixtureServer()
const page = await fetch(`${baseUrl}/tools/base64`, { headers: { Accept: 'text/html' } }) const page = await fetch(`${baseUrl}/tools/base64`, { headers: { Accept: 'text/html' } })
@@ -88,6 +94,47 @@ describe('HTTP server', () => {
expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual(updated) expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual(updated)
}) })
it('normalizes an existing navigation file when a later release adds a tool', async () => {
const { baseUrl, dataDir } = await createFixtureServer()
const legacy = { version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] }
await mkdir(dataDir, { recursive: true })
await writeFile(path.join(dataDir, 'navigation.json'), JSON.stringify(legacy))
const response = await fetch(`${baseUrl}/api/navigation`)
expect(await response.json()).toEqual(legacy)
const expandedCatalog = { ...catalog, tools: [...catalog.tools, { id: 'base64', category: 'encoding' }] }
const expandedDefaults = { ...defaults, categories: [{ id: 'encoding', tools: ['hex', 'base64'] }, ...defaults.categories.slice(1)] }
const secondServer = await createWebToolsServer({
staticRoot: path.join(dataDir, 'missing-dist'),
dataDir,
catalog: expandedCatalog,
defaults: expandedDefaults,
logger: { error: () => undefined },
})
servers.push(secondServer)
await new Promise((resolve, reject) => {
secondServer.once('error', reject)
secondServer.listen(0, '127.0.0.1', resolve)
})
const address = secondServer.address()
const upgraded = await fetch(`http://127.0.0.1:${address.port}/api/navigation`)
expect(await upgraded.json()).toEqual({
version: 1,
categories: [
{ id: 'encoding', tools: ['hex', 'base64'] },
{ id: 'crypto', tools: ['cmac'] },
],
})
expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual({
version: 1,
categories: [
{ id: 'encoding', tools: ['hex', 'base64'] },
{ id: 'crypto', tools: ['cmac'] },
],
})
})
it('rejects invalid, oversized and cross-site navigation writes', async () => { it('rejects invalid, oversized and cross-site navigation writes', async () => {
const { baseUrl } = await createFixtureServer() const { baseUrl } = await createFixtureServer()
const invalid = await fetch(`${baseUrl}/api/navigation`, { const invalid = await fetch(`${baseUrl}/api/navigation`, {
+1 -1
View File
@@ -68,7 +68,7 @@ function Assert-ProductionBuild {
} }
if (-not (Test-Path -LiteralPath $distIndex -PathType Leaf)) { if (-not (Test-Path -LiteralPath $distIndex -PathType Leaf)) {
throw "Production build was not found: $distIndex. Run '.\web-tools.ps1 update' first." throw "Production build was not found: $distIndex. Run '.\web-tools.ps1 update -NoStart' or 'npm.cmd run build' first."
} }
} }