Skip to content
Merged
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
4 changes: 3 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createHealthRoutes } from './routes/health'
import { createTTSRoutes, cleanupExpiredCache } from './routes/tts';
import { createSTTRoutes } from './routes/stt'
import { createFileRoutes } from './routes/files'
import { createFilesystemRoutes } from './routes/filesystem'
import { createScheduleRoutes } from './routes/schedules'

async function getAppVersion(): Promise<string> {
Expand Down Expand Up @@ -357,7 +358,8 @@ protectedApi.use('/*', requireAuth)

protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeClient, openCodeSupervisor))
protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeClient, openCodeSupervisor))
protectedApi.route('/files', createFileRoutes())
protectedApi.route('/files', createFileRoutes())
protectedApi.route('/filesystem', createFilesystemRoutes())
protectedApi.route('/providers', createProvidersRoutes(db, openCodeClient, openCodeSupervisor))
protectedApi.route('/oauth', createOAuthRoutes(openCodeClient, openCodeSupervisor))
protectedApi.route('/tts', createTTSRoutes(db))
Expand Down
60 changes: 60 additions & 0 deletions backend/src/routes/filesystem.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Hono } from 'hono'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import { createFilesystemRoutes } from './filesystem'

let tmpRoot: string
let app: Hono
const originalBrowseRoot = process.env.REPO_BROWSE_ROOT

beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ocm-fs-route-'))
process.env.REPO_BROWSE_ROOT = tmpRoot
app = new Hono()
app.route('/filesystem', createFilesystemRoutes())
})

afterEach(async () => {
if (originalBrowseRoot === undefined) {
delete process.env.REPO_BROWSE_ROOT
} else {
process.env.REPO_BROWSE_ROOT = originalBrowseRoot
}
await fs.rm(tmpRoot, { recursive: true, force: true })
})

describe('GET /api/filesystem/browse', () => {
it('returns the directory listing for the root', async () => {
await fs.mkdir(path.join(tmpRoot, 'projects'))

const res = await app.request('/filesystem/browse')
expect(res.status).toBe(200)

const body = await res.json() as { isRoot: boolean; entries: { name: string }[] }
expect(body.isRoot).toBe(true)
expect(body.entries.map((e) => e.name)).toEqual(['projects'])
})

it('returns 403 for a path outside the root', async () => {
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'ocm-fs-outside-'))
try {
const res = await app.request(`/filesystem/browse?path=${encodeURIComponent(outside)}`)
expect(res.status).toBe(403)
} finally {
await fs.rm(outside, { recursive: true, force: true })
}
})

it('returns 404 for a missing directory', async () => {
const res = await app.request(`/filesystem/browse?path=${encodeURIComponent(path.join(tmpRoot, 'missing'))}`)
expect(res.status).toBe(404)
})

it('returns 501 when browsing is not configured', async () => {
delete process.env.REPO_BROWSE_ROOT
const res = await app.request('/filesystem/browse')
expect(res.status).toBe(501)
})
})
22 changes: 22 additions & 0 deletions backend/src/routes/filesystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Hono } from 'hono'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import * as filesystemService from '../services/filesystem'
import { logger } from '../utils/logger'
import { getErrorMessage, getStatusCode } from '../utils/error-utils'

export function createFilesystemRoutes() {
const app = new Hono()

app.get('/browse', async (c) => {
try {
const requestedPath = c.req.query('path')
const result = await filesystemService.browseDirectory(requestedPath)
return c.json(result)
} catch (error: unknown) {
logger.error('Failed to browse directory:', error)
return c.json({ error: getErrorMessage(error) || 'Failed to browse directory' }, getStatusCode(error) as ContentfulStatusCode)
}
})

return app
}
113 changes: 113 additions & 0 deletions backend/src/services/filesystem.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import { browseDirectory } from './filesystem'

let tmpRoot: string
const originalBrowseRoot = process.env.REPO_BROWSE_ROOT

beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ocm-browse-'))
process.env.REPO_BROWSE_ROOT = tmpRoot
})

afterEach(async () => {
if (originalBrowseRoot === undefined) {
delete process.env.REPO_BROWSE_ROOT
} else {
process.env.REPO_BROWSE_ROOT = originalBrowseRoot
}
await fs.rm(tmpRoot, { recursive: true, force: true })
})

