Archived
feat: add safe manual workspace uploads
This commit is contained in:
@@ -155,6 +155,33 @@ describe("buildApp", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
||||
});
|
||||
|
||||
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const remoteWorkspaces = [{
|
||||
id: "w1",
|
||||
projectId: "p1",
|
||||
path: "/repo",
|
||||
label: "main",
|
||||
isMain: true,
|
||||
isGitRepo: false,
|
||||
isGitWorktree: false,
|
||||
effectiveConfig: { uploads: { defaultFolder: "remote-project-uploads" } },
|
||||
}];
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify(remoteWorkspaces)]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual(remoteWorkspaces);
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces", undefined);
|
||||
});
|
||||
|
||||
it("preserves remote file preview security headers while proxying safe response metadata", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -181,6 +208,29 @@ describe("buildApp", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined);
|
||||
});
|
||||
|
||||
it("proxies remote workspace file writes as raw request bodies", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify({ path: "image.png", size: payload.length, modifiedAt: "now", created: true })]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file?path=${encodeURIComponent("image.png")}`,
|
||||
payload,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ path: "image.png", size: payload.length, modifiedAt: "now", created: true });
|
||||
expect(request).toHaveBeenCalledWith("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "application/octet-stream" });
|
||||
});
|
||||
|
||||
it("proxies remote terminal command-run and continue routes", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -465,6 +515,47 @@ describe("buildApp", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes the default upload config on workspace responses", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "Upload Defaults", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
|
||||
expect(workspacesResponse.statusCode).toBe(200);
|
||||
expect(workspacesResponse.json<Workspace[]>()).toEqual([
|
||||
expect.objectContaining({
|
||||
projectId: project.id,
|
||||
effectiveConfig: { uploads: { defaultFolder: ".pi-web/uploads" } },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets project-local upload config override global upload config on workspace responses", async () => {
|
||||
piWebConfig = { uploads: { defaultFolder: "global-uploads" } };
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/projects",
|
||||
payload: { name: "Project Upload Defaults", path: projectDir, create: true },
|
||||
});
|
||||
const project = addResponse.json<Project>();
|
||||
await mkdir(join(projectDir, ".pi-web"), { recursive: true });
|
||||
await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, uploads: { defaultFolder: "project-uploads" } }, null, 2)}\n`);
|
||||
|
||||
const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
|
||||
|
||||
expect(workspacesResponse.statusCode).toBe(200);
|
||||
expect(workspacesResponse.json<Workspace[]>()).toEqual([
|
||||
expect.objectContaining({
|
||||
projectId: project.id,
|
||||
effectiveConfig: { uploads: { defaultFolder: "project-uploads" } },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("serves supported workspace images as previews", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
+23
-4
@@ -9,6 +9,7 @@ import { ProjectService } from "./projects/projectService.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { pathAccessForCwd } from "./workspaces/effectivePathAccess.js";
|
||||
import { loadEffectiveProjectUploadsConfig } from "./workspaces/projectPiWebConfig.js";
|
||||
import { normalizeRequestCwd } from "./workingDirectory.js";
|
||||
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
@@ -25,6 +26,7 @@ import { MachineService } from "./machines/machineService.js";
|
||||
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
|
||||
import { proxyMachinePluginAsset, registerMachinePluginProxyRoutes } from "./machines/machinePluginProxyRoutes.js";
|
||||
import type { Project, Workspace } from "./types.js";
|
||||
|
||||
export interface AppDependencies {
|
||||
projects?: ProjectService;
|
||||
@@ -39,7 +41,11 @@ export interface AppDependencies {
|
||||
bodyLimit?: number;
|
||||
}
|
||||
|
||||
function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void {
|
||||
interface LocalProjectRouteOptions {
|
||||
config?: Pick<PiWebConfigService, "read">;
|
||||
}
|
||||
|
||||
function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string, options: LocalProjectRouteOptions = {}): void {
|
||||
app.get(`${prefix}/projects`, async () => projects.list());
|
||||
|
||||
app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => {
|
||||
@@ -70,13 +76,26 @@ function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectServi
|
||||
app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => {
|
||||
try {
|
||||
const project = await projects.requireProject(request.params.projectId);
|
||||
return await workspaces.list(project);
|
||||
return await listWorkspacesWithEffectiveConfig(project, workspaces, options.config);
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function listWorkspacesWithEffectiveConfig(project: Project, workspaces: WorkspaceService, config?: Pick<PiWebConfigService, "read">): Promise<Workspace[]> {
|
||||
const [workspaceList, effectiveConfig] = await Promise.all([
|
||||
workspaces.list(project),
|
||||
workspaceEffectiveConfig(project.path, config),
|
||||
]);
|
||||
return workspaceList.map((workspace) => ({ ...workspace, effectiveConfig }));
|
||||
}
|
||||
|
||||
async function workspaceEffectiveConfig(projectPath: string, config?: Pick<PiWebConfigService, "read">): Promise<NonNullable<Workspace["effectiveConfig"]>> {
|
||||
const globalConfig = config === undefined ? {} : (await config.read()).effectiveConfig;
|
||||
return { uploads: await loadEffectiveProjectUploadsConfig(projectPath, globalConfig) };
|
||||
}
|
||||
|
||||
interface LocalFileSuggestionRouteOptions {
|
||||
config?: Pick<PiWebConfigService, "read">;
|
||||
}
|
||||
@@ -131,8 +150,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
registerMachineRoutes(app, machines);
|
||||
registerMachinePluginProxyRoutes(app, machines);
|
||||
|
||||
registerLocalProjectRoutes(app, projects, workspaces, "/api");
|
||||
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local");
|
||||
registerLocalProjectRoutes(app, projects, workspaces, "/api", { config: configService });
|
||||
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local", { config: configService });
|
||||
|
||||
registerSessionProxyRoutes(app, sessionDaemon);
|
||||
registerSessionProxyRoutes(app, sessionDaemon, "/api/machines/local");
|
||||
|
||||
@@ -37,11 +37,11 @@ describe("config routes", () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 } },
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 });
|
||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 });
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,18 @@ describe("config routes", () => {
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid upload defaults before writing", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { uploads: { defaultFolder: "/tmp" } } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { effectivePiWebConfig, loadPiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js";
|
||||
import { effectivePiWebConfig, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js";
|
||||
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import { isPiWebPluginId } from "../shared/pluginIds.js";
|
||||
|
||||
@@ -59,6 +59,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
const shortcuts = value["shortcuts"];
|
||||
const plugins = value["plugins"];
|
||||
const pathAccess = value["pathAccess"];
|
||||
const uploads = value["uploads"];
|
||||
const maxUploadBytes = value["maxUploadBytes"];
|
||||
const spawnSessions = value["spawnSessions"];
|
||||
const subsessions = value["subsessions"];
|
||||
@@ -74,6 +75,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts);
|
||||
if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins);
|
||||
if (pathAccess !== undefined) config.pathAccess = parsePathAccessRequest(pathAccess);
|
||||
if (uploads !== undefined) config.uploads = parseUploadsConfig(uploads, "request");
|
||||
if (maxUploadBytes !== undefined) config.maxUploadBytes = parseMaxUploadBytesRequest(maxUploadBytes);
|
||||
if (spawnSessions !== undefined) {
|
||||
if (typeof spawnSessions !== "boolean") throw new Error("PI WEB config spawnSessions must be a boolean");
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RemoteMachineClient } from "./machineClient.js";
|
||||
|
||||
describe("RemoteMachineClient", () => {
|
||||
it("forwards raw binary request bodies with the provided content type", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response("ok", { status: 200 })));
|
||||
const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/" }, fetchImpl);
|
||||
const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
await client.request("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "image/png" });
|
||||
|
||||
const { input, init } = onlyFetchCall(fetchImpl);
|
||||
expect(fetchInputUrl(input)).toBe("https://remote.example.test/api/projects/p1/workspaces/w1/file?path=image.png");
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(new Headers(init.headers).get("content-type")).toBe("image/png");
|
||||
if (!(init.body instanceof ArrayBuffer)) throw new Error("Expected binary request body");
|
||||
expect(Array.from(new Uint8Array(init.body))).toEqual([0x89, 0x50, 0x4e, 0x47]);
|
||||
});
|
||||
|
||||
it("serializes structured request bodies as JSON by default", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response("ok", { status: 200 })));
|
||||
const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/base/", token: "secret" }, fetchImpl);
|
||||
|
||||
await client.request("POST", "/api/sessions", { cwd: "/repo" });
|
||||
|
||||
const { input, init } = onlyFetchCall(fetchImpl);
|
||||
expect(fetchInputUrl(input)).toBe("https://remote.example.test/base/api/sessions");
|
||||
expect(new Headers(init.headers).get("authorization")).toBe("Bearer secret");
|
||||
expect(new Headers(init.headers).get("content-type")).toBe("application/json");
|
||||
expect(init.body).toBe(JSON.stringify({ cwd: "/repo" }));
|
||||
});
|
||||
});
|
||||
|
||||
function fetchInputUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.href;
|
||||
return input.url;
|
||||
}
|
||||
|
||||
function onlyFetchCall(fetchImpl: ReturnType<typeof vi.fn<typeof fetch>>): { input: RequestInfo | URL; init: RequestInit } {
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const call = fetchImpl.mock.calls[0];
|
||||
if (call === undefined) throw new Error("Expected fetch call");
|
||||
const [input, init] = call;
|
||||
if (init === undefined) throw new Error("Expected fetch init");
|
||||
return { input, init };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export interface MachineJsonResponse {
|
||||
|
||||
export interface MachineRequestOptions {
|
||||
timeoutMs?: number;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface MachineClient {
|
||||
@@ -82,13 +83,14 @@ export class RemoteMachineClient implements MachineClient {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => { controller.abort(); }, options.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const requestBody = serializeRequestBody(method, body);
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: this.requestHeaders(body),
|
||||
headers: this.requestHeaders(body, options),
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
};
|
||||
if (body !== undefined && method !== "GET" && method !== "HEAD") init.body = JSON.stringify(body);
|
||||
if (requestBody !== undefined) init.body = requestBody;
|
||||
return await this.fetchImpl(this.remoteUrl(path), init);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw new RemoteMachineRequestError("Remote machine request timed out", 504);
|
||||
@@ -98,11 +100,11 @@ export class RemoteMachineClient implements MachineClient {
|
||||
}
|
||||
}
|
||||
|
||||
private requestHeaders(body: unknown): HeadersInit {
|
||||
private requestHeaders(body: unknown, options: MachineRequestOptions): HeadersInit {
|
||||
return {
|
||||
...this.remoteHeaders(),
|
||||
accept: "*/*",
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
...(body === undefined ? {} : { "content-type": options.contentType ?? defaultContentTypeForBody(body) }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +149,34 @@ function headersToRecord(headers: Headers): Record<string, string> {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
|
||||
function serializeRequestBody(method: string, body: unknown): NonNullable<RequestInit["body"]> | undefined {
|
||||
if (body === undefined || method === "GET" || method === "HEAD") return undefined;
|
||||
if (isRawRequestBody(body)) return body;
|
||||
if (ArrayBuffer.isView(body)) return copyArrayBufferView(body);
|
||||
const serialized: string = JSON.stringify(body);
|
||||
return serialized;
|
||||
}
|
||||
|
||||
function defaultContentTypeForBody(body: unknown): string {
|
||||
return isRawRequestBody(body) || ArrayBuffer.isView(body) ? "application/octet-stream" : "application/json";
|
||||
}
|
||||
|
||||
function isRawRequestBody(body: unknown): body is NonNullable<RequestInit["body"]> {
|
||||
return typeof body === "string"
|
||||
|| body instanceof URLSearchParams
|
||||
|| body instanceof Blob
|
||||
|| body instanceof FormData
|
||||
|| body instanceof ReadableStream
|
||||
|| body instanceof ArrayBuffer;
|
||||
}
|
||||
|
||||
function copyArrayBufferView(view: ArrayBufferView): ArrayBuffer {
|
||||
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
function readableFromWebResponseBody(body: Response["body"]): NodeJS.ReadableStream {
|
||||
if (body === null) throw new Error("Response body is not readable");
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Node fetch returns a web stream that is runtime-compatible with Readable.fromWeb, but DOM and node:stream/web types are not structurally identical in this TS config.
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { WebSocket } from "ws";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
|
||||
import { bridgeSockets } from "../webSocketBridge.js";
|
||||
import { RemoteMachineRequestError } from "./machineClient.js";
|
||||
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
|
||||
import { MachineService } from "./machineService.js";
|
||||
|
||||
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
|
||||
@@ -23,7 +23,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
|
||||
app.route<{ Params: { machineId: string }; Body: unknown }>({
|
||||
method: spec.method,
|
||||
url: `/api/machines/:machineId${spec.path}`,
|
||||
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, reply),
|
||||
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, reply: FastifyReply): Promise<FastifyReply> {
|
||||
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
|
||||
if (machineId === "local") {
|
||||
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
|
||||
}
|
||||
@@ -45,7 +45,10 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await client.request(method, remoteApiPath(machineId, requestUrl), body);
|
||||
const requestOptions = proxyRequestOptions(body, contentType);
|
||||
const upstream = requestOptions === undefined
|
||||
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
|
||||
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
if (upstream.body === undefined) return await reply.send();
|
||||
@@ -81,6 +84,20 @@ function remoteApiPath(machineId: string, requestUrl: string): string {
|
||||
return `/api${compatPath}`;
|
||||
}
|
||||
|
||||
function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
|
||||
if (!isRawProxyBody(body)) return undefined;
|
||||
const value = firstHeaderValue(contentType);
|
||||
return value === undefined || value === "" ? undefined : { contentType: value };
|
||||
}
|
||||
|
||||
function isRawProxyBody(body: unknown): boolean {
|
||||
return typeof body === "string" || body instanceof ArrayBuffer || ArrayBuffer.isView(body);
|
||||
}
|
||||
|
||||
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined) continue;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { loadEffectiveProjectPathAccess, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js";
|
||||
import { loadEffectiveProjectPathAccess, loadEffectiveProjectUploadsConfig, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js";
|
||||
|
||||
let tempDir: string;
|
||||
let projectPath: string;
|
||||
@@ -26,13 +26,13 @@ describe("project PI WEB config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads project-local path access config", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } });
|
||||
it("loads project-local path access and upload config", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } });
|
||||
|
||||
await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({
|
||||
path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH),
|
||||
exists: true,
|
||||
config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } },
|
||||
config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,12 @@ describe("project PI WEB config", () => {
|
||||
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings");
|
||||
});
|
||||
|
||||
it("reuses PI WEB upload schema validation", async () => {
|
||||
await writeProjectConfig({ version: 1, uploads: { defaultFolder: "../outside" } });
|
||||
|
||||
await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config uploads.defaultFolder must not contain path traversal");
|
||||
});
|
||||
|
||||
it("merges global and project path access in order", async () => {
|
||||
await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/project-sdk", "/shared"] } });
|
||||
|
||||
@@ -55,6 +61,14 @@ describe("project PI WEB config", () => {
|
||||
allowedPaths: ["/global-sdk", "/shared", "/project-sdk"],
|
||||
});
|
||||
});
|
||||
|
||||
it("lets project upload defaults override global upload defaults", async () => {
|
||||
await writeProjectConfig({ version: 1, uploads: { defaultFolder: "project-uploads" } });
|
||||
|
||||
await expect(loadEffectiveProjectUploadsConfig(projectPath, { uploads: { defaultFolder: "global-uploads" } })).resolves.toEqual({
|
||||
defaultFolder: "project-uploads",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergePathAccessConfigs", () => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { parsePathAccessConfig, type PiWebConfig } from "../../config.js";
|
||||
import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js";
|
||||
import { effectiveUploadsConfig, parsePathAccessConfig, parseUploadsConfig, type PiWebConfig } from "../../config.js";
|
||||
import type { PiWebPathAccessConfig, PiWebUploadsConfig } from "../../shared/apiTypes.js";
|
||||
|
||||
export const PROJECT_PI_WEB_CONFIG_PATH = ".pi-web/config.json";
|
||||
|
||||
export interface ProjectPiWebConfig {
|
||||
version?: 1;
|
||||
pathAccess?: PiWebPathAccessConfig;
|
||||
uploads?: PiWebUploadsConfig;
|
||||
}
|
||||
|
||||
export interface LoadedProjectPiWebConfig {
|
||||
@@ -33,6 +34,11 @@ export async function loadEffectiveProjectPathAccess(projectPath: string, global
|
||||
return mergePathAccessConfigs(globalConfig.pathAccess, projectConfig.config.pathAccess);
|
||||
}
|
||||
|
||||
export async function loadEffectiveProjectUploadsConfig(projectPath: string, globalConfig: PiWebConfig): Promise<PiWebUploadsConfig> {
|
||||
const projectConfig = await loadProjectPiWebConfig(projectPath);
|
||||
return effectiveUploadsConfig({ uploads: { ...(globalConfig.uploads ?? {}), ...(projectConfig.config.uploads ?? {}) } });
|
||||
}
|
||||
|
||||
export function mergePathAccessConfigs(...configs: (PiWebPathAccessConfig | undefined)[]): PiWebPathAccessConfig | undefined {
|
||||
const allowedPaths = dedupe(configs.flatMap((config) => config?.allowedPaths ?? []));
|
||||
return allowedPaths.length === 0 ? undefined : { allowedPaths };
|
||||
@@ -43,6 +49,7 @@ function parseProjectPiWebConfig(value: Record<string, unknown>, path: string):
|
||||
return {
|
||||
...(version !== undefined ? { version: parseProjectConfigVersion(version, path) } : {}),
|
||||
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
|
||||
...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user