From 1fbb2a6fedb4cc8677db834702bfb110cdfeb323 Mon Sep 17 00:00:00 2001 From: m1saka Date: Fri, 16 Oct 2026 16:13:35 +0800 Subject: [PATCH] feat(runtime): add document tools, MiSans and patched chat manager --- README.md | 16 ++++++++++ image/Dockerfile | 12 ++++++- image/MiSans-NOTICE.txt | 9 ++++++ image/entrypoint.sh | 2 +- image/init-profile.mjs | 9 +++--- image/init-profile.test.mjs | 24 ++++++++++++++ image/install-chat-manager.mjs | 53 +++++++++++++++++++++++++++++++ image/install-misans.mjs | 33 +++++++++++++++++++ image/patch-chat-manager.mjs | 24 ++++++++++++++ image/patch-chat-manager.test.mjs | 34 ++++++++++++++++++++ image/platforms.test.mjs | 9 ++++++ 11 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 image/MiSans-NOTICE.txt create mode 100644 image/install-chat-manager.mjs create mode 100644 image/install-misans.mjs create mode 100644 image/patch-chat-manager.mjs create mode 100644 image/patch-chat-manager.test.mjs diff --git a/README.md b/README.md index 501a092..e69ee57 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,21 @@ # DSH Runtime Release +## 内置修复版 Chat Manager + +镜像内置 `dsh-chat-manager@1.5.12-dsh-mgr.1`(基于官方 1.5.12,保留 MIT 许可及服务端安全校验)。补丁仅针对客户端:删除、恢复、归档搜索在面板内使用当前 `/dsh/<实例ID>/` 前缀,独立 DSH 根路径部署保持原行为;非 JSON 响应显示 HTTP 状态和代理排查提示,不再直接暴露 JSON 解析异常。不会修改全局 fetch、绕过 Cookie/Origin/可信主机校验或自动重试删除请求。 + +新建空 web profile 默认启用此插件,内置包和 clsx 依赖均支持离线安装。已有 profile 不会被覆盖,也不会自动替换用户已安装的官方版本。升级镜像后,如需迁移已有实例,请保留其他设置,将 profile 中同名依赖改为 `file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz`,并设置 pnpm override `dsh-chat-manager>clsx` 为 `file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz`,确保 bundles 中只有一次 `dsh-chat-manager`,再重启实例。不要同时启用重复的手动 patch 插件条目。 + +域名访问仍要求 DSH 配置相应 trusted host,且 runtime 必须经过面板认证与实例归属校验。此修复不开放面板根路径的插件接口。 + +## 内置 Python 与 LibreOffice + +Runtime 镜像包含 Debian Bookworm 的 Python 3、pip、venv(`python` 指向 `python3`),以及 LibreOffice 和 Noto CJK 中文字体。LibreOffice 可通过 `libreoffice --headless` 无界面运行。Python 项目依赖请安装到工作区的虚拟环境中,不要覆盖系统 Python 包。 + +这些依赖在镜像构建时安装;已有实例需要重建镜像并升级后才生效。 + +另包含小米 MiSans 简体中文字体的 10 个静态 TTF 字重,保留 Noto CJK,不修改默认字体优先级。构建时从小米官方下载并校验固定 SHA-256,仅安装原始 TTF 文件;上游包变化会令构建失败,需审核后更新校验值。字体及声明位于 `/usr/local/share/fonts/misans`。MiSans 使用小米专有免费商用许可,并非开源字体许可;安装使用及对外分发镜像前应阅读[官方许可协议](https://hyperos.mi.com/font/zh/download/),不得单独分发或售卖字体。 + ## runtime-7.3:域名访问设置适配 本次待发布修订为 runtime-7.3。构建时对 DSH 0.2.0-rc.2 的设置 UI 应用精确补丁,使设置视图与表单使用 host 持久化;不修改 connection.isLoopback、可信主机、Origin 或认证逻辑。版本或目标语句不匹配时构建失败,升级上游版本必须重新审核补丁。 diff --git a/image/Dockerfile b/image/Dockerfile index 3a87a6c..0298b60 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -3,13 +3,20 @@ FROM node:24-bookworm-slim ARG DSH_VERSION # Keep the historical dependency-layer cache key; the template revision is set below. ARG RECIPE_REV=3 -RUN apt-get update && apt-get install -y --no-install-recommends tini ca-certificates git procps && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends \ + tini ca-certificates git procps \ + python3 python3-pip python3-venv python-is-python3 \ + libreoffice fonts-noto-cjk \ + && rm -rf /var/lib/apt/lists/* +RUN python --version && python3 -m pip --version && libreoffice --headless --version RUN test -n "$DSH_VERSION" && npm install --global --foreground-scripts --loglevel=verbose --registry=https://registry.npmjs.org "@deepseek-ai/dsh@${DSH_VERSION}" RUN npm install --global --ignore-scripts --registry=https://registry.npmjs.org pnpm@10.11.0 && test "$(pnpm --version)" = "10.11.0" COPY install-mobile.mjs /usr/local/lib/dsh-mgr/install-mobile.mjs RUN node /usr/local/lib/dsh-mgr/install-mobile.mjs COPY install-free-search.mjs /usr/local/lib/dsh-mgr/install-free-search.mjs RUN node /usr/local/lib/dsh-mgr/install-free-search.mjs +COPY install-chat-manager.mjs patch-chat-manager.mjs /usr/local/lib/dsh-mgr/ +RUN node /usr/local/lib/dsh-mgr/install-chat-manager.mjs COPY install-workspace.mjs /usr/local/lib/dsh-mgr/install-workspace.mjs RUN node /usr/local/lib/dsh-mgr/install-workspace.mjs COPY init-profile.mjs /usr/local/lib/dsh-mgr/init-profile.mjs @@ -22,6 +29,9 @@ RUN cp /usr/bin/caddy /usr/bin/caddy.clean && mv /usr/bin/caddy.clean /usr/bin/c COPY Caddyfile /etc/caddy/Caddyfile COPY entrypoint.sh /usr/local/bin/dsh-entrypoint RUN chmod 755 /usr/local/bin/dsh-entrypoint && mkdir -p /data /workspace /shared && ln -s /workspace /data/dsh-home && chown -R node:node /data /workspace /shared +COPY install-misans.mjs /usr/local/lib/dsh-mgr/install-misans.mjs +RUN node /usr/local/lib/dsh-mgr/install-misans.mjs +COPY MiSans-NOTICE.txt /usr/local/share/fonts/misans/NOTICE.txt ARG RECIPE_REV=7 LABEL dsh-mgr.role=runtime dsh-mgr.version=$DSH_VERSION dsh-mgr.recipe=$RECIPE_REV ENV DSH_HOME=/workspace HOME=/workspace XDG_DATA_HOME=/workspace/.local/share XDG_CONFIG_HOME=/workspace/.config diff --git a/image/MiSans-NOTICE.txt b/image/MiSans-NOTICE.txt new file mode 100644 index 0000000..17142c7 --- /dev/null +++ b/image/MiSans-NOTICE.txt @@ -0,0 +1,9 @@ +This runtime uses MiSans fonts by Xiaomi. +Official source: https://hyperos.mi.com/font/zh/download/ +Archive: https://hyperos.mi.com/font-download/MiSans.zip + +MiSans is subject to the MiSans Fonts Intellectual Property License Agreement +published on the official download page. It is not an open-source font license. +Installation and use indicate acceptance of that agreement. Do not modify the +fonts or distribute/sell them separately. Review the complete agreement before +redistributing a runtime image containing these fonts. diff --git a/image/entrypoint.sh b/image/entrypoint.sh index e389c7a..f2dbe72 100644 --- a/image/entrypoint.sh +++ b/image/entrypoint.sh @@ -8,7 +8,7 @@ if [[ -f "${DSH_HOME}/profiles/web/package.json" ]]; then # pnpm otherwise probes registry metadata for the overlapping optional peer. # Restrict offline mode to profiles containing only our fixed archives. install_env=() - if node -e 'const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); const pins={"dsh-mgr-workspace":"file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz","dsh-web-mobile":"file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz","dsh-free-search":"file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz"}; process.exit(Object.entries({...m.dependencies,...m.devDependencies,...m.optionalDependencies}).every(([n,v])=>pins[n]===v)?0:1)'; then + if node -e 'const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); const pins={"dsh-chat-manager":"file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz","dsh-mgr-workspace":"file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz","dsh-web-mobile":"file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz","dsh-free-search":"file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz"}; process.exit(Object.entries({...m.dependencies,...m.devDependencies,...m.optionalDependencies}).every(([n,v])=>pins[n]===v)?0:1)'; then install_env=(npm_config_offline=true) fi if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts; then diff --git a/image/init-profile.mjs b/image/init-profile.mjs index d8fd0b5..768956f 100644 --- a/image/init-profile.mjs +++ b/image/init-profile.mjs @@ -14,22 +14,23 @@ if (!existsSync(profile) || readdirSync(profile).length === 0) { mkdirSync(profile, { recursive: true }); writeFileSync(join(profile, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', private: true, - dependencies: { 'dsh-mgr-workspace': 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz', 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz', 'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz' }, + dependencies: { 'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz', 'dsh-mgr-workspace': 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz', 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz', 'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz' }, pnpm: { overrides: { + 'dsh-chat-manager>clsx': 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz', 'dsh-free-search>@deepseek-ai/schemastery': 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz', '@deepseek-ai/schemastery>@deepseek-ai/cosmokit': 'file:/usr/local/lib/dsh-mgr/cosmokit-1.8.5.tgz', '@deepseek-ai/schemastery>@standard-schema/spec': 'file:/usr/local/lib/dsh-mgr/spec-1.1.0.tgz', } }, - dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace'] } }, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace', 'dsh-chat-manager'] } }, }, null, 2), { flag: 'wx', mode: 0o600 }); - console.log('dsh-mgr: new web profile includes dsh-web-mobile@3.0.5 and dsh-free-search@0.8.5'); + console.log('dsh-mgr: new web profile includes dsh-web-mobile@3.0.5 and dsh-free-search@0.8.5 and dsh-chat-manager@1.5.12-dsh-mgr.1'); } const manifestPath = join(profile, 'package.json'); const workspacePath = join(profile, 'pnpm-workspace.yaml'); // Backups omit workspace settings; restore host-provided peers before pnpm runs. if (existsSync(manifestPath) && !existsSync(workspacePath)) { const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - if (manifest.dependencies?.['dsh-web-mobile'] === 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz' || manifest.dependencies?.['dsh-free-search'] === 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz') { + if (manifest.dependencies?.['dsh-chat-manager'] === 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz' || manifest.dependencies?.['dsh-web-mobile'] === 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz' || manifest.dependencies?.['dsh-free-search'] === 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz') { writeFileSync(workspacePath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', { flag: 'wx', mode: 0o600 }); } } diff --git a/image/init-profile.test.mjs b/image/init-profile.test.mjs index 99be286..2dcd62c 100644 --- a/image/init-profile.test.mjs +++ b/image/init-profile.test.mjs @@ -11,6 +11,9 @@ test('new profile enables bundle once and preserves existing configuration', () run(); const file=join(home,'profiles/web/package.json'); const original=readFileSync(file,'utf8'); + assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-chat-manager')); + assert.equal(JSON.parse(original).dependencies['dsh-chat-manager'], 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz'); + assert.equal(JSON.parse(original).pnpm.overrides['dsh-chat-manager>clsx'], 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz'); assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-web-mobile')); assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-free-search')); assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-mgr-workspace')); @@ -24,11 +27,15 @@ test('new profile enables bundle once and preserves existing configuration', () run();assert.equal(readFileSync(file,'utf8'),original); const legacy = JSON.parse(original); delete legacy.dependencies['dsh-mgr-workspace']; + delete legacy.dependencies['dsh-chat-manager']; + delete legacy.pnpm.overrides['dsh-chat-manager>clsx']; + legacy.dsh.profile.bundles = legacy.dsh.profile.bundles.filter(name => name !== 'dsh-chat-manager'); legacy.dsh.profile.bundles = legacy.dsh.profile.bundles.filter(name => name !== 'dsh-mgr-workspace'); writeFileSync(file, JSON.stringify(legacy)); run(); assert.deepEqual(JSON.parse(readFileSync(file)), legacy); const disabled = JSON.parse(original); + disabled.dsh.profile.bundles = disabled.dsh.profile.bundles.filter(name => name !== 'dsh-chat-manager'); disabled.dsh.profile.bundles = disabled.dsh.profile.bundles.filter(name => name !== 'dsh-web-mobile'); writeFileSync(file, JSON.stringify(disabled)); writeFileSync(workspace, 'custom workspace'); @@ -42,6 +49,23 @@ test('new profile enables bundle once and preserves existing configuration', () writeFileSync(file,custom);run();assert.equal(readFileSync(file,'utf8'),custom); } finally { rmSync(home,{recursive:true,force:true}); } }); +test('chat-manager-only restored profile gets offline peer settings without manifest changes', () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-chat-restore-')); + try { + const profile = join(home, 'profiles/web'); + mkdirSync(profile, { recursive: true }); + const manifest = JSON.stringify({ dependencies: { 'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz' } }); + writeFileSync(join(profile, 'package.json'), manifest); + execFileSync(process.execPath, [new URL('./init-profile.mjs', import.meta.url).pathname], { env: { ...process.env, DSH_HOME: home } }); + assert.equal(readFileSync(join(profile, 'package.json'), 'utf8'), manifest); + assert.match(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), /autoInstallPeers: false/); + const entrypoint = readFileSync(new URL('./entrypoint.sh', import.meta.url), 'utf8'); + const predicate = entrypoint.match(/if node -e '([^']+)'/)[1]; + execFileSync(process.execPath, ['-e', predicate], { env: { ...process.env, DSH_HOME: home } }); + writeFileSync(join(profile, 'package.json'), JSON.stringify({ dependencies: { 'dsh-chat-manager': '^1.5.12' } })); + assert.throws(() => execFileSync(process.execPath, ['-e', predicate], { env: { ...process.env, DSH_HOME: home }, stdio: 'pipe' })); + } finally { rmSync(home, { recursive: true, force: true }); } +}); test('nonempty legacy profiles are not automatically injected', () => { const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-legacy-')); try { diff --git a/image/install-chat-manager.mjs b/image/install-chat-manager.mjs new file mode 100644 index 0000000..02dfeb2 --- /dev/null +++ b/image/install-chat-manager.mjs @@ -0,0 +1,53 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { patchClient } from './patch-chat-manager.mjs'; + +const root = '/usr/local/lib/dsh-mgr'; +const version = '1.5.12'; +const patchedVersion = '1.5.12-dsh-mgr.1'; +const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'; +const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-chat-manager'; +const packages = [ + ['dsh-chat-manager', version, 'https://registry.npmjs.org/dsh-chat-manager/-/dsh-chat-manager-1.5.12.tgz', 'UpecIKftHTKZMAdZg0w/GFtYGN2A/fB7NVQ/OrrBOKsq1frN5nm4CDBH8KZggwfT9GSx+MjwLlwYgxDfDWLm4g=='], + ['clsx', '2.1.1', 'https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz', 'eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=='], +]; +mkdirSync(root, { recursive: true }); +for (const [name, release, url, integrity] of packages) { + const response = await fetch(url, { signal: AbortSignal.timeout(120000) }); + if (!response.ok) throw new Error('download failed: ' + name); + const data = Buffer.from(await response.arrayBuffer()); + if (createHash('sha512').update(data).digest('base64') !== integrity) throw new Error('integrity mismatch: ' + name); + writeFileSync(`${root}/${name}-${release}.tgz`, data, { mode: 0o644 }); +} +mkdirSync(destination, { recursive: true }); +execFileSync('tar', ['-xzf', `${root}/dsh-chat-manager-${version}.tgz`, '--strip-components=1', '--no-same-owner', '-C', destination]); +const pkg = JSON.parse(readFileSync(destination + '/package.json')); +if (pkg.name !== 'dsh-chat-manager' || pkg.version !== version || pkg.license !== 'MIT' || JSON.stringify(pkg.dependencies) !== JSON.stringify({ clsx: '^2.0.0' })) throw new Error('unexpected chat-manager metadata'); +if (!readFileSync(destination + '/LICENSE', 'utf8').includes('MIT License')) throw new Error('missing chat-manager MIT license'); +const require = createRequire(hostManifest); +const host = JSON.parse(readFileSync(hostManifest)); +const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver'); +for (const [name, range] of Object.entries(pkg.peerDependencies || {})) { + if (name === 'react') { + if (host.version !== '0.2.0-rc.2') throw new Error('unverified browser React host: ' + host.version); + continue; + } + const peer = require(name + '/package.json'); + if (!semver.satisfies(peer.version, range)) throw new Error('incompatible chat-manager host dependency: ' + name + '@' + peer.version); +} +const client = destination + '/lib/client.js'; +writeFileSync(client, patchClient(readFileSync(client, 'utf8'), version)); +pkg.version = patchedVersion; +writeFileSync(destination + '/package.json', JSON.stringify(pkg, null, 2)); +execFileSync('chmod', ['-R', 'a+rX', destination]); +execFileSync('tar', ['-czf', `${root}/dsh-chat-manager-${patchedVersion}.tgz`, '--transform=s,^dsh-chat-manager,package,', '-C', destination + '/..', 'dsh-chat-manager']); +execFileSync('chmod', ['644', `${root}/dsh-chat-manager-${patchedVersion}.tgz`]); +const clsx = destination + '/node_modules/clsx'; +mkdirSync(clsx, { recursive: true }); +execFileSync('tar', ['-xzf', `${root}/clsx-2.1.1.tgz`, '--strip-components=1', '--no-same-owner', '-C', clsx]); +execFileSync('chmod', ['-R', 'a+rX', clsx]); +host.dependencies = { ...host.dependencies, 'dsh-chat-manager': patchedVersion }; +writeFileSync(hostManifest, JSON.stringify(host, null, 2)); +console.log('Installed dsh-chat-manager@' + patchedVersion + ' and clsx@2.1.1 (SHA-512 verified; no lifecycle scripts; MIT retained)'); diff --git a/image/install-misans.mjs b/image/install-misans.mjs new file mode 100644 index 0000000..8630a77 --- /dev/null +++ b/image/install-misans.mjs @@ -0,0 +1,33 @@ +import { createHash } from 'node:crypto'; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const url = 'https://hyperos.mi.com/font-download/MiSans.zip'; +const sha256 = 'b6aa1fc827035922612df8edf36e5609bca1c5441e25cd57572204569b7b81d9'; +const weights = ['Thin', 'ExtraLight', 'Light', 'Normal', 'Regular', 'Medium', 'Demibold', 'Semibold', 'Bold', 'Heavy']; +const destination = '/usr/local/share/fonts/misans'; +const temporary = mkdtempSync(join(tmpdir(), 'misans-')); +try { + const response = await fetch(url, { signal: AbortSignal.timeout(300000) }); + if (!response.ok) throw new Error(`MiSans download failed: ${response.status}`); + const archive = Buffer.from(await response.arrayBuffer()); + if (createHash('sha256').update(archive).digest('hex') !== sha256) { + throw new Error('MiSans archive checksum mismatch; review upstream changes before updating'); + } + const archivePath = join(temporary, 'MiSans.zip'); + writeFileSync(archivePath, archive); + mkdirSync(destination, { recursive: true }); + for (const weight of weights) { + const name = `MiSans-${weight}.ttf`; + // Extract only fixed, reviewed filenames; never unpack arbitrary archive paths. + const font = execFileSync('python3', ['-c', 'import sys, zipfile; sys.stdout.buffer.write(zipfile.ZipFile(sys.argv[1]).read(sys.argv[2]))', archivePath, `MiSans/ttf/${name}`], { maxBuffer: 16000000 }); + writeFileSync(join(destination, name), font, { mode: 0o644 }); + } + execFileSync('fc-cache', ['-f', destination], { stdio: 'inherit' }); + const family = execFileSync('fc-match', ['-f', '%{family}', 'MiSans'], { encoding: 'utf8' }); + if (!family.split(',').includes('MiSans')) throw new Error(`MiSans font lookup failed: ${family}`); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} diff --git a/image/patch-chat-manager.mjs b/image/patch-chat-manager.mjs new file mode 100644 index 0000000..7565cb8 --- /dev/null +++ b/image/patch-chat-manager.mjs @@ -0,0 +1,24 @@ +// Scoped to this plugin; host authentication and global fetch are unchanged. +async function dshChatFetch(path, init) { + const parts = window.location.pathname.split('/'); + const prefix = parts[1] === 'dsh' && parts[2] ? '/dsh/' + parts[2] : ''; + return fetch(prefix + path, init); +} +async function dshChatJson(response) { + const text = await response.text(); + try { return JSON.parse(text); } catch { + throw new Error('Chat Manager: HTTP ' + response.status + ' returned a non-JSON response; check the instance proxy path and authentication.'); + } +} +export const clientHelpers = dshChatFetch.toString() + String.fromCharCode(10) + dshChatJson.toString() + String.fromCharCode(10); + +export function patchClient(source, version) { + if (version !== '1.5.12') throw new Error('unsupported chat-manager version'); + if (source.includes('dshChatFetch') || source.split('await fetch(').length !== 4 + || source.split('await response.json()').length !== 4 + || source.split('/plugins/dsh-session-delete/').length !== 4) { + throw new Error('unexpected chat-manager client; review upstream changes'); + } + return clientHelpers + source.replaceAll('await fetch(', 'await dshChatFetch(') + .replaceAll('await response.json()', 'await dshChatJson(response)'); +} diff --git a/image/patch-chat-manager.test.mjs b/image/patch-chat-manager.test.mjs new file mode 100644 index 0000000..6c765bc --- /dev/null +++ b/image/patch-chat-manager.test.mjs @@ -0,0 +1,34 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { runInNewContext } from 'node:vm'; +import { clientHelpers, patchClient } from './patch-chat-manager.mjs'; + +test('chat paths preserve instance prefix and POST options', async () => { + for (const pathname of ['/dsh/demo/', '/dsh/demo', '/dsh/demo/session/123', '/', '/other/']) { + let received; + const context = { window: { location: { pathname } }, fetch: (...args) => { received = args; } }; + runInNewContext(clientHelpers, context); + for (const action of ['delete', 'restore', 'archive-search']) { + const path = '/plugins/dsh-session-delete/' + action; + const options = { method: 'POST', body: '{}', headers: { 'x-dsh-session-delete-confirmation': 'delete-session' } }; + await context.dshChatFetch(path, options); + assert.equal(received[0], (pathname.startsWith('/dsh/demo') ? '/dsh/demo' : '') + path); + assert.equal(received[1], options); + } + } +}); +test('non-JSON errors report HTTP status without exposing response content', async () => { + const context = {}; + runInNewContext(clientHelpers, context); + await assert.rejects(context.dshChatJson(new Response('method not allowed', { status: 405 })), /HTTP 405.*non-JSON/); + const body = await context.dshChatJson(Response.json({ ok: false, error: { code: 'forbidden' } }, { status: 403 })); + assert.equal(body.error.code, 'forbidden'); +}); +test('patch rejects unsupported or changed clients and double patching', () => { + const source = Array(3).fill("await fetch('/plugins/dsh-session-delete/delete'); await response.json()").join(';'); + const patched = patchClient(source, '1.5.12'); + assert.equal(patched.split('await dshChatFetch(').length, 4); + assert.throws(() => patchClient(source, '1.5.13'), /unsupported/); + assert.throws(() => patchClient('', '1.5.12'), /unexpected/); + assert.throws(() => patchClient(patched, '1.5.12'), /unexpected/); +}); diff --git a/image/platforms.test.mjs b/image/platforms.test.mjs index 377fbd8..7f3b3fc 100644 --- a/image/platforms.test.mjs +++ b/image/platforms.test.mjs @@ -14,3 +14,12 @@ test('Node and Caddy stages inherit the target platform', () => { assert.deepEqual(stages, ['FROM caddy:2.10.2 AS proxy', 'FROM node:24-bookworm-slim']); assert.ok(dockerfile.includes('COPY --from=proxy /usr/bin/caddy /usr/bin/caddy')); }); + +test('runtime includes Python tooling and headless LibreOffice with CJK fonts', () => { + const dockerfile = readFileSync(new URL('./Dockerfile', import.meta.url), 'utf8'); + const install = dockerfile.split(String.fromCharCode(92, 10)).join(' ').split(String.fromCharCode(10)).find(line => line.startsWith('RUN apt-get update && apt-get install')); + for (const pkg of ['python3', 'python3-pip', 'python3-venv', 'python-is-python3', 'libreoffice', 'fonts-noto-cjk']) { + assert.ok(install.split(/\s+/).includes(pkg), `missing runtime package: ${pkg}`); + } + assert.ok(dockerfile.includes('RUN python --version && python3 -m pip --version && libreoffice --headless --version')); +});