describe('browseDirectory', () => {
it('lists only directories at the browse root and marks the root', async () => {
await fs.mkdir(path.join(tmpRoot, 'projects'))
await fs.mkdir(path.join(tmpRoot, 'archive'))
await fs.writeFile(path.join(tmpRoot, 'readme.txt'), 'ignored file')

const result = await browseDirectory()

expect(result.isRoot).toBe(true)
expect(result.parentPath).toBeNull()
expect(result.path).toBe(path.resolve(tmpRoot))
expect(result.entries.map((e) => e.name)).toEqual(['archive', 'projects'])
})

it('marks git repositories', async () => {
const repoDir = path.join(tmpRoot, 'my-repo')
await fs.mkdir(repoDir)
await fs.mkdir(path.join(repoDir, '.git'))
await fs.mkdir(path.join(tmpRoot, 'plain'))

const result = await browseDirectory()

const repo = result.entries.find((e) => e.name === 'my-repo')
const plain = result.entries.find((e) => e.name === 'plain')
expect(repo?.isGitRepo).toBe(true)
expect(plain?.isGitRepo).toBe(false)
})

it('ignores hidden directories', async () => {
await fs.mkdir(path.join(tmpRoot, '.hidden'))
await fs.mkdir(path.join(tmpRoot, 'visible'))

const result = await browseDirectory()

expect(result.entries.map((e) => e.name)).toEqual(['visible'])
})

it('navigates into a subdirectory and exposes the parent path', async () => {
const sub = path.join(tmpRoot, 'level1')
await fs.mkdir(sub)
await fs.mkdir(path.join(sub, 'level2'))

const result = await browseDirectory(sub)

expect(result.isRoot).toBe(false)
expect(result.parentPath).toBe(path.resolve(tmpRoot))
expect(result.entries.map((e) => e.name)).toEqual(['level2'])
})

it('rejects paths outside the browse root with 403', async () => {
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'ocm-outside-'))
try {
await expect(browseDirectory(outside)).rejects.toMatchObject({ statusCode: 403 })
} finally {
await fs.rm(outside, { recursive: true, force: true })
}
})

it('rejects traversal above the root with 403', async () => {
await expect(browseDirectory(path.join(tmpRoot, '..'))).rejects.toMatchObject({ statusCode: 403 })
})

it('rejects a symlink inside the root that escapes the root with 403', async () => {
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'ocm-outside-'))
try {
const linkPath = path.join(tmpRoot, 'escape-link')
await fs.symlink(outside, linkPath)

await expect(browseDirectory(linkPath)).rejects.toMatchObject({ statusCode: 403 })
} finally {
await fs.rm(outside, { recursive: true, force: true })
}
})

it('returns 404 for a non-existent directory', async () => {
await expect(browseDirectory(path.join(tmpRoot, 'nope'))).rejects.toMatchObject({ statusCode: 404 })
})

it('returns 400 when the path is a file', async () => {
const filePath = path.join(tmpRoot, 'file.txt')
await fs.writeFile(filePath, 'data')

await expect(browseDirectory(filePath)).rejects.toMatchObject({ statusCode: 400 })
})

it('returns 501 when REPO_BROWSE_ROOT is not configured', async () => {
delete process.env.REPO_BROWSE_ROOT
await expect(browseDirectory()).rejects.toMatchObject({ statusCode: 501 })
})
})
89 changes: 89 additions & 0 deletions backend/src/services/filesystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { getBrowseRootPath } from '@opencode-manager/shared/config/env'
import type { BrowseDirectoryResponse, DirectoryEntry } from '@opencode-manager/shared/types'

function getRoot(): string {
const configured = getBrowseRootPath()
if (!configured) {
throw {
message: 'Folder browsing is disabled. Set REPO_BROWSE_ROOT in the server environment to enable it.',
statusCode: 501,
}
}
return path.resolve(configured)
}

async function resolveWithinRoot(root: string, requestedPath?: string): Promise<string> {
const resolved = (!requestedPath || requestedPath.trim() === '') ? root : path.resolve(requestedPath)

const realRoot = await fs.realpath(root)
let realResolved: string
try {
realResolved = await fs.realpath(resolved)
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException | undefined)?.code
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw { message: 'Directory not found', statusCode: 404 }
}
throw err
}

const rel = path.relative(realRoot, realResolved)
if (rel === '..' || rel.startsWith(`..${path.sep}`)) {
throw { message: 'Path is outside the allowed browse root', statusCode: 403 }
}

return resolved
}

async function isGitRepo(entryPath: string): Promise<boolean> {
try {
const stats = await fs.lstat(path.join(entryPath, '.git'))
return stats.isDirectory() || stats.isFile()
} catch {
return false
}
}

export async function browseDirectory(requestedPath?: string): Promise<BrowseDirectoryResponse> {
const root = getRoot()
const targetPath = await resolveWithinRoot(root, requestedPath)

let stats
try {
stats = await fs.stat(targetPath)
} catch {
throw { message: 'Directory not found', statusCode: 404 }
}

if (!stats.isDirectory()) {
throw { message: 'Path is not a directory', statusCode: 400 }
}

const dirEntries = await fs.readdir(targetPath, { withFileTypes: true })
const directories = dirEntries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))

const entries: DirectoryEntry[] = await Promise.all(
directories.map(async (entry) => {
const entryPath = path.join(targetPath, entry.name)
return {
name: entry.name,
path: entryPath,
isGitRepo: await isGitRepo(entryPath),
}
})
)

entries.sort((a, b) => a.name.localeCompare(b.name))

const isRoot = targetPath === root
const parentPath = isRoot ? null : path.dirname(targetPath)

return {
path: targetPath,
parentPath,
isRoot,
entries,
}
}
9 changes: 9 additions & 0 deletions frontend/src/api/filesystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { fetchWrapper } from './fetchWrapper'
import { API_BASE_URL } from '@/config'
import type { BrowseDirectoryResponse } from '@opencode-manager/shared/types'

export async function browseDirectory(path?: string): Promise<BrowseDirectoryResponse> {
return fetchWrapper(`${API_BASE_URL}/api/filesystem/browse`, {
params: { path },
})
}
Loading