feat(v0.5.0): secure project previews and fix host UI compatibility
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { hostPreviewUrl } from './hostTransport'
|
||||
|
||||
const SANDBOX = 'allow-scripts allow-forms allow-downloads'
|
||||
const SITE = '/api/worktable/site/'
|
||||
|
||||
/** Accept navigations, not executable URL schemes. Keep saved URLs free of capabilities. */
|
||||
export function previewTarget(input: string, base: string) {
|
||||
const url = new URL(input, base)
|
||||
const own = new URL(base)
|
||||
const sameHost = url.protocol === own.protocol && url.host === own.host
|
||||
if (!['http:', 'https:'].includes(url.protocol) && !(sameHost && url.protocol === 'dsh-app:')) throw new Error('Unsupported preview URL')
|
||||
if (!sameHost || !url.pathname.startsWith(SITE)) return { url: url.href }
|
||||
const segments = url.pathname.slice(SITE.length).split('/')
|
||||
const root = decodeURIComponent(segments.shift() ?? '')
|
||||
if (!root) throw new Error('Missing preview directory')
|
||||
const relative = segments.map(decodeURIComponent)
|
||||
if (relative.some(s => s === '..' || /[\\/\0]/.test(s))) throw new Error('Invalid preview path')
|
||||
return { root, relative: relative.map(encodeURIComponent).join('/'), suffix: url.search + url.hash }
|
||||
}
|
||||
|
||||
/** Untrusted project pages never share the host's document origin or storage. */
|
||||
export function SandboxedFrame(props: { url: string; title?: string; reloadKey: number }) {
|
||||
const [src, setSrc] = useState('about:blank')
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let token: string | undefined
|
||||
const revoke = (value: string) => { void fetch('/api/worktable/preview', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ revoke: value }), keepalive: true,
|
||||
}).catch(() => {}) }
|
||||
setSrc('about:blank')
|
||||
setError('')
|
||||
void (async () => {
|
||||
if (!props.url || props.url === 'about:blank') return
|
||||
const target = previewTarget(props.url, location.href)
|
||||
if ('url' in target) { if (alive) setSrc(target.url!); return }
|
||||
const response = await fetch('/api/worktable/preview', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ root: target.root }),
|
||||
})
|
||||
if (!response.ok) throw new Error('Preview HTTP ' + response.status)
|
||||
const grant = await response.json()
|
||||
if (typeof grant.token !== 'string' || !/^[\w-]{32}$/.test(grant.token) || grant.base !== '/api/worktable/preview-static/' + grant.token + '/') throw new Error('Invalid preview grant')
|
||||
token = grant.token
|
||||
if (!alive) { revoke(token!); token = undefined; return }
|
||||
setSrc(hostPreviewUrl(grant.base + target.relative + target.suffix))
|
||||
})().catch(err => {
|
||||
if (token) { revoke(token); token = undefined }
|
||||
if (alive) setError(String(err))
|
||||
})
|
||||
return () => { alive = false; if (token) revoke(token) }
|
||||
}, [props.url, props.reloadKey])
|
||||
if (error) return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{error}</span></div>
|
||||
return <iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src}
|
||||
data-source-url={props.url} title={props.title ?? ''} sandbox={SANDBOX} referrerPolicy="no-referrer" />
|
||||
}
|
||||
@@ -1,6 +1,28 @@
|
||||
/** 更新公告正文(v0.4.0:双端适配、分组与项目内容隔离;纯文本呈现,发布状态以 GitHub 为准) */
|
||||
export const CHANGELOG_VERSION = 'v0.4.0'
|
||||
export const CHANGELOG_V030 = `更新公告 · v0.4.0
|
||||
/** 更新公告正文;公开发布状态以 GitHub Release 为准。 */
|
||||
export const CHANGELOG_VERSION = 'v0.5.0'
|
||||
export const CHANGELOG_V030 = `更新公告 · v0.5.0
|
||||
|
||||
🛡️ 新版宿主兼容、安全隔离与侧栏修复
|
||||
|
||||
【接口身份校验】文件、目录、写入、Git、工作区接口和终端握手复用宿主身份验证。新版只允许 owner,0.2.0 使用单 operator 身份;不能确认身份时拒绝访问,不静默开放。健康接口仍可读状态,不代表全部功能可用。
|
||||
|
||||
【项目网页隔离】浏览器、动画及自定义 HTML 窗格不再拥有 DSH 主界面的同源权限。本地 HTML 通过临时只读地址加载该目录内的资源;地址不写入项目存档,关闭网页标签、刷新替换或卸载会撤销。切换项目仍保留原有最多 6 个项目保活,不等于立即撤销。网页仍能联网,不应给不可信页面挂载含敏感文件的目录。
|
||||
|
||||
【隔离带来的影响】部分网页的浏览器存储、Cookie 登录、嵌套页面、WebGL 贴图及标注自动取字可能受限,旧网页可能需要项目侧适配。工作台不自动改写项目网页,也不通过取消隔离恢复这些能力。窗口级标注仍可用,必要时补截图。工作台自己的项目、布局与媒体不清空、不迁移。
|
||||
|
||||
【分栏与侧栏】修复小数尺寸经浏览器规范化后误判、反复重排,以及会话根更换后仍观察旧元素的问题。展开和折叠侧栏共用一份弹窗层,控制室的新建入口及绑定、图标、删除弹窗不再因侧栏收起而缺失,避免重复渲染。
|
||||
|
||||
【预览读取】单资源上限 40 MiB,读取期间也执行限制;使用同一文件句柄并复核路径与文件身份,遇到文件替换、变化或挂载撤销则拒绝返回内容。它不是针对恶意本地进程的操作系统级文件沙箱。
|
||||
|
||||
【兼容与验收边界】本版只列 DSH 0.2.0-rc.2 / 0.2.1-rc.3。候选已在 Windows 隔离网页端验证主要页面、文件、网页资源与连接;用户在官方 Windows Desktop 0.2.1-rc.3 验证候选的新项目、项目打开、分栏缩放及侧栏设置弹窗正常。正式包另做安装与回归检查,不将候选页面结果扩大为正式包全功能验收。未验证模型生成全链路、真实审批/子代理及 macOS,不保证未测版本兼容;旧 0.1.x 不在本版支持范围。
|
||||
|
||||
【发布检查】流水线包含源码回归、独立安装、工厂门禁、14 项分栏锚点、侧栏实际组件回归、5 项 Chromium 几何回归与数据目录检查。社区 PR 的修复思路按当前源码吸收,贡献范围与来源见发布说明;采用修法不等于原 PR 已合并。
|
||||
|
||||
【升级提醒】公开发布以 GitHub Release 为准。安装前备份重要数据、核对其他插件兼容性;网页端重启后刷新,桌面端完整退出后通过自带 CLI 安装、再手动打开。若仍加载失败,保留第一条完整错误。这个包不包含宿主模型 HTTP 400 修复。
|
||||
|
||||
───────
|
||||
|
||||
此前版本说明(v0.4.0)
|
||||
|
||||
🖥️ 网页端与 Windows 桌面端适配,修复项目内容串挂
|
||||
|
||||
|
||||
@@ -17,6 +17,24 @@ export function hostWebSocketUrl(
|
||||
return url.href
|
||||
}
|
||||
|
||||
/** Desktop preview assets use the Host HTTP origin with a read-only capability. */
|
||||
export function hostPreviewUrl(
|
||||
path: string,
|
||||
surface: { protocol: string; streamBaseUrl?: string } = {
|
||||
protocol: location.protocol,
|
||||
streamBaseUrl: (globalThis as any).__DSH_TRANSPORT__?.streamBaseUrl,
|
||||
},
|
||||
): string {
|
||||
if (!path.startsWith('/api/worktable/preview-static/')) throw new Error('Invalid preview path')
|
||||
if (surface.protocol !== 'dsh-app:') return path
|
||||
if (!surface.streamBaseUrl) throw new Error('Desktop Host preview URL unavailable')
|
||||
const url = new URL(path, surface.streamBaseUrl)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Unsupported Host preview protocol')
|
||||
// Opaque sandbox requests cannot pass Desktop's dsh-app Origin fence. This
|
||||
// read-only capability needs no Host cookie, so its assets use the Host origin.
|
||||
return url.href
|
||||
}
|
||||
|
||||
/** Keep update instructions on the active profile; Desktop must use its bundled CLI. */
|
||||
export function worktableUpgrade(protocol = typeof location === 'undefined' ? 'http:' : location.protocol) {
|
||||
const desktop = protocol === 'dsh-app:'
|
||||
|
||||
+131
-122
@@ -2516,128 +2516,8 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (!wide) {
|
||||
// 收起态 = 等行高的正方形圆角按钮,中间只留 emoji;点击 = 进入对应项目
|
||||
const railItems: { icon: string; name: string; onClick: (e: any) => void }[] = [
|
||||
{ icon: CONSOLE_ICON, name: t('console.name'), onClick: (e) => clickConsoleCard(e.currentTarget as HTMLElement) },
|
||||
...aliveRegisteredIds.map((id) => ({
|
||||
icon: projects.iconOverrides[id] ?? metas[id]?.icon ?? '📦',
|
||||
name: projects.nameOverrides[id] ?? metas[id]?.name ?? id,
|
||||
onClick: () => openRailProject(id),
|
||||
})),
|
||||
...projects.shortcuts.map((s) => ({
|
||||
icon: s.icon,
|
||||
name: s.name,
|
||||
onClick: () => { try { window.open(s.href, '_blank', 'noopener') } catch {} },
|
||||
})),
|
||||
...projects.layouts.map((l) => ({
|
||||
icon: l.icon ?? '🧱',
|
||||
name: projects.nameOverrides[l.id] ?? l.title,
|
||||
onClick: () => openSplit(l),
|
||||
})),
|
||||
]
|
||||
// 浮动态:fixed 定位到拖前高度(左/宽取折叠列实测几何);停靠态:文档流原位
|
||||
const railStyle = isFloat && railRect
|
||||
? { position: 'fixed' as const, top: float.top, left: railRect.left, width: railRect.width, zIndex: 70 }
|
||||
: bottomInset > 0 ? { marginBottom: bottomInset } : undefined
|
||||
return (
|
||||
<div ref={rootRef} className="dsh-wt_section dsh-wt_rail" style={railStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_railBox">
|
||||
{railItems.length > 0
|
||||
? railItems.map((it, i) => (
|
||||
<button key={i} type="button" className="dsh-wt_railBtn" title={it.name} aria-label={it.name} onClick={it.onClick}>
|
||||
<span aria-hidden>{it.icon}</span>
|
||||
</button>
|
||||
))
|
||||
: <span className="dsh-wt_railIcon">≡</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={'dsh-wt_section' + (isFloat ? ' dsh-wt_float' : '')} style={isFloat ? floatStyle : dockedStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_header">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_handle"
|
||||
data-float={isFloat ? 'true' : 'false'}
|
||||
title={t('handle.title')}
|
||||
aria-label={t('handle.aria')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>≡</button>
|
||||
<span
|
||||
className="dsh-wt_title"
|
||||
title={t('handle.title')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>{t('title')}</span>
|
||||
{updateInfo && (
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_updateBadge"
|
||||
title={t('update.badgeTitle') + ' · v' + updateInfo.latest}
|
||||
aria-label={t('update.badgeTitle')}
|
||||
onClick={() => setViewOptionsOpen(true)}
|
||||
>{ICON_SYNC}</button>
|
||||
)}
|
||||
<div className="dsh-wt_actions">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.search')}
|
||||
title={t('menu.search')}
|
||||
onClick={() => persistView({ searchOpen: !view.searchOpen, query: view.searchOpen ? '' : view.query })}
|
||||
>{ICON_SEARCH}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.viewOptions')}
|
||||
title={t('menu.viewOptions')}
|
||||
onClick={() => { invalidatePickState(); setViewOptionsOpen((v) => !v); setAddOpen(false) }}
|
||||
>{ICON_VIEW_OPTIONS}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.add')}
|
||||
title={t('menu.add')}
|
||||
onClick={() => {
|
||||
invalidatePickState()
|
||||
setAddOpen((v) => !v); setViewOptionsOpen(false)
|
||||
// 父目录默认 = 当前会话工作目录(不落 C 盘默认位置)
|
||||
if (!wsFolderParent) {
|
||||
const cwd = sessionScopeStore.snapshot?.cwd ?? ''
|
||||
if (cwd) setWsFolderParent(cwd)
|
||||
}
|
||||
}}
|
||||
>{ICON_ADD}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.searchOpen && (
|
||||
<div className="dsh-wt_search">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={view.query}
|
||||
onChange={(e) => persistView({ query: e.target.value })}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
/>
|
||||
<button type="button" className="dsh-wt_searchClear" aria-label={t('search.close')}
|
||||
onClick={() => persistView({ searchOpen: false, query: '' })}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
const popupLayer = (
|
||||
<>
|
||||
{addOpen && <div className="dsh-wt_popBackdrop" onClick={() => { invalidatePickState(); setAddOpen(false) }} />}
|
||||
{addOpen && (
|
||||
<div className="dsh-wt_menu dsh-wt_add dsh-wt_pop" style={{ position: 'fixed', left: popLeft, top: popTop, width: 360, zIndex: 80 }}>
|
||||
@@ -3064,6 +2944,135 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
|
||||
if (!wide) {
|
||||
// 收起态 = 等行高的正方形圆角按钮,中间只留 emoji;点击 = 进入对应项目
|
||||
const railItems: { icon: string; name: string; onClick: (e: any) => void }[] = [
|
||||
{ icon: CONSOLE_ICON, name: t('console.name'), onClick: (e) => clickConsoleCard(e.currentTarget as HTMLElement) },
|
||||
...aliveRegisteredIds.map((id) => ({
|
||||
icon: projects.iconOverrides[id] ?? metas[id]?.icon ?? '📦',
|
||||
name: projects.nameOverrides[id] ?? metas[id]?.name ?? id,
|
||||
onClick: () => openRailProject(id),
|
||||
})),
|
||||
...projects.shortcuts.map((s) => ({
|
||||
icon: s.icon,
|
||||
name: s.name,
|
||||
onClick: () => { try { window.open(s.href, '_blank', 'noopener') } catch {} },
|
||||
})),
|
||||
...projects.layouts.map((l) => ({
|
||||
icon: l.icon ?? '🧱',
|
||||
name: projects.nameOverrides[l.id] ?? l.title,
|
||||
onClick: () => openSplit(l),
|
||||
})),
|
||||
]
|
||||
// 浮动态:fixed 定位到拖前高度(左/宽取折叠列实测几何);停靠态:文档流原位
|
||||
const railStyle = isFloat && railRect
|
||||
? { position: 'fixed' as const, top: float.top, left: railRect.left, width: railRect.width, zIndex: 70 }
|
||||
: bottomInset > 0 ? { marginBottom: bottomInset } : undefined
|
||||
return (
|
||||
<>
|
||||
<div ref={rootRef} className="dsh-wt_section dsh-wt_rail" style={railStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_railBox">
|
||||
{railItems.length > 0
|
||||
? railItems.map((it, i) => (
|
||||
<button key={i} type="button" className="dsh-wt_railBtn" title={it.name} aria-label={it.name} onClick={it.onClick}>
|
||||
<span aria-hidden>{it.icon}</span>
|
||||
</button>
|
||||
))
|
||||
: <span className="dsh-wt_railIcon">≡</span>}
|
||||
</div>
|
||||
</div>
|
||||
{popupLayer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={'dsh-wt_section' + (isFloat ? ' dsh-wt_float' : '')} style={isFloat ? floatStyle : dockedStyle}>
|
||||
<div className="dsh-wt_divider" />
|
||||
<div className="dsh-wt_header">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_handle"
|
||||
data-float={isFloat ? 'true' : 'false'}
|
||||
title={t('handle.title')}
|
||||
aria-label={t('handle.aria')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>≡</button>
|
||||
<span
|
||||
className="dsh-wt_title"
|
||||
title={t('handle.title')}
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerUp}
|
||||
onPointerCancel={onHandlePointerUp}
|
||||
onDoubleClick={resetDock}
|
||||
>{t('title')}</span>
|
||||
{updateInfo && (
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_updateBadge"
|
||||
title={t('update.badgeTitle') + ' · v' + updateInfo.latest}
|
||||
aria-label={t('update.badgeTitle')}
|
||||
onClick={() => setViewOptionsOpen(true)}
|
||||
>{ICON_SYNC}</button>
|
||||
)}
|
||||
<div className="dsh-wt_actions">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.search')}
|
||||
title={t('menu.search')}
|
||||
onClick={() => persistView({ searchOpen: !view.searchOpen, query: view.searchOpen ? '' : view.query })}
|
||||
>{ICON_SEARCH}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.viewOptions')}
|
||||
title={t('menu.viewOptions')}
|
||||
onClick={() => { invalidatePickState(); setViewOptionsOpen((v) => !v); setAddOpen(false) }}
|
||||
>{ICON_VIEW_OPTIONS}</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_iconBtn"
|
||||
aria-label={t('menu.add')}
|
||||
title={t('menu.add')}
|
||||
onClick={() => {
|
||||
invalidatePickState()
|
||||
setAddOpen((v) => !v); setViewOptionsOpen(false)
|
||||
// 父目录默认 = 当前会话工作目录(不落 C 盘默认位置)
|
||||
if (!wsFolderParent) {
|
||||
const cwd = sessionScopeStore.snapshot?.cwd ?? ''
|
||||
if (cwd) setWsFolderParent(cwd)
|
||||
}
|
||||
}}
|
||||
>{ICON_ADD}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.searchOpen && (
|
||||
<div className="dsh-wt_search">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={view.query}
|
||||
onChange={(e) => persistView({ query: e.target.value })}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
/>
|
||||
<button type="button" className="dsh-wt_searchClear" aria-label={t('search.close')}
|
||||
onClick={() => persistView({ searchOpen: false, query: '' })}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{popupLayer}
|
||||
<div className="dsh-wt_projects" data-managing={viewOptionsOpen ? 'true' : undefined}>
|
||||
{/* 「工作台」控制室项目:固定首位、不可删除;未绑定点开 = 强制绑定弹窗 */}
|
||||
<button
|
||||
|
||||
@@ -96,7 +96,7 @@ export const zh = {
|
||||
'custom.groupNew': '+ 新建一个分组…',
|
||||
'custom.groupNewParent': '分组父目录',
|
||||
'custom.groupNewName': '新分组名',
|
||||
'custom.groupNewParentPh': '例如 E:\\AI_Workspace\\DeepseekHarness',
|
||||
'custom.groupNewParentPh': '例如 C:\\Projects',
|
||||
'custom.groupNewNamePh': '例如 MyProject(将新建此文件夹并注册为分组)',
|
||||
'custom.groupNeedPath': '请填写分组父目录和新分组名',
|
||||
'bind.title': '绑定对话',
|
||||
@@ -174,7 +174,7 @@ export const zh = {
|
||||
'console.groupNew': '新建分组',
|
||||
'console.chooseGroup': '选择分组…',
|
||||
'console.blankNeedsGroup': '当前 DSH 的未分组空对话无法输入,请选择分组或加入现有对话。',
|
||||
'console.newParentPh': '分组父目录,例如 E:\\AI_Workspace',
|
||||
'console.newParentPh': '分组父目录,例如 C:\\Projects',
|
||||
'console.newNamePh': '分组文件夹名(将创建并注册)',
|
||||
'console.createBind': '新建并绑定',
|
||||
'console.bindFail': '创建失败,请重试',
|
||||
@@ -334,7 +334,7 @@ export const en = {
|
||||
'custom.groupNew': '+ Create a new group…',
|
||||
'custom.groupNewParent': 'Group parent folder',
|
||||
'custom.groupNewName': 'New group name',
|
||||
'custom.groupNewParentPh': 'e.g. E:\\AI_Workspace\\DeepseekHarness',
|
||||
'custom.groupNewParentPh': 'e.g. C:\\Projects',
|
||||
'custom.groupNewNamePh': 'e.g. MyProject (folder will be created and registered)',
|
||||
'custom.groupNeedPath': 'Fill in the parent folder and new group name',
|
||||
'bind.title': 'Bind conversation',
|
||||
@@ -412,7 +412,7 @@ export const en = {
|
||||
'console.groupNew': 'New group',
|
||||
'console.chooseGroup': 'Choose a group…',
|
||||
'console.blankNeedsGroup': 'Blank ungrouped chats cannot accept input in this DSH. Choose a group or join an existing chat.',
|
||||
'console.newParentPh': 'Group parent folder, e.g. E:\\AI_Workspace',
|
||||
'console.newParentPh': 'Group parent folder, e.g. C:\\Projects',
|
||||
'console.newNamePh': 'Group folder name (created & registered)',
|
||||
'console.createBind': 'Create & bind',
|
||||
'console.bindFail': 'Creation failed, try again',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { parentPathOf } from './pathutil'
|
||||
import { hostWebSocketUrl, worktableUpgrade } from './hostTransport'
|
||||
import { SandboxedFrame } from './SandboxedFrame'
|
||||
import { CHANGELOG_V030 } from './changelog'
|
||||
import { LOCAL_VERSION, checkUpdate, getAutoCheck, readCache, setAutoCheck as storeAutoCheck, setSkipVersion, type UpdateInfo, type UpdateStatus } from './updateCheck'
|
||||
import { Terminal } from 'xterm'
|
||||
@@ -476,7 +477,7 @@ function boxPayload(x0: number, y0: number, x1: number, y1: number): { primary:
|
||||
const r = iframe.getBoundingClientRect()
|
||||
if (r.width <= 0 || r.height <= 0) continue
|
||||
if (cx < r.left || cx > r.right || cy < r.top || cy > r.bottom) continue
|
||||
src = iframe.getAttribute('src') ?? ''
|
||||
src = iframe.getAttribute('data-source-url') ?? iframe.getAttribute('src') ?? ''
|
||||
let inner: Document | null = null
|
||||
try { inner = iframe.contentDocument } catch { inner = null }
|
||||
if (inner) readDoc(inner, r.left, r.top)
|
||||
@@ -939,6 +940,8 @@ export const splitStore: SplitState = {
|
||||
this.savedMarginLeft = viewArea.style.marginLeft
|
||||
this.savedMarginRight = viewArea.style.marginRight
|
||||
this.savedMarginTop = viewArea.style.marginTop
|
||||
this.yieldObserver?.disconnect()
|
||||
this.yieldObserver?.observe(viewArea, { attributes: true, attributeFilter: ['style'] })
|
||||
}
|
||||
this.observer?.disconnect()
|
||||
this.observer.observe(next)
|
||||
@@ -988,6 +991,9 @@ export const splitStore: SplitState = {
|
||||
viewArea.style.marginLeft = this.lastMarginLeft
|
||||
viewArea.style.marginRight = this.lastMarginRight
|
||||
viewArea.style.marginTop = mt
|
||||
this.lastMarginLeft = viewArea.style.marginLeft
|
||||
this.lastMarginRight = viewArea.style.marginRight
|
||||
this.lastMarginTop = viewArea.style.marginTop
|
||||
},
|
||||
|
||||
setChatW(w) {
|
||||
@@ -1429,14 +1435,14 @@ function BrowserPane(props: { row: PaneRow; index: number; tabId: string; conten
|
||||
/>
|
||||
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
|
||||
</div>
|
||||
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="browser" />
|
||||
<SandboxedFrame url={src} title="browser" reloadKey={props.reloadKey} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** iframe 内容标签(网页/站点产物):刷新统一在标签栏最左(重挂载整页刷新,跨域可靠) */
|
||||
function IframePane(props: { url: string; title?: string; reloadKey: number }) {
|
||||
return <iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={props.url} title={props.title ?? ''} />
|
||||
return <SandboxedFrame {...props} />
|
||||
}
|
||||
|
||||
/** 动画播放窗:iframe 壳 + 地址栏(站内自带项目/场景列表、播放、画幅切换、导出等全部控件) */
|
||||
@@ -1464,7 +1470,7 @@ function AnimPane(props: { row: PaneRow; index: number; tabId: string; content:
|
||||
/>
|
||||
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
|
||||
</div>
|
||||
<iframe key={props.reloadKey} className="dsh-wt_paneFrame" src={src} title="anim" />
|
||||
<SandboxedFrame url={src} title="anim" reloadKey={props.reloadKey} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+101
-21
@@ -6,6 +6,8 @@ import { dirname, resolve as pathResolve, sep } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { homedir } from 'node:os'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { requestRejection, rejectionText } from './requestAuth'
|
||||
import { createPreviewFiles, PREVIEW_PREFIX, PREVIEW_CSP } from './previewFiles'
|
||||
|
||||
/**
|
||||
* 基础数据目录解析:不加载任何官方包(loadPkg 的兜底只能用它,禁止反向调用包加载函数——否则成环)。
|
||||
@@ -149,7 +151,13 @@ function json(res: any, status: number, body: unknown) {
|
||||
|
||||
async function readJsonBody(req: any): Promise<any> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
|
||||
let bytes = 0
|
||||
for await (const chunk of req) {
|
||||
const data = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
||||
bytes += data.length
|
||||
if (bytes > 22 * 1024 * 1024) throw Object.assign(new Error('request too large'), { status: 413 })
|
||||
chunks.push(data)
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
if (!text) return {}
|
||||
try { return JSON.parse(text) } catch { return {} }
|
||||
@@ -207,6 +215,15 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
if (!WebSocketServer) return
|
||||
const pty = ptyMod.default ?? ptyMod
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
let disposed = false
|
||||
const terminals = new Set<any>()
|
||||
ctx.effect(() => () => {
|
||||
disposed = true
|
||||
for (const ws of wss.clients ?? []) { try { ws.terminate() } catch {} }
|
||||
for (const term of terminals) { try { term.kill() } catch {} }
|
||||
terminals.clear()
|
||||
try { wss.close() } catch {}
|
||||
}, 'dsh-worktable: terminal sessions')
|
||||
const spawnShell = (): { cmd: string; args: string[] } =>
|
||||
process.platform === 'win32'
|
||||
? { cmd: 'powershell.exe', args: ['-NoLogo', '-NoProfile'] } // -NoProfile:跳过用户配置(oh-my-posh 花哨提示符在 xterm 里是乱码,PSReadLine 长输入行不换行被截断)
|
||||
@@ -215,7 +232,13 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
|
||||
ctx.effect(() => webServer.registerUpgrade({
|
||||
path: '/api/worktable/term',
|
||||
handler: (req: any, socket: any, head: any) => {
|
||||
handler: async (req: any, socket: any, head: any) => {
|
||||
let rejected = await requestRejection(ctx, req)
|
||||
if (disposed) rejected = 503
|
||||
if (rejected !== undefined) {
|
||||
socket.end(`HTTP/1.1 ${rejected} ${rejectionText(rejected)}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||
return
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws: any) => {
|
||||
const u = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const cwd = serverCwd(ctx, u.searchParams.get('sessionId') || undefined, u.searchParams.get('cwd') || undefined)
|
||||
@@ -225,13 +248,14 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
try {
|
||||
const shell = spawnShell()
|
||||
term = pty.spawn(shell.cmd, shell.args, { name: 'xterm-256color', cols, rows, cwd, env: process.env })
|
||||
terminals.add(term)
|
||||
} catch (err) {
|
||||
try { ws.send('\r\n[worktable] 终端启动失败:' + String(err)) } catch {}
|
||||
try { ws.close() } catch {}
|
||||
return
|
||||
}
|
||||
term.onData((d: string) => { try { ws.send(d) } catch {} })
|
||||
term.onExit(() => { try { ws.close() } catch {} })
|
||||
term.onExit(() => { terminals.delete(term); try { ws.close() } catch {} })
|
||||
ws.on('message', (raw: any) => {
|
||||
const text = String(raw)
|
||||
try {
|
||||
@@ -243,7 +267,7 @@ function setupTerminal(webServer: any, ctx: any) {
|
||||
} catch {}
|
||||
try { term.write(text) } catch {}
|
||||
})
|
||||
ws.on('close', () => { try { term.kill() } catch {} })
|
||||
ws.on('close', () => { terminals.delete(term); try { term.kill() } catch {} })
|
||||
})
|
||||
},
|
||||
}), 'dsh-worktable: terminal upgrade')
|
||||
@@ -256,7 +280,57 @@ export function apply(ctx: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
webServer.register({
|
||||
const previews = createPreviewFiles()
|
||||
ctx.effect(() => () => previews.clear(), 'dsh-worktable: preview capabilities')
|
||||
// Direct WebServer routes do NOT inherit the host's /api authentication route.
|
||||
const register = (route: any, publicAsset = false) => ctx.effect(() => webServer.register({
|
||||
...route,
|
||||
handler: async (req: any, res: any) => {
|
||||
if (!publicAsset && route.path !== HEALTH_PATH) {
|
||||
const rejected = await requestRejection(ctx, req)
|
||||
if (rejected !== undefined) { json(res, rejected, { error: rejectionText(rejected) }); return }
|
||||
}
|
||||
return route.handler(req, res)
|
||||
},
|
||||
}), 'dsh-worktable: ' + route.path)
|
||||
|
||||
register({
|
||||
kind: 'exact', path: '/api/worktable/preview',
|
||||
handler: async (req: any, res: any) => {
|
||||
if (req.method !== 'POST') { json(res, 405, { error: 'method not allowed' }); return }
|
||||
try {
|
||||
const body = await readJsonBody(req)
|
||||
if (typeof body.revoke === 'string') { previews.revoke(body.revoke); json(res, 200, { ok: true }); return }
|
||||
if (typeof body.root !== 'string') { json(res, 400, { error: 'missing root' }); return }
|
||||
const token = await previews.mount(body.root)
|
||||
json(res, 200, { token, base: PREVIEW_PREFIX + '/' + token + '/' })
|
||||
} catch (err: any) { json(res, err.status ?? 400, { error: String(err) }) }
|
||||
},
|
||||
})
|
||||
|
||||
register({
|
||||
kind: 'prefix', path: PREVIEW_PREFIX,
|
||||
handler: async (req: any, res: any) => {
|
||||
// A sandboxed document has an opaque origin. It can read only this capability's
|
||||
// static directory, without credentials; it cannot pass the host API admission.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') { json(res, 405, { error: 'method not allowed' }); return }
|
||||
try {
|
||||
const parts = new URL(req.url ?? '/', 'http://dsh.internal').pathname.slice(PREVIEW_PREFIX.length + 1).split('/').map(decodeURIComponent)
|
||||
const file = await previews.file(parts.shift() ?? '', parts.filter(Boolean), req.method === 'HEAD')
|
||||
if (!file) { json(res, 404, { error: 'preview unavailable; refresh to reopen' }); return }
|
||||
const ext = (file.path.split('.').pop() || '').toLowerCase()
|
||||
res.writeHead(200, {
|
||||
'content-type': FILE_TYPES[ext] ?? 'application/octet-stream',
|
||||
'cache-control': 'no-store', 'referrer-policy': 'no-referrer',
|
||||
'content-security-policy': PREVIEW_CSP, 'x-content-type-options': 'nosniff',
|
||||
'access-control-allow-origin': '*',
|
||||
})
|
||||
res.end(file.data)
|
||||
} catch (err: any) { json(res, err?.status === 413 ? 413 : 404, { error: err?.status === 413 ? 'file too large' : 'preview unavailable' }) }
|
||||
},
|
||||
}, true)
|
||||
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: HEALTH_PATH,
|
||||
handler: (_req: any, res: any) => {
|
||||
@@ -265,7 +339,7 @@ export function apply(ctx: Context) {
|
||||
})
|
||||
|
||||
// 本地文件读取(资源管理器点击 .html 后浏览器标签内打开)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/file',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -284,7 +358,10 @@ export function apply(ctx: Context) {
|
||||
json: 'application/json; charset=utf-8', md: 'text/markdown; charset=utf-8', markdown: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8', log: 'text/plain; charset=utf-8',
|
||||
pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon',
|
||||
}
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store',
|
||||
'x-content-type-options': 'nosniff',
|
||||
...(/^(html?|svg)$/.test(ext) ? { 'content-security-policy': PREVIEW_CSP, 'referrer-policy': 'no-referrer' } : {}),
|
||||
})
|
||||
res.end(data)
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) })
|
||||
@@ -295,7 +372,7 @@ export function apply(ctx: Context) {
|
||||
// 本地站点(目录级静态托管):点开 index.html 时挂载整个所在目录,
|
||||
// 让 ./assets/... 等相对引用正常解析(前缀路由,余下路径 = <rootToken>/<相对路径>)。
|
||||
// 原生皮肤模板:HTML 骨架 + 设计系统样式表(随插件分发,主题自动适配)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'prefix',
|
||||
path: TEMPLATE_PREFIX,
|
||||
handler: (req: any, res: any) => {
|
||||
@@ -316,7 +393,7 @@ export function apply(ctx: Context) {
|
||||
},
|
||||
})
|
||||
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'prefix',
|
||||
path: SITE_PREFIX,
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -340,7 +417,8 @@ export function apply(ctx: Context) {
|
||||
if (info.size > 40 * 1024 * 1024) { json(res, 413, { error: 'file too large' }); return }
|
||||
const data = await readFile(abs)
|
||||
const ext = (abs.split('.').pop() || '').toLowerCase()
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
|
||||
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store',
|
||||
'content-security-policy': PREVIEW_CSP, 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff' })
|
||||
res.end(data)
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) })
|
||||
@@ -348,7 +426,7 @@ export function apply(ctx: Context) {
|
||||
},
|
||||
})
|
||||
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/fs',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -359,7 +437,7 @@ export function apply(ctx: Context) {
|
||||
: serverCwd(ctx, body.sessionId, body.cwd)
|
||||
json(res, 200, await listDirectory(path))
|
||||
} catch (err) {
|
||||
json(res, 500, { path: '', entries: [], truncated: false, error: String(err) })
|
||||
json(res, (err as any).status ?? 500, { path: '', entries: [], truncated: false, error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -368,7 +446,7 @@ export function apply(ctx: Context) {
|
||||
// 优先走宿主正式服务 ctx.workspaceRegistry(0.1.1/0.1.2 均有,正确感知 DSH_HOME 与存储后端);
|
||||
// 不可用时回退按 resolveDshHomeSafe() 读 storages/workspace.json(只读)。
|
||||
// 返回结构是客户端契约,两种来源都映射成同一 shape。
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/workspaces',
|
||||
handler: async (_req: any, res: any) => {
|
||||
@@ -412,7 +490,7 @@ export function apply(ctx: Context) {
|
||||
})
|
||||
|
||||
// 本地文件写入(MD 编辑模式保存回磁盘)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/write',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -427,13 +505,13 @@ export function apply(ctx: Context) {
|
||||
await import('node:fs/promises').then((m) => m.writeFile(abs, content, 'utf8'))
|
||||
json(res, 200, { ok: true })
|
||||
} catch (err) {
|
||||
json(res, 500, { error: String(err) })
|
||||
json(res, (err as any).status ?? 500, { error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 新建分组:创建目录(仅当父目录已存在,避免递归误建深层垃圾目录)
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/mkdir',
|
||||
handler: async (req: any, res: any) => {
|
||||
@@ -449,18 +527,20 @@ export function apply(ctx: Context) {
|
||||
await fsx.mkdir(abs)
|
||||
json(res, 200, { ok: true, path: abs })
|
||||
} catch (err: any) {
|
||||
json(res, err?.code === 'EEXIST' ? 200 : 500, err?.code === 'EEXIST' ? { ok: true, exists: true } : { error: String(err) })
|
||||
json(res, err?.code === 'EEXIST' ? 200 : err.status ?? 500, err?.code === 'EEXIST' ? { ok: true, exists: true } : { error: String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
webServer.register({
|
||||
register({
|
||||
kind: 'exact',
|
||||
path: '/api/worktable/git',
|
||||
handler: async (req: any, res: any) => {
|
||||
const body = await readJsonBody(req)
|
||||
const cwd = serverCwd(ctx, body.sessionId, body.cwd)
|
||||
json(res, 200, await gitStatus(cwd))
|
||||
try {
|
||||
const body = await readJsonBody(req)
|
||||
const cwd = serverCwd(ctx, body.sessionId, body.cwd)
|
||||
json(res, 200, await gitStatus(cwd))
|
||||
} catch (err: any) { json(res, err.status ?? 500, { error: String(err) }) }
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { open, realpath, stat } from 'node:fs/promises'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
|
||||
export const PREVIEW_PREFIX = '/api/worktable/preview-static'
|
||||
export const PREVIEW_SANDBOX = 'allow-scripts allow-forms allow-downloads'
|
||||
export const PREVIEW_CSP = `sandbox ${PREVIEW_SANDBOX}; base-uri 'none'; object-src 'none'; frame-src 'none'`
|
||||
export const PREVIEW_MAX_BYTES = 40 * 1024 * 1024
|
||||
const IDLE_MS = 30 * 60 * 1000
|
||||
const MAX_MOUNTS = 128
|
||||
|
||||
function inside(root: string, file: string): boolean {
|
||||
const rel = relative(root, file)
|
||||
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + sep))
|
||||
}
|
||||
|
||||
/** Short-lived read-only capabilities. They grant one directory, never host APIs. */
|
||||
export function createPreviewFiles(now = () => Date.now()) {
|
||||
const mounts = new Map<string, { root: string; touched: number }>()
|
||||
const prune = () => { for (const [key, item] of mounts) if (now() - item.touched >= IDLE_MS) mounts.delete(key) }
|
||||
return {
|
||||
async mount(root: string) {
|
||||
if (!isAbsolute(root)) throw new Error('Preview root must be absolute')
|
||||
const canonical = await realpath(root)
|
||||
if (!(await stat(canonical)).isDirectory()) throw new Error('Preview root is not a directory')
|
||||
prune()
|
||||
if (mounts.size >= MAX_MOUNTS) throw new Error('Too many open previews; close an unused preview first')
|
||||
const token = randomBytes(24).toString('base64url')
|
||||
mounts.set(token, { root: canonical, touched: now() })
|
||||
return token
|
||||
},
|
||||
revoke(token: string) { mounts.delete(token) },
|
||||
clear() { mounts.clear() },
|
||||
async file(token: string, segments: string[], headOnly = false) {
|
||||
prune()
|
||||
const mount = mounts.get(token)
|
||||
if (!mount) return null
|
||||
// Decode exactly once before this call; forbid separators embedded in a segment.
|
||||
if (segments.some(s => s === '..' || s.includes('/') || s.includes('\\') || s.includes('\0'))) return null
|
||||
let target = resolve(mount.root, ...segments)
|
||||
if (!inside(mount.root, target)) return null
|
||||
let info = await stat(target).catch(() => null)
|
||||
if (info?.isDirectory()) target = resolve(target, 'index.html')
|
||||
const canonical = await realpath(target).catch(() => null)
|
||||
if (!canonical || !inside(mount.root, canonical)) return null
|
||||
// Keep the read bound to one handle. Recheck path/identity around the read
|
||||
// to reject ordinary replacement races; this is not an OS filesystem jail
|
||||
// against an adversarial local process coordinating ABA path swaps.
|
||||
const handle = await open(canonical, 'r').catch(() => null)
|
||||
if (!handle) return null
|
||||
try {
|
||||
const before = await handle.stat({ bigint: true })
|
||||
if (!before.isFile()) return null
|
||||
const sameFile = (other: typeof before) => other.isFile()
|
||||
&& other.dev === before.dev && other.ino === before.ino
|
||||
&& other.size === before.size && other.mtimeNs === before.mtimeNs
|
||||
&& other.ctimeNs === before.ctimeNs
|
||||
const stillValid = async () => {
|
||||
const current = await realpath(canonical)
|
||||
if (current !== canonical || !inside(mount.root, current)) return false
|
||||
const named = await stat(current, { bigint: true })
|
||||
const opened = await handle.stat({ bigint: true })
|
||||
return sameFile(named) && sameFile(opened)
|
||||
&& mounts.get(token) === mount && now() - mount.touched < IDLE_MS
|
||||
}
|
||||
const tooLarge = () => Object.assign(new Error('file too large'), { status: 413 })
|
||||
if (before.size > BigInt(PREVIEW_MAX_BYTES)) throw tooLarge()
|
||||
if (!(await stillValid())) return null
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
if (!headOnly) {
|
||||
// A file may grow after stat. Never allocate/read an unbounded body.
|
||||
while (total <= PREVIEW_MAX_BYTES) {
|
||||
const chunk = Buffer.alloc(Math.min(64 * 1024, PREVIEW_MAX_BYTES + 1 - total))
|
||||
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total)
|
||||
if (!bytesRead) break
|
||||
total += bytesRead
|
||||
if (total > PREVIEW_MAX_BYTES) throw tooLarge()
|
||||
chunks.push(chunk.subarray(0, bytesRead))
|
||||
}
|
||||
}
|
||||
if (!(await stillValid()) || (!headOnly && BigInt(total) !== before.size)) return null
|
||||
mount.touched = now()
|
||||
return { path: canonical, size: Number(before.size), data: headOnly ? undefined : Buffer.concat(chunks, total) }
|
||||
} catch (error: any) {
|
||||
if (error?.status === 413) throw error
|
||||
return null
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Use the host's current authentication service for every request, never a cached grant. */
|
||||
export async function requestRejection(ctx: any, req: any): Promise<number | undefined> {
|
||||
try {
|
||||
const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
|
||||
if (!connection) return 503
|
||||
if (typeof connection.admit === 'function') {
|
||||
const admission = await connection.admit(req)
|
||||
if (!admission || typeof admission !== 'object') return 503
|
||||
if ('rejection' in admission) return admission.rejection === 401 ? 401 : 403
|
||||
const peer = admission.peer
|
||||
// 0.2.1 has owner/non-owner principals. 0.2.0 has a single operator Peer.
|
||||
if (peer?.principal?.kind === 'owner') return undefined
|
||||
if (peer && peer === connection.operator && peer.principal === undefined) return undefined
|
||||
return 403
|
||||
}
|
||||
} catch {}
|
||||
// An unavailable or failed security service must not turn into public filesystem access.
|
||||
return 503
|
||||
}
|
||||
|
||||
export function rejectionText(status: number): string {
|
||||
return status === 401 ? 'unauthorized' : status === 403 ? 'forbidden' : 'Host authentication unavailable'
|
||||
}
|
||||
Reference in New Issue
Block a user