Repository navigation
refactor(server): move the file routes and types into their own slice #1696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { afterAll, beforeAll, describe, expect, it, spyOn } from "bun:test"; | ||
| import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
|
|
||
| import * as herdr from "./herdr/client.ts"; | ||
| import { HerdrError } from "./herdr/client.ts"; | ||
| import { createServer } from "./index.ts"; | ||
|
|
||
| describe("file routes", () => { | ||
| let server: ReturnType<typeof createServer>; | ||
| let stateDir: string; | ||
| let folder: string; | ||
| let notes: string; | ||
| const url = (path: string) => `http://127.0.0.1:${server.port}${path}`; | ||
| const q = (value: string) => encodeURIComponent(value); | ||
| const error = async (response: Response) => [response.status, ((await response.json()) as { error: { code: string } }).error.code]; | ||
|
|
||
| beforeAll(() => { | ||
| stateDir = mkdtempSync(join(tmpdir(), "ovis-file-routes-")); | ||
| folder = join(stateDir, "project"); | ||
| mkdirSync(join(folder, "public"), { recursive: true }); | ||
| mkdirSync(join(folder, "docs")); | ||
| notes = join(folder, "notes.txt"); | ||
| writeFileSync(notes, "hello"); | ||
| writeFileSync(join(folder, "public", "favicon.ico"), new Uint8Array([0, 0, 1, 0, 1, 0, 16, 16])); | ||
| server = createServer({ port: 0, hostname: "127.0.0.1", stateDir, token: "", machines: false, sidecar: false }); | ||
| }); | ||
| afterAll(() => { | ||
| server.stop(); | ||
| rmSync(stateDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it("wants GET or HEAD, and no other site's page", async () => { | ||
| expect(await error(await fetch(url(`/api/fs/stat?path=${q(notes)}`), { method: "POST", headers: { origin: url("") } }))).toEqual([400, "method_not_allowed"]); | ||
| expect(await error(await fetch(url(`/api/fs/file?path=${q(notes)}`), { headers: { "sec-fetch-site": "cross-site", "sec-fetch-mode": "no-cors" } }))).toEqual([403, "cross_site"]); | ||
| expect((await fetch(url(`/api/fs/file?path=${q(notes)}`), { headers: { "sec-fetch-site": "cross-site", "sec-fetch-mode": "navigate" } })).status).toBe(200); | ||
| expect((await fetch(url(`/api/fs/file?path=${q(notes)}`), { method: "HEAD" })).status).toBe(200); | ||
| }); | ||
|
|
||
| it("describes and serves a file by its absolute path, and says when there is none", async () => { | ||
| const stat = await fetch(url(`/api/fs/stat?path=${q(notes)}`)); | ||
| expect(stat.status).toBe(200); | ||
| expect(await stat.json()).toMatchObject({ name: "notes.txt", kind: "text", size: 5 }); | ||
| const file = await fetch(url(`/api/fs/file?path=${q(notes)}`)); | ||
| expect(await file.text()).toBe("hello"); | ||
| expect(file.headers.get("content-disposition")).toStartWith("inline;"); | ||
| expect((await fetch(url(`/api/fs/file?path=${q(notes)}&download=1`))).headers.get("content-disposition")).toStartWith("attachment;"); | ||
| expect(await error(await fetch(url(`/api/fs/stat?path=${q(join(folder, "missing.txt"))}`)))).toEqual([404, "not_found"]); | ||
| }); | ||
|
|
||
| it("finds a relative path in the named pane's folder, and none when herdr cannot say it", async () => { | ||
| const get = spyOn(herdr, "paneGet").mockImplementation(async (paneId) => ({ pane_id: paneId, workspace_id: "w1", tab_id: "w1:t1", cwd: folder, agent_status: "idle" }) as unknown as Awaited<ReturnType<typeof herdr.paneGet>>); | ||
| try { | ||
| const found = await fetch(url("/api/fs/stat?pane_id=w1:p1&path=notes.txt")); | ||
| expect(found.status).toBe(200); | ||
| expect(await found.json()).toMatchObject({ name: "notes.txt", size: 5 }); | ||
| get.mockImplementation(async (paneId) => { throw new HerdrError("pane_not_found", `pane ${paneId} not found`); }); | ||
| expect(await error(await fetch(url("/api/fs/stat?pane_id=w1:p1&path=notes.txt")))).toEqual([404, "not_found"]); | ||
| expect((await fetch(url(`/api/fs/stat?pane_id=w1:p1&path=${q(notes)}`))).status).toBe(200); | ||
| } finally { | ||
| get.mockRestore(); | ||
| } | ||
| }); | ||
|
|
||
| it("answers a folder's project icon, or none", async () => { | ||
| expect(await error(await fetch(url("/api/fs/favicon")))).toEqual([400, "missing_root"]); | ||
| expect(await (await fetch(url(`/api/fs/favicon?root=${q(folder)}`))).json()).toMatchObject({ icon: { name: "favicon.ico", kind: "image" } }); | ||
| expect(await (await fetch(url(`/api/fs/favicon?root=${q(join(folder, "docs"))}`))).json()).toEqual({ icon: null }); | ||
| }); | ||
|
|
||
| it("plays no page media it never gave out", async () => { | ||
| expect(await error(await fetch(url("/api/fs/page-media/not-a-token")))).toEqual([404, "not_found"]); | ||
| expect(await error(await fetch(url("/api/fs/page-media/not-a-token"), { method: "POST", headers: { origin: url("") } }))).toEqual([400, "method_not_allowed"]); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| /** | ||
| * The file routes: what a file is and its bytes (GET/HEAD /api/fs/stat, /api/fs/file), a folder's | ||
| * project icon (/api/fs/favicon) and a rendered page's own media (/api/fs/page-media/:media_id). | ||
| * The dispatcher in index.ts runs the access gate first and hands over who asks: a page's media | ||
| * plays only for the client and device that rendered it (file-view.ts). | ||
| */ | ||
| import type { ProjectFavicon } from "../shared/files.ts"; | ||
| import type { Access } from "./access.ts"; | ||
| import { isSecureRequest } from "./auth.ts"; | ||
| import { claudeSessionTempDir } from "./claude-store.ts"; | ||
| import { claudePaneSession } from "./conversation.ts"; | ||
| import { fileResponse, findFile, crossSiteFileRead, pageMediaResponse, pageMediaSource, PAGE_MEDIA_PATH } from "./file-view.ts"; | ||
| import { paneGet } from "./herdr/client.ts"; | ||
| import { badRequest, jsonResponse, setRequestRoute } from "./http.ts"; | ||
| import { paneContext, resolveAgent } from "./pane-context.ts"; | ||
| import { findProjectFavicon } from "./project-favicon.ts"; | ||
|
|
||
| /** Who asks, as the dispatcher's access gate worked it out. */ | ||
| export interface FileRequester { bridgeAuthorized: boolean; requestHost: string | null; forwarded: boolean; client: string | null; access: Access } | ||
|
|
||
| /** The temp folder of the Claude Code session in a pane, or null when the pane runs none or it has none. */ | ||
| async function claudeTempFolder(paneId: string): Promise<string | null> { | ||
| try { | ||
| const pane = await paneGet(paneId); | ||
| if (resolveAgent(pane).agent !== "claude") return null; | ||
| const session = await claudePaneSession(pane); | ||
| return session === null ? null : await claudeSessionTempDir(session.path); | ||
| } catch { return null; /* herdr busy or the pane gone: the path stays not found */ } | ||
| } | ||
|
|
||
| /** The answer for a file route, or null when the path is not one. */ | ||
| export async function handleFileRequest(request: Request, url: URL, pathname: string, { bridgeAuthorized, requestHost, forwarded, client, access }: FileRequester): Promise<Response | null> { | ||
| if (pathname === "/api/fs/stat" || pathname === "/api/fs/file" || pathname === "/api/fs/favicon") { | ||
| setRequestRoute(pathname); | ||
| if (request.method !== "GET" && request.method !== "HEAD") return badRequest("method_not_allowed", "use GET"); | ||
| if (crossSiteFileRead(request)) return jsonResponse({ error: { code: "cross_site", message: "A file is read from this app or opened in a tab, not loaded by another page" } }, 403); | ||
| // a folder's project icon: every place it may be kept, in one answer that a miss is part of | ||
| if (pathname === "/api/fs/favicon") { | ||
| const root = url.searchParams.get("root") ?? ""; | ||
| if (root.trim() === "") return badRequest("missing_root", "root is required"); | ||
| return jsonResponse({ icon: await findProjectFavicon(root) } satisfies ProjectFavicon); | ||
| } | ||
| const paneId = url.searchParams.get("pane_id"); | ||
| let cwd: string | null = null; | ||
| if (paneId) { | ||
| try { cwd = (await paneContext(paneId)).cwd; } catch { /* an absolute path still opens */ } | ||
| } | ||
| const path = url.searchParams.get("path") ?? ""; | ||
| const found = await findFile(path, cwd, () => paneId ? claudeTempFolder(paneId) : Promise.resolve(null)); | ||
| if (found === null) return jsonResponse({ error: { code: "not_found", message: "no readable file at that path" } }, 404); | ||
| // several files end in that name: the viewer lists them to choose from | ||
| if ("candidates" in found) return jsonResponse({ error: { code: "ambiguous_path", message: "several files have that name", candidates: found.candidates } }, 409); | ||
| // a page another PC's server asks for through its bridge is played from that server's address, which this one cannot name: it is drawn as before | ||
| const mediaSource = bridgeAuthorized ? null : pageMediaSource(requestHost, isSecureRequest(request), forwarded ? request.headers.get("x-forwarded-host") : null); | ||
| // its media plays only for the client address that rendered it, and ends with that device (file-view.ts) | ||
| const media = mediaSource === null ? null : { source: mediaSource, client, device: access.level === "full" ? access.device?.id ?? null : null }; | ||
| return pathname === "/api/fs/stat" ? jsonResponse(found.info) : fileResponse(found.info, url.searchParams.get("download") === "1", url.searchParams.get("render") === "1", media); | ||
| } | ||
|
|
||
| // a rendered page's own video, by the address its render gave it: the page has no origin and | ||
| // no cookie, so the address is all it holds (requiresAuth lets this path through) | ||
| if (pathname.startsWith(PAGE_MEDIA_PATH)) { | ||
| setRequestRoute("/api/fs/page-media/:media_id"); | ||
| if (request.method !== "GET" && request.method !== "HEAD") return badRequest("method_not_allowed", "use GET"); | ||
| const media = pageMediaResponse(pathname.slice(PAGE_MEDIA_PATH.length), client); | ||
| return media ?? jsonResponse({ error: { code: "not_found", message: "no such page media" } }, 404); | ||
| } | ||
| return null; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| /** How the file viewer shows a file. */ | ||
| export type FileKind = "image" | "video" | "audio" | "pdf" | "text" | "binary"; | ||
|
|
||
| /** | ||
| * GET /api/fs/stat: a file the viewer can open (GET /api/fs/file streams it). An HTML page is | ||
| * `text` with `mime` "text/html": `/api/fs/file` serves it as plain text, and as the page itself | ||
| * only with `render=1`, in a sandbox with no origin and no network of its own. | ||
| */ | ||
| export interface FileInfo { | ||
| path: string; | ||
| name: string; | ||
| size: number; | ||
| /** ISO time of the last change */ | ||
| modified: string; | ||
| mime: string; | ||
| kind: FileKind; | ||
| } | ||
|
|
||
| /** | ||
| * GET /api/fs/favicon?root=: the icon the project in folder `root` keeps, or null. The PC looks | ||
| * through every place a project keeps one (`FAVICON_CANDIDATES` in server/project-favicon.ts), | ||
| * best first, each read as /api/fs/stat reads `<root>/<place>`, and answers once: a folder with | ||
| * no icon is `{ icon: null }`, never a 404. 400 missing_root; the access rules of /api/fs/stat | ||
| * (no watching device, no other site's page). | ||
| */ | ||
| export interface ProjectFavicon { | ||
| /** the image found; `path` opens it through /api/fs/file */ | ||
| icon: FileInfo | null; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.