feat: add safe manual workspace uploads

This commit is contained in:
Federico Jaramillo Martinez
2026-06-25 15:16:44 +02:00
parent f0b779b062
commit e46d9ecbf8
34 changed files with 2314 additions and 131 deletions
+33 -1
View File
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { dirname, isAbsolute, join, resolve } from "node:path";
import type { PiWebConfigValues } from "./shared/apiTypes.js";
import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js";
@@ -33,6 +33,12 @@ export function defaultPiWebDataDir(): string {
*/
export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads";
export function effectiveUploadsConfig(config: Pick<PiWebConfig, "uploads"> = {}): NonNullable<PiWebConfig["uploads"]> {
return { defaultFolder: config.uploads?.defaultFolder ?? DEFAULT_UPLOADS_FOLDER };
}
export function maxUploadBytes(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): number {
const fromEnv = env["PI_WEB_MAX_UPLOAD_BYTES"];
if (fromEnv !== undefined && fromEnv !== "") {
@@ -82,6 +88,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf
...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}),
...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}),
...(maxUpload !== undefined && maxUpload !== "" ? { maxUploadBytes: parseMaxUploadBytes(maxUpload, "PI_WEB_MAX_UPLOAD_BYTES") } : {}),
uploads: effectiveUploadsConfig(loaded.config),
// Always resolved (on by default) so the effective config is the single
// source of truth for the runtime state and the settings UI toggle.
spawnSessions: spawnSessionsEnabled(env, loaded.config),
@@ -102,6 +109,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
delete existing["shortcuts"];
delete existing["plugins"];
delete existing["pathAccess"];
delete existing["uploads"];
delete existing["maxUploadBytes"];
delete existing["spawnSessions"];
delete existing["subsessions"];
@@ -126,6 +134,7 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}),
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}),
...(config.uploads !== undefined ? { uploads: config.uploads } : {}),
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
@@ -140,6 +149,7 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}),
...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}),
...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}),
...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}),
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
@@ -225,6 +235,28 @@ function parseAllowedPaths(value: unknown, path: string): string[] {
return value;
}
export function parseUploadsConfig(value: unknown, path: string): NonNullable<PiWebConfigValues["uploads"]> {
if (!isRecord(value)) throw new Error(`PI WEB config uploads must be an object: ${path}`);
const defaultFolder = value["defaultFolder"];
return {
...(defaultFolder !== undefined ? { defaultFolder: parseWorkspaceRelativeFolder(defaultFolder, "uploads.defaultFolder", path) } : {}),
};
}
function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string): string {
if (typeof value !== "string" || value.trim() === "") throw new Error(`PI WEB config ${key} must be a non-empty workspace-relative path: ${path}`);
if (isAbsoluteLike(value)) throw new Error(`PI WEB config ${key} must be workspace-relative: ${path}`);
const parts = value.split(/[\\/]+/).filter((part) => part !== "" && part !== ".");
if (parts.length === 0) throw new Error(`PI WEB config ${key} must be a non-empty workspace-relative path: ${path}`);
if (parts.some((part) => part === "..")) throw new Error(`PI WEB config ${key} must not contain path traversal: ${path}`);
return parts.join("/");
}
function isAbsoluteLike(value: string): boolean {
const withForwardSlashes = value.replace(/\\/g, "/");
return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes);
}
function parseShortcuts(value: unknown, path: string): Record<string, string | null> {
if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`);
return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => {