Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
import { encodeHtmlUrl } from '../html-route.ts'
import { resolveSidebarPath } from './paths.ts'
import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type {
SidebarCreateTeamTaskRequest,
Expand Down Expand Up @@ -482,7 +483,7 @@ export function archiveDownloadUrl(scope: SessionScope, id: string): string {

/** Shared URL builder for the /sidebar/file route (media vs download). */
function fileUrl(scope: SessionScope, path: string, download: boolean): string {
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
const params = new URLSearchParams({ sessionId: scope.sessionId, path: resolveSidebarPath(scope.cwd, path) })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
if (download) params.set('download', '1')
return `/sidebar/file?${params.toString()}`
Expand All @@ -497,5 +498,5 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
* client-side platform signal is needed.
*/
export function htmlUrl(scope: SessionScope, path: string): string {
return encodeHtmlUrl(scope.sessionId, path)
return encodeHtmlUrl(scope.sessionId, resolveSidebarPath(scope.cwd, path))
}
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const raw = url.searchParams.get('path')
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const path = await ensureWorkspacePath(cwd, raw)
// Only relative requests need a workspace base. Absolute paths may
// intentionally point outside the workspace and must stay unchanged.
const target = isAbsolute(raw) ? raw : join(cwd, raw)
const path = await ensureWorkspacePath(cwd, target)
const info = await stat(path)
if (!info.isFile() || info.size > resolved.mediaLimit) {
throw new SidebarError('fs-error', 'not a file or too large', 400)
Expand Down
34 changes: 33 additions & 1 deletion tests/paths.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import { decodeHtmlUrl } from '../src/html-route.ts'
import { isAbsolutePath, relativeTo } from '../src/client/paths.ts'
import { resolveSidebarPath } from '../src/client/paths.ts'
import { htmlUrl } from '../src/client/api.ts'
import { downloadUrl, htmlUrl, mediaUrl } from '../src/client/api.ts'

describe('path helpers', () => {
it('derives relative paths under the cwd (and "." for the cwd itself)', () => {
Expand Down Expand Up @@ -67,3 +68,34 @@ describe('path helpers', () => {
.toBe('/sidebar/html/s/home/me/index.html')
})
})

describe('preview URL path resolution', () => {
it.each([
['/home/project', 'pages/report.html', '/home/project/pages/report.html'],
['/home/project/', '报告 #1.html', '/home/project/报告 #1.html'],
['C:\\work\\project', 'pages/report.html', 'C:/work/project/pages/report.html'],
['\\\\server\\share\\project', 'report.html', '//server/share/project/report.html'],
['/home/project', '/tmp/report.html', '/tmp/report.html'],
])('resolves %s + %s before encoding', (cwd, path, expected) => {
const scope = { sessionId: 'session #1', cwd }
const decoded = decodeHtmlUrl(new URL(htmlUrl(scope, path), 'http://localhost').pathname)
expect(decoded).toEqual({ ok: true, ref: { sessionId: scope.sessionId, path: expected } })
for (const build of [mediaUrl, downloadUrl]) {
const url = new URL(build(scope, path), 'http://localhost')
expect(url.searchParams.get('path')?.replace(/\\/g, '/')).toBe(expected)
expect(url.searchParams.get('cwd')).toBe(cwd)
expect(url.searchParams.get('download')).toBe(build === downloadUrl ? '1' : null)
}
})

it('keeps nested relative assets in the same session and document directory', () => {
const page = new URL(htmlUrl({ sessionId: 's', cwd: '/work/project' }, 'pages/report.html'), 'http://localhost')
for (const [asset, expected] of [['./style.css', '/work/project/pages/style.css'], ['../img/pic.png', '/work/project/img/pic.png']] as const) {
expect(decodeHtmlUrl(new URL(asset, page).pathname)).toEqual({ ok: true, ref: { sessionId: 's', path: expected } })
}
})

it('leaves relative file queries for server resolution when cwd is unavailable', () => {
expect(new URL(mediaUrl({ sessionId: 's' }, 'pic.png'), 'http://localhost').searchParams.get('path')).toBe('pic.png')
})
})
43 changes: 42 additions & 1 deletion tests/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve as resolvePath } from 'node:path'
import { dirname, join, resolve as resolvePath } from 'node:path'
import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply, FS_TREES_MAX_PATHS, mediaTypeForPath } from '../src/index.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
import { encodeHtmlUrl } from '../src/html-route.ts'
import { downloadUrl, htmlUrl } from '../src/client/api.ts'
import * as git from '../src/git.ts'
import { listDirectory } from '../src/fs-tree.ts'
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
Expand Down Expand Up @@ -543,6 +544,46 @@ describe('session cwd resolution over the API route', () => {
}
})

it('serves relative previews and assets without rewriting external absolute paths', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-preview-paths-'))
const workspace = join(root, 'workspace')
const outside = join(root, 'outside')
mkdirSync(join(workspace, 'pages'), { recursive: true })
mkdirSync(outside)
writeFileSync(join(workspace, 'pages', 'report.html'), '<link rel="stylesheet" href="./style.css"><p>workspace</p>')
writeFileSync(join(workspace, 'pages', 'style.css'), 'body { color: red; }')
const external = join(outside, 'report.html')
writeFileSync(external, '<p>external</p>')
// A shadow at the old fallback destination must never win.
const shadow = join(workspace, external.replace(/^[\\/]+/, ''))
if (process.platform !== 'win32') {
mkdirSync(dirname(shadow), { recursive: true })
writeFileSync(shadow, '<p>wrong shadow</p>')
}
try {
const routes = mountAll({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
const html = routes.find(route => route.path === '/sidebar/html')!
const file = routes.find(route => route.path === '/sidebar/file')!
const scope = { sessionId: 'preview', cwd: workspace }
const page = htmlUrl(scope, 'pages/report.html')
expect(await invokeGet(html, page)).toMatchObject({ status: 200, body: expect.stringContaining('workspace') })
const asset = new URL('./style.css', new URL(page, 'http://localhost')).pathname
expect(await invokeGet(html, asset)).toMatchObject({ status: 200, body: 'body { color: red; }' })
// Missing cwd exercises the server's relative-only fallback.
expect(await invokeGet(file, downloadUrl({ sessionId: scope.sessionId }, 'pages/report.html')))
.toMatchObject({ status: 200, body: expect.stringContaining('workspace') })
expect(await invokeGet(html, htmlUrl(scope, external)))
.toMatchObject({ status: 200, body: '<p>external</p>' })
expect(await invokeGet(file, downloadUrl(scope, external)))
.toMatchObject({ status: 200, body: '<p>external</p>' })
rmSync(external)
expect((await invokeGet(html, htmlUrl(scope, external))).status).toBe(500)
expect((await invokeGet(file, downloadUrl(scope, external))).status).toBe(500)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

it('serves media and HTML through a workspace symlink (fence removed)', async () => {
if (!canCreateSymlink) return
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-route-symlink-security-'))
Expand Down