feat(runtime): add native office preview and download plugin
This commit is contained in:
@@ -1,8 +1,42 @@
|
||||
# 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 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 修复
|
||||
|
||||
@@ -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 构建及运行验收。清单声明会开放面板的对应平台构建入口,不等于兼容性证明;验收完成前不要推送发布。
|
||||
|
||||
|
||||
@@ -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
|
||||
RUN node /usr/local/lib/dsh-mgr/install-misans.mjs
|
||||
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
|
||||
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
|
||||
|
||||
+1
-1
@@ -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-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)
|
||||
fi
|
||||
if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts; then
|
||||
|
||||
@@ -14,14 +14,14 @@ 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-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: {
|
||||
'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-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 });
|
||||
console.log('dsh-mgr: new web profile includes [email protected] and [email protected] and [email protected]');
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ 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.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.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');
|
||||
|
||||
@@ -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();
|
||||
@@ -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']);
|
||||
@@ -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]); }
|
||||
};
|
||||
}
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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 }); }
|
||||
@@ -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)
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
} });
|
||||
@@ -0,0 +1,3 @@
|
||||
- insert:
|
||||
- id: dsh-preview-download
|
||||
name: dsh-preview-download
|
||||
@@ -0,0 +1,3 @@
|
||||
// Client-only extension: no new server endpoints or authorization changes.
|
||||
export const name = 'dsh-preview-download';
|
||||
export function apply() {}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"release_id": "runtime-7.5",
|
||||
"release_id": "runtime-7.7",
|
||||
"version": "0.2.0-rc.2",
|
||||
"deployment_schema": 7,
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
|
||||
Reference in New Issue
Block a user