feat(runtime): add native office preview and download plugin

This commit is contained in:
2026-10-17 03:03:32 +08:00
parent 65d118a145
commit bbdee632f6
18 changed files with 476 additions and 6 deletions
+36 -2
View File
@@ -1,8 +1,42 @@
# DSH Runtime Release # DSH Runtime Release
## runtime-7.7:预览下载插件
内置独立插件 dsh-preview-download@1.0.0,在文档预览工具栏提供「下载原文件」。使用正式 sidebar.right.tab.document.actions 插槽,不修改上游 UI、不扫描 DOM、不增加服务端下载接口。通过当前会话 workspaceFiles.readBytes 获取原始字节,沿用服务端权限和完整文件大小上限;支持中文文件名、下载中状态、失败提示及切换文件/卸载时取消读取。下载的是原始文件,不是 Office 转换出的 PDF。文件元数据无法读取时不会显示工具栏按钮。
新建空 profile 默认启用。已有实例升级镜像后,在 /workspace/profiles/web/package.json 的 dependencies 中添加以下条目,并在 dsh.profile.bundles 中追加一次 dsh-preview-download,保留其他配置,然后重启:
~~~json
"dsh-preview-download": "file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.0.tgz"
~~~
插件源码位于 image/preview-download,也可单独打包安装。无第三方运行时依赖,React 由 DSH 浏览器提供。当前仅针对 DSH 0.2.0-rc.2 验证。25 项离线回归测试通过,已在现有 AMD64 镜像中验证插件打包、新 profile 离线安装及服务启动;完整 runtime-7.7 构建在 MiSans 下载阶段超时,尚未完成浏览器实际点击下载、移动端和 ARM64 验收。不应把单元测试中的拒绝响应模拟当作真实权限端到端验收。
## runtime-7.6:原生 Office 文档预览
Linux 文档预览通过构建期补丁将 Office-to-PDF 转换入口接到系统 LibreOffice,不再使用该入口原先默认的 WASM 后端。补丁限定 DSH 和 dsh-office-to-pdf 为 0.2.0-rc.2,目标 import 不匹配或重复应用时构建失败。保留上游授权、队列、缓存和 PDF 验证;不修改独立 dsoffice CLI 或其他插件。
每次转换使用私有临时目录、独立 profile、最高宏安全级别和独立进程组;保留默认 60 秒超时,取消时终止进程组。检查输入大小、OOXML 条目数、解压总量和 CRC、输出大小及完整性。子进程采用最小环境变量;校验器地址空间上限 512 MiB,LibreOffice 为 2 GiB,单文件写入上限为 max(maxOutputBytes, 16 MiB),最终 PDF 仍严格检查 maxOutputBytes。进程资源限制不是进程树总量或临时目录总量限制,部署时仍须容器内存、CPU、PID 和临时磁盘限制。
安全边界:原生 LibreOffice 与 DSH 使用同一 UID,独立 profile 不是文件系统或网络沙箱;不应将此功能视为隔离恶意文档的服务。容器内敏感文件及网络访问仍需部署侧隔离,尤其是处理不可信外部文档时。
字体由系统解析;显式 fontDirectories/fontFallbacks 配置会被拒绝,应移除并改用系统字体配置。kit 的 maxFontFiles/maxFontFileBytes/maxLoadedFontBytes 不再约束系统字体。missingFonts 为空仅表示不提供缺失字体诊断,不代表没有字体替换。
已完成 AMD64 构建、22 项回归测试,以及非 root/禁网容器中 DOCX/PPTX/DOC/PPT 并发转换、已安装 DSH convertBytes 集成、输出/压缩包限制、超时及清理验证。小型单页样本约 3 秒/文件,不代表实际文档性能保证。ARM64、真实失败样本、浏览器端到端、XLS/XLSX 和视觉版式尚未验收。
构建后在仓库根目录运行:
~~~sh
docker run --rm --network none --init \
--mount type=bind,src="$PWD/image",dst=/test,readonly \
--entrypoint node dsh-web:runtime-7.6-amd64 /test/office-preview-smoke.mjs
~~~
现有实例需升级新镜像才生效,不需要清空工作区。不自动提交、推送或升级线上实例。
## runtime-7.5:精简 MiSans 字重 ## runtime-7.5:精简 MiSans 字重
本次待发布修订为 runtime-7.5,仅保留 MiSans Regular 和 Bold,从第三方 GitHub 仓库 boyan01/mi_sans_font 的固定提交下载独立 TTF 并逐个校验 SHA-256,合计约 15.73 MB,不再下载官方完整 ZIP。构建环境需要能够访问 raw.githubusercontent.com。 runtime-7.5 修订仅保留 MiSans Regular 和 Bold,从第三方 GitHub 仓库 boyan01/mi_sans_font 的固定提交下载独立 TTF 并逐个校验 SHA-256,合计约 15.73 MB,不再下载官方完整 ZIP。构建环境需要能够访问 raw.githubusercontent.com。
## runtime-7.4:文档工具、中文字体与 Chat Manager 修复 ## runtime-7.4:文档工具、中文字体与 Chat Manager 修复
@@ -36,7 +70,7 @@ Runtime 镜像包含 Debian Bookworm 的 Python 3、pip、venv(`python` 指向
## 发布清单 ## 发布清单
`release.json` 是发布入口:`schema_version` 为清单格式版本,`release_id` 标识一次发布,`version` 为精确 DSH 版本,`deployment_schema` 为运行布局版本,`platforms` 为支持的平台。当前待发布配置为 `runtime-7.5`、DSH `0.2.0-rc.2`、布局 7、`linux/amd64` 和 `linux/arm64`(aarch64,不含 32 位 ARM)。 `release.json` 是发布入口:`schema_version` 为清单格式版本,`release_id` 标识一次发布,`version` 为精确 DSH 版本,`deployment_schema` 为运行布局版本,`platforms` 为支持的平台。当前待发布配置为 `runtime-7.7`、DSH `0.2.0-rc.2`、布局 7、`linux/amd64` 和 `linux/arm64`(aarch64,不含 32 位 ARM)。
ARM64 验收状态:已确认 Node/Caddy 基础镜像提供 ARM64 版本,但尚未完成 ARM64 runtime 构建及运行验收。清单声明会开放面板的对应平台构建入口,不等于兼容性证明;验收完成前不要推送发布。 ARM64 验收状态:已确认 Node/Caddy 基础镜像提供 ARM64 版本,但尚未完成 ARM64 runtime 构建及运行验收。清单声明会开放面板的对应平台构建入口,不等于兼容性证明;验收完成前不要推送发布。
+5
View File
@@ -32,6 +32,11 @@ RUN chmod 755 /usr/local/bin/dsh-entrypoint && mkdir -p /data /workspace /shared
COPY install-misans.mjs /usr/local/lib/dsh-mgr/install-misans.mjs COPY install-misans.mjs /usr/local/lib/dsh-mgr/install-misans.mjs
RUN node /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 COPY MiSans-NOTICE.txt /usr/local/share/fonts/misans/NOTICE.txt
COPY install-office-preview.mjs native-office.mjs validate-office.py office-worker.py /usr/local/lib/dsh-mgr/
RUN node /usr/local/lib/dsh-mgr/install-office-preview.mjs
COPY preview-download /usr/local/lib/dsh-mgr/preview-download
COPY install-preview-download.mjs /usr/local/lib/dsh-mgr/install-preview-download.mjs
RUN node /usr/local/lib/dsh-mgr/install-preview-download.mjs
ARG RECIPE_REV=7 ARG RECIPE_REV=7
LABEL dsh-mgr.role=runtime dsh-mgr.version=$DSH_VERSION dsh-mgr.recipe=$RECIPE_REV 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 ENV DSH_HOME=/workspace HOME=/workspace XDG_DATA_HOME=/workspace/.local/share XDG_CONFIG_HOME=/workspace/.config
+1 -1
View File
@@ -8,7 +8,7 @@ if [[ -f "${DSH_HOME}/profiles/web/package.json" ]]; then
# pnpm otherwise probes registry metadata for the overlapping optional peer. # pnpm otherwise probes registry metadata for the overlapping optional peer.
# Restrict offline mode to profiles containing only our fixed archives. # Restrict offline mode to profiles containing only our fixed archives.
install_env=() install_env=()
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 if node -e 'const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); const pins={"dsh-preview-download":"file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.0.tgz","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) install_env=(npm_config_offline=true)
fi fi
if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts; then if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts; then
+2 -2
View File
@@ -14,14 +14,14 @@ if (!existsSync(profile) || readdirSync(profile).length === 0) {
mkdirSync(profile, { recursive: true }); mkdirSync(profile, { recursive: true });
writeFileSync(join(profile, 'package.json'), JSON.stringify({ writeFileSync(join(profile, 'package.json'), JSON.stringify({
name: 'dsh-profile-web', private: true, name: 'dsh-profile-web', private: true,
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' }, dependencies: { 'dsh-preview-download': 'file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.0.tgz', '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: { pnpm: { overrides: {
'dsh-chat-manager>clsx': 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz', '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', '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>@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', '@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-chat-manager'] } }, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace', 'dsh-chat-manager', 'dsh-preview-download'] } },
}, null, 2), { flag: 'wx', mode: 0o600 }); }, null, 2), { flag: 'wx', mode: 0o600 });
console.log('dsh-mgr: new web profile includes [email protected] and [email protected] and [email protected]'); console.log('dsh-mgr: new web profile includes [email protected] and [email protected] and [email protected]');
} }
+2
View File
@@ -11,6 +11,8 @@ test('new profile enables bundle once and preserves existing configuration', ()
run(); run();
const file=join(home,'profiles/web/package.json'); const file=join(home,'profiles/web/package.json');
const original=readFileSync(file,'utf8'); const original=readFileSync(file,'utf8');
assert.equal(JSON.parse(original).dependencies['dsh-preview-download'], 'file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.0.tgz');
assert.equal(JSON.parse(original).dsh.profile.bundles.filter(name => name === 'dsh-preview-download').length, 1);
assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-chat-manager')); 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).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.equal(JSON.parse(original).pnpm.overrides['dsh-chat-manager>clsx'], 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz');
+21
View File
@@ -0,0 +1,21 @@
import { readFileSync, writeFileSync, copyFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
export const target = 'import { createConverter } from "@deepseek-ai/libreoffice-kit";';
export function patchOffice(source, hostVersion, packageVersion) {
if (hostVersion !== '0.2.0-rc.2' || packageVersion !== '0.2.0-rc.2') throw new Error('unsupported Office preview version');
if (source.split(target).length !== 2) throw new Error('expected exactly one original converter import');
return source.replace(target, 'import { createConverter } from "./dsh-native-office.mjs";');
}
export function install(root = '/usr/local/lib/node_modules/@deepseek-ai/dsh') {
const dir = join(root, 'node_modules/@deepseek-ai/dsh-office-to-pdf');
const version = path => JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')).version;
const file = join(dir, 'lib/index.js');
const patched = patchOffice(readFileSync(file, 'utf8'), version(root), version(dir));
for (const [source, name] of [['native-office.mjs', 'dsh-native-office.mjs'], ['validate-office.py', 'validate-office.py'], ['office-worker.py', 'office-worker.py']]) {
copyFileSync(new URL(source, import.meta.url), join(dir, 'lib', name));
}
writeFileSync(file, patched);
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) install();
+8
View File
@@ -0,0 +1,8 @@
import { cpSync, mkdirSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
const root = '/usr/local/lib/dsh-mgr';
const host = JSON.parse(readFileSync('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'));
if (host.version !== '0.2.0-rc.2') throw new Error('Unverified preview download host');
mkdirSync(root + '/preview-download-package', { recursive: true });
cpSync(new URL('./preview-download', import.meta.url), root + '/preview-download-package/package', { recursive: true });
execFileSync('tar', ['-czf', root + '/dsh-preview-download-1.0.0.tgz', '-C', root + '/preview-download-package', 'package']);
+119
View File
@@ -0,0 +1,119 @@
import { spawn } from 'node:child_process';
import { constants } from 'node:fs';
import { open, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, extname, isAbsolute } from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
const failure = (code, message) => Object.assign(new Error(message), { code });
// No shell; terminate the whole group, including soffice.bin, on cancellation.
export function runProcess(executable, args, signal, env = process.env) {
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const child = spawn(executable, args, { detached: true, stdio: 'ignore', env });
let error;
const kill = () => {
if (child.pid) {
try { process.kill(-child.pid, 'SIGKILL'); }
catch (cause) { if (cause.code !== 'ESRCH') error ??= cause; }
}
};
const abort = () => { error = signal.reason; kill(); };
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
child.once('error', cause => { error ??= failure('unavailable', 'Cannot start helper: ' + cause.code); });
child.once('close', code => {
signal.removeEventListener('abort', abort);
kill();
if (error) reject(error);
else if (code !== 0) reject(failure('invalid-document', 'Conversion helper exit: ' + code));
else resolve();
});
});
}
async function readBounded(path, maximum, kind, signal) {
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const stat = await file.stat();
if (!stat.isFile()) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'Regular file required');
if (stat.size > maximum) throw failure(kind + '-too-large', 'File exceeds limit');
const bytes = Buffer.alloc(stat.size + 1);
let length = 0;
while (length < bytes.length) {
signal.throwIfAborted();
const read = await file.read(bytes, length, bytes.length - length, null);
if (!read.bytesRead) break;
length += read.bytesRead;
}
if (length !== stat.size) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'File changed');
return bytes.subarray(0, length);
} finally { await file.close(); }
}
export async function createConverter(options = {}) {
if (process.platform !== 'linux') throw failure('unavailable', 'Native preview requires Linux');
if (options.fontDirectories !== undefined || options.fontFallbacks !== undefined) {
throw failure('unavailable', 'Native preview uses system fonts; remove kit-only font overrides');
}
const lifetime = new AbortController();
const active = new Set();
const limits = { timeoutMs: 60000, maxInputBytes: 50 * 1024 ** 2, maxOutputBytes: 100 * 1024 ** 2, maxArchiveEntries: 10000, maxUncompressedBytes: 250 * 1024 ** 2, maxImageResolution: 192, ...options };
for (const key of ['timeoutMs', 'maxInputBytes', 'maxOutputBytes', 'maxArchiveEntries', 'maxUncompressedBytes', 'maxImageResolution']) {
if (!Number.isSafeInteger(limits[key]) || limits[key] < 1) throw new TypeError('Invalid ' + key);
}
async function render({ inputPath, outputPath }, external = new AbortController().signal) {
const deadline = new AbortController();
const signal = AbortSignal.any([external, lifetime.signal, deadline.signal]);
const timer = setTimeout(() => deadline.abort(failure('timeout', 'Native Office conversion timed out')), limits.timeoutMs);
let scratch;
let output;
let success = false;
try {
signal.throwIfAborted();
const extension = extname(inputPath).slice(1).toLowerCase();
if (!['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(extension)) throw failure('unsupported-format', 'Unsupported Office extension');
if (!isAbsolute(inputPath) || !isAbsolute(outputPath)) throw failure('invalid-document', 'Absolute paths required');
output = await open(outputPath, 'wx', 0o600);
scratch = await mkdtemp(join(tmpdir(), 'dsh-native-office-'));
const source = join(scratch, 'source.' + extension);
await writeFile(source, await readBounded(inputPath, limits.maxInputBytes, 'input', signal), { flag: 'wx', mode: 0o600, signal });
const launcher = fileURLToPath(new URL('./office-worker.py', import.meta.url));
const env = { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', HOME: scratch, TMPDIR: scratch, SAL_USE_VCLPLUGIN: 'svp', SAL_DISABLE_PRINTERLIST: '1', SAL_DISABLE_DEFAULTPRINTER: '1' };
await runProcess('/usr/bin/python3', ['-I', launcher, String(512 * 1024 ** 2), String(limits.maxOutputBytes), '/usr/bin/python3', '-I', fileURLToPath(new URL('./validate-office.py', import.meta.url)), source, extension, String(limits.maxArchiveEntries), String(limits.maxUncompressedBytes)], signal, env);
const profile = join(scratch, 'profile');
await mkdir(join(profile, 'user'), { recursive: true, mode: 0o700 });
await writeFile(join(profile, 'user/registrymodifications.xcu'), '<?xml version="1.0"?><oor:items xmlns:oor="http://openoffice.org/2001/registry"><item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="MacroSecurityLevel" oor:op="fuse"><value>3</value></prop></item></oor:items>');
const filter = extension.startsWith('ppt') ? 'impress_pdf_Export' : extension.startsWith('xls') ? 'calc_pdf_Export' : 'writer_pdf_Export';
const pdfOptions = JSON.stringify({ ReduceImageResolution: { type: 'boolean', value: 'true' }, MaxImageResolution: { type: 'long', value: String(limits.maxImageResolution) } });
await runProcess('/usr/bin/python3', ['-I', launcher, String(2 * 1024 ** 3), String(Math.max(limits.maxOutputBytes, 16 * 1024 ** 2)), '/usr/bin/libreoffice', '-env:UserInstallation=' + pathToFileURL(profile).href, '--headless', '--nologo', '--nodefault', '--nofirststartwizard', '--convert-to', 'pdf:' + filter + ':' + pdfOptions, '--outdir', scratch, source], signal, env);
let bytes;
try { bytes = await readBounded(join(scratch, 'source.pdf'), limits.maxOutputBytes, 'output', signal); }
catch (cause) { if (cause.code === 'ENOENT') throw failure('invalid-output', 'LibreOffice produced no PDF'); throw cause; }
if (!/^%PDF-[0-9][.][0-9]/.test(bytes.subarray(0, 8).toString()) || !bytes.subarray(-1024).toString().trimEnd().endsWith('%%EOF')) throw failure('invalid-output', 'Incomplete PDF');
signal.throwIfAborted();
await output.writeFile(bytes);
signal.throwIfAborted();
success = true;
// System LibreOffice has no kit missing-font diagnostic API.
return { backend: 'native-system', missingFonts: [] };
} finally {
clearTimeout(timer);
try { await output?.close(); }
finally {
try { if (output && !success) await rm(outputPath, { force: true }); }
finally { if (scratch) await rm(scratch, { recursive: true, force: true }); }
}
}
}
return {
render(request, signal) {
const task = render(request, signal);
active.add(task);
task.then(() => active.delete(task), () => active.delete(task));
return task;
},
async dispose() { lifetime.abort(failure('unavailable', 'Converter disposed')); await Promise.allSettled([...active]); }
};
}
+50
View File
@@ -0,0 +1,50 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, readFile, rm, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createConverter, runProcess } from './native-office.mjs';
import { patchOffice, target } from './install-office-preview.mjs';
test('patch is exact, version gated and refuses repeat application', () => {
const patched = patchOffice(target, '0.2.0-rc.2', '0.2.0-rc.2');
assert.match(patched, /dsh-native-office/);
for (const source of ['', target + target, patched]) assert.throws(() => patchOffice(source, '0.2.0-rc.2', '0.2.0-rc.2'));
assert.throws(() => patchOffice(target, 'next', '0.2.0-rc.2'));
assert.throws(() => patchOffice(target, '0.2.0-rc.2', 'next'));
});
test('process cancellation, missing binary and failure are classified', async () => {
const signal = new AbortController().signal;
await assert.rejects(runProcess('/not-an-office-binary', [], signal), { code: 'unavailable' });
await assert.rejects(runProcess(process.execPath, ['-e', 'process.exit(2)'], signal), { code: 'invalid-document' });
const controller = new AbortController();
const reason = new Error('cancel test');
const pending = runProcess(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], controller.signal);
controller.abort(reason);
await assert.rejects(pending, error => error === reason);
});
test('input bounds, corruption, exclusive output, abort and disposal', async () => {
const directory = await mkdtemp(join(tmpdir(), 'office-test-'));
const inputPath = join(directory, 'source.docx');
const outputPath = join(directory, 'output.pdf');
const converter = await createConverter();
try {
await writeFile(inputPath, 'invalid');
await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'invalid-document' });
await assert.rejects(access(outputPath));
const small = await createConverter({ maxInputBytes: 1 });
await assert.rejects(small.render({ inputPath, outputPath }), { code: 'input-too-large' });
await small.dispose();
await writeFile(outputPath, 'preserve');
await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'EEXIST' });
assert.equal(await readFile(outputPath, 'utf8'), 'preserve');
const controller = new AbortController();
controller.abort(new Error('cancelled'));
await assert.rejects(converter.render({ inputPath, outputPath }, controller.signal), /cancelled/);
await converter.dispose();
await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'unavailable' });
await assert.rejects(createConverter({ fontDirectories: [] }), { code: 'unavailable' });
} finally { await converter.dispose(); await rm(directory, { recursive: true, force: true }); }
});
+47
View File
@@ -0,0 +1,47 @@
// Run inside the built image as UID 1000. No downloads or fixture dependencies.
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, readFile, rm, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { pathToFileURL } from 'node:url';
import { runProcess } from './native-office.mjs';
const installed = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-office-to-pdf/lib/';
const { createConverter } = await import(installed + 'dsh-native-office.mjs');
const { OfficeToPdf } = await import(installed + 'index.js');
assert.notEqual(process.getuid(), 0, 'smoke test must run as non-root');
const dir = await mkdtemp(join(tmpdir(), 'office-smoke-'));
const signal = new AbortController().signal;
const ns = 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"';
const converter = await createConverter();
try {
await writeFile(join(dir, 'word.fodt'), '<?xml version="1.0"?><office:document ' + ns + ' office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.text"><office:body><office:text><text:p>中文预览 Word conversion test</text:p></office:text></office:body></office:document>');
await writeFile(join(dir, 'slides.fodp'), '<?xml version="1.0"?><office:document ' + ns + ' office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.presentation"><office:body><office:presentation><draw:page draw:name="slide1"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="20cm" svg:height="5cm"><draw:text-box><text:p>中文预览 PPT conversion test</text:p></draw:text-box></draw:frame></draw:page></office:presentation></office:body></office:document>');
for (const [source, format] of [['word.fodt', 'docx'], ['slides.fodp', 'pptx'], ['word.fodt', 'doc'], ['slides.fodp', 'ppt']]) {
await runProcess('/usr/bin/libreoffice', ['-env:UserInstallation=' + pathToFileURL(join(dir, 'fixture-profile')).href, '--headless', '--convert-to', format, '--outdir', dir, join(dir, source)], signal);
}
await Promise.all(['word.docx', 'slides.pptx', 'word.doc', 'slides.ppt'].map(async name => {
const start = performance.now();
const outputPath = join(dir, name + '.pdf');
const result = await converter.render({ inputPath: join(dir, name), outputPath });
assert.equal(result.backend, 'native-system');
assert.match((await readFile(outputPath)).subarray(0, 8).toString(), /^%PDF-/);
console.log(name + ': native PDF in ' + Math.round(performance.now() - start) + 'ms');
}));
const small = await createConverter({ maxOutputBytes: 1 });
await assert.rejects(small.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'small.pdf') }), { code: 'output-too-large' });
await small.dispose();
const timeout = await createConverter({ timeoutMs: 1 });
await assert.rejects(timeout.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'timeout.pdf') }), { code: 'timeout' });
await timeout.dispose();
const limited = await createConverter({ maxArchiveEntries: 1 });
await assert.rejects(limited.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'limit.pdf') }), { code: 'invalid-document' });
await limited.dispose();
const provider = { slots: [], options: {}, config: { maxOutputBytes: 100 * 1024 ** 2 } };
const converted = await OfficeToPdf.prototype.convertBytes.call(provider, await readFile(join(dir, 'slides.pptx')), 'pptx', signal);
assert.match(Buffer.from(converted.pdf).subarray(0, 8).toString(), /^%PDF-/);
await Promise.all(provider.slots.map(async slot => (await slot.converter).dispose()));
console.log('installed DSH convertBytes integration passed');
assert.equal((await readdir(tmpdir())).filter(name => name.startsWith('dsh-native-office-')).length, 0);
console.log('concurrent DOCX/PPTX/DOC/PPT, output/ZIP limits, timeout and cleanup passed');
} finally { await converter.dispose(); await rm(dir, { recursive: true, force: true }); }
+10
View File
@@ -0,0 +1,10 @@
"""Apply process limits before executing a trusted conversion helper."""
import os
import resource
import sys
memory, file_bytes, *command = sys.argv[1:]
resource.setrlimit(resource.RLIMIT_AS, (int(memory), int(memory)))
resource.setrlimit(resource.RLIMIT_FSIZE, (int(file_bytes), int(file_bytes)))
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
os.execv(command[0], command)
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
function load() {
let plugin;
runInNewContext(readFileSync(new URL('./preview-download/client.js', import.meta.url), 'utf8'), {
Uint8Array, window: { __ModuleLoader__: { load({ id, factory }) { assert.equal(id, 'dsh-preview-download'); plugin = factory(() => ({})); } } }
});
return plugin;
}
test('download passes exact session/path/signal to authorized complete-file RPC', async () => {
const plugin = load();
const data = new Uint8Array([0, 255, 10]);
const signal = new AbortController().signal;
const remote = { workspaceFiles: { async readBytes(...args) {
assert.equal(args[0], 'session-A'); assert.equal(args[1], '/workspace/中文.pptx');
assert.equal(Object.keys(args[2]).length, 0); assert.equal(args[3], signal);
return { ok: true, value: { offset: 0, eof: true, bytes: 3, data } };
} } };
assert.equal(await plugin.readOriginal(remote, 'session-A', '/workspace/中文.pptx', signal), data);
assert.equal(plugin.filename('/workspace/中文.pptx'), '中文.pptx');
assert.equal(plugin.filename('C:' + String.fromCharCode(92) + 'folder' + String.fromCharCode(92) + 'file.docx'), 'file.docx');
});
test('denied, partial, size mismatch and cancelled reads never produce download data', async () => {
const plugin = load();
for (const result of [
{ ok: false, error: { code: 'gateway/forbidden' } },
{ ok: true, value: { offset: 0, eof: false, data: new Uint8Array() } },
{ ok: true, value: { offset: 0, eof: true, bytes: 5, data: new Uint8Array(2) } }
]) {
await assert.rejects(plugin.readOriginal({ workspaceFiles: { readBytes: async () => result } }, 's', '/file', new AbortController().signal));
}
const controller = new AbortController(); controller.abort();
await assert.rejects(plugin.readOriginal({ workspaceFiles: { readBytes: async () => ({ ok: true, value: {} }) } }, 's', '/file', controller.signal));
});
test('plugin registers only the supported preview action slot', () => {
const plugin = load();
let registration;
plugin.apply({ effect: fn => fn(), locale: { register: () => () => {} }, remote: {}, slots: {
inject(name, fn) { assert.equal(name, 'sidebar.right.tab.document.actions'); return fn(); },
register(options, component) { registration = options; assert.equal(typeof component, 'function'); return () => {}; }
} });
assert.equal(registration.id, 'dsh-preview-download');
assert.equal(typeof registration.inject('session-A').download, 'function');
});
+77
View File
@@ -0,0 +1,77 @@
window.__ModuleLoader__.load({ id: 'dsh-preview-download', factory: require => {
const React = require('react');
const { createElement: h, useState, useRef, useEffect } = React;
const namespace = 'previewDownload';
function filename(path) {
const name = path.replaceAll('\\', '/').split('/').pop() || 'download';
return Array.from(name, char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127 || '<>:"|?*'.includes(char) ? '_' : char).join('');
}
async function readOriginal(remote, sessionId, path, signal) {
signal.throwIfAborted();
const result = await remote.workspaceFiles.readBytes(sessionId, path, {}, signal);
signal.throwIfAborted();
if (!result.ok) throw Object.assign(new Error('Download refused'), { code: result.error.code });
const file = result.value;
if (!file.eof || file.offset !== 0 || !(file.data instanceof Uint8Array) || (file.bytes !== undefined && file.bytes !== file.data.byteLength)) {
throw new Error('Incomplete file response');
}
return file.data;
}
function Download({ absolutePath, download, t }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const pending = useRef(null);
useEffect(() => {
setBusy(false);
setError('');
return () => { pending.current?.abort(); pending.current = null; };
}, [absolutePath, download]);
async function start() {
if (pending.current) return;
const controller = new AbortController();
pending.current = controller;
setBusy(true);
setError('');
try {
const bytes = await download(absolutePath, controller.signal);
controller.signal.throwIfAborted();
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/octet-stream' }));
const link = document.createElement('a');
link.href = url;
link.download = filename(absolutePath);
link.hidden = true;
document.body.appendChild(link);
try { link.click(); }
finally { link.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); }
} catch (cause) {
if (!controller.signal.aborted) setError(t(cause?.code === 'workspace-file/too-large' ? 'tooLarge' : 'failed'));
} finally {
if (pending.current === controller) { pending.current = null; setBusy(false); }
}
}
return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', flexShrink: 0 } },
h('button', { type: 'button', disabled: busy, onClick: start, title: t('download'), 'aria-label': t('download'), 'aria-busy': busy, 'data-preview-download': '',
style: { color: 'inherit', background: 'transparent', border: '1px solid currentColor', borderRadius: '6px', padding: '4px 8px', font: 'inherit', cursor: busy ? 'wait' : 'pointer' }
}, busy ? t('loading') : t('download')),
error && h('span', { role: 'alert', style: { fontSize: '12px', maxWidth: '180px' } }, error));
}
return {
inject: ['slots', 'locale', 'remote.workspaceFiles'],
apply(ctx) {
ctx.effect(() => ctx.locale.register(namespace, {
zh: { download: '下载原文件', loading: '下载中…', failed: '下载失败,请检查文件权限或重试。', tooLarge: '文件超过下载大小限制。' },
en: { download: 'Download original', loading: 'Downloading…', failed: 'Download failed. Check access or retry.', tooLarge: 'File exceeds the download size limit.' }
}));
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({
name: 'sidebar.right.tab.document.actions', id: 'dsh-preview-download', locale: namespace,
inject: sessionId => ({ download: (path, signal) => readOriginal(ctx.remote, sessionId, path, signal) })
}, Download)));
},
// Pure helpers are also exercised by the offline regression suite.
filename, readOriginal
};
} });
+3
View File
@@ -0,0 +1,3 @@
- insert:
- id: dsh-preview-download
name: dsh-preview-download
+3
View File
@@ -0,0 +1,3 @@
// Client-only extension: no new server endpoints or authorization changes.
export const name = 'dsh-preview-download';
export function apply() {}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "dsh-preview-download",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "index.js",
"exports": { ".": "./index.js", "./client": "./client.js" },
"dsh": {
"bundle": { "patch": "cordis.patch.yml" },
"client": {
"platform": "web",
"inject": ["@deepseek-ai/dsh-client-ui-sidebar-documentpreview", "@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-api-workspace-files"]
}
}
}
+29
View File
@@ -0,0 +1,29 @@
"""Bounded, non-extracting Office container validation; Python stdlib only."""
import sys
import zipfile
path, ext, entries, maximum = sys.argv[1:]
if ext in ('doc', 'xls', 'ppt'):
with open(path, 'rb') as source:
if source.read(8) != bytes.fromhex('d0cf11e0a1b11ae1'):
raise ValueError('not OLE')
else:
expected = {'docx': 'word/document.xml', 'xlsx': 'xl/workbook.xml', 'pptx': 'ppt/presentation.xml'}[ext]
with zipfile.ZipFile(path) as archive:
info = archive.infolist()
names = [entry.filename for entry in info]
if len(info) > int(entries) or len(set(names)) != len(names):
raise ValueError('entry limit or duplicate')
if expected not in names or '[Content_Types].xml' not in names:
raise ValueError('format mismatch')
if sum(entry.file_size for entry in info) > int(maximum):
raise ValueError('uncompressed size limit')
total = 0
for entry in info:
if entry.flag_bits & 1:
raise ValueError('encrypted')
with archive.open(entry) as stream:
while chunk := stream.read(65536):
total += len(chunk)
if total > int(maximum):
raise ValueError('uncompressed size limit')
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"schema_version": 1, "schema_version": 1,
"release_id": "runtime-7.5", "release_id": "runtime-7.7",
"version": "0.2.0-rc.2", "version": "0.2.0-rc.2",
"deployment_schema": 7, "deployment_schema": 7,
"platforms": ["linux/amd64", "linux/arm64"] "platforms": ["linux/amd64", "linux/arm64"]