367 lines
9.9 KiB
TypeScript
367 lines
9.9 KiB
TypeScript
import { Buffer } from "buffer";
|
|
|
|
type NullableString = string | null | undefined;
|
|
|
|
function stripSearchAndHash(pathname: string): string {
|
|
const hashIndex = pathname.indexOf("#");
|
|
const queryIndex = pathname.indexOf("?");
|
|
const end = [hashIndex, queryIndex]
|
|
.filter((index) => index >= 0)
|
|
.reduce((min, index) => Math.min(min, index), pathname.length);
|
|
return pathname.slice(0, end);
|
|
}
|
|
|
|
function extractSearch(pathname: string): string {
|
|
const queryIndex = pathname.indexOf("?");
|
|
if (queryIndex < 0) {
|
|
return "";
|
|
}
|
|
const hashIndex = pathname.indexOf("#", queryIndex);
|
|
return hashIndex >= 0
|
|
? pathname.slice(queryIndex + 1, hashIndex)
|
|
: pathname.slice(queryIndex + 1);
|
|
}
|
|
|
|
function trimNonEmpty(value: NullableString): string | null {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function encodeSegment(value: string): string {
|
|
return encodeURIComponent(value);
|
|
}
|
|
|
|
function decodeSegment(value: string): string {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function toBase64UrlNoPad(input: string): string {
|
|
return Buffer.from(input, "utf8")
|
|
.toString("base64")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/g, "");
|
|
}
|
|
|
|
function decodeBase64UrlNoPadUtf8(input: string): string | null {
|
|
const normalized = input.trim();
|
|
if (normalized.length === 0) {
|
|
return null;
|
|
}
|
|
if (!/^[A-Za-z0-9_-]+$/.test(normalized)) {
|
|
return null;
|
|
}
|
|
|
|
const base64 = normalized.replace(/-/g, "+").replace(/_/g, "/");
|
|
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
|
|
|
|
let decoded: string;
|
|
try {
|
|
decoded = Buffer.from(padded, "base64").toString("utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return decoded;
|
|
}
|
|
|
|
function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
|
|
const normalized = input.trim();
|
|
const decoded = decodeBase64UrlNoPadUtf8(normalized);
|
|
if (!decoded) {
|
|
return null;
|
|
}
|
|
|
|
// Validate via round-trip to avoid false positives ("workspace-1" etc).
|
|
if (toBase64UrlNoPad(decoded) !== normalized) {
|
|
return null;
|
|
}
|
|
|
|
return decoded;
|
|
}
|
|
|
|
function isPathLikeWorkspaceIdentity(value: string): boolean {
|
|
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value);
|
|
}
|
|
|
|
function normalizeWorkspaceId(value: string): string {
|
|
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
}
|
|
|
|
export type WorkspaceOpenIntent =
|
|
| { kind: "agent"; agentId: string }
|
|
| { kind: "terminal"; terminalId: string }
|
|
| { kind: "file"; path: string }
|
|
| { kind: "draft"; draftId: string };
|
|
|
|
export function parseWorkspaceOpenIntent(
|
|
value: string | null | undefined,
|
|
): WorkspaceOpenIntent | null {
|
|
const normalized = trimNonEmpty(value);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const separator = normalized.indexOf(":");
|
|
if (separator <= 0 || separator >= normalized.length - 1) {
|
|
return null;
|
|
}
|
|
|
|
const kind = normalized.slice(0, separator);
|
|
const payload = trimNonEmpty(normalized.slice(separator + 1));
|
|
if (!payload) {
|
|
return null;
|
|
}
|
|
|
|
if (kind === "agent") {
|
|
return { kind: "agent", agentId: payload };
|
|
}
|
|
if (kind === "terminal") {
|
|
return { kind: "terminal", terminalId: payload };
|
|
}
|
|
if (kind === "draft") {
|
|
return { kind: "draft", draftId: payload };
|
|
}
|
|
if (kind === "file") {
|
|
const decodedPath = decodeFilePathFromPathSegment(payload);
|
|
if (!decodedPath) {
|
|
return null;
|
|
}
|
|
return { kind: "file", path: decodedPath };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function parseHostWorkspaceOpenIntentFromPathname(
|
|
pathname: string,
|
|
): WorkspaceOpenIntent | null {
|
|
const search = extractSearch(pathname);
|
|
if (!search) {
|
|
return null;
|
|
}
|
|
return parseWorkspaceOpenIntent(new URLSearchParams(search).get("open"));
|
|
}
|
|
|
|
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
|
|
const normalized = trimNonEmpty(workspaceId);
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
return toBase64UrlNoPad(normalizeWorkspaceId(normalized));
|
|
}
|
|
|
|
export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): string | null {
|
|
const normalizedSegment = trimNonEmpty(workspaceIdSegment);
|
|
if (!normalizedSegment) {
|
|
return null;
|
|
}
|
|
|
|
// Decode %2F etc first (legacy scheme), but keep the raw segment to decide if base64 applies.
|
|
const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
|
|
if (!decoded) {
|
|
return null;
|
|
}
|
|
|
|
// Legacy: if it already looks like a path after decoding, keep it.
|
|
if (decoded.includes("/") || decoded.includes("\\")) {
|
|
return normalizeWorkspaceId(decoded);
|
|
}
|
|
|
|
const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded);
|
|
if (base64Decoded) {
|
|
return normalizeWorkspaceId(base64Decoded);
|
|
}
|
|
|
|
// Some older links use non-canonical base64url (non-zero pad bits). Accept
|
|
// decoded values only when they clearly represent filesystem paths.
|
|
const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded);
|
|
if (relaxedBase64Decoded && isPathLikeWorkspaceIdentity(relaxedBase64Decoded)) {
|
|
return normalizeWorkspaceId(relaxedBase64Decoded);
|
|
}
|
|
|
|
return normalizeWorkspaceId(decoded);
|
|
}
|
|
|
|
export function encodeFilePathForPathSegment(filePath: string): string {
|
|
const normalized = trimNonEmpty(filePath);
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
return toBase64UrlNoPad(normalized);
|
|
}
|
|
|
|
export function decodeFilePathFromPathSegment(filePathSegment: string): string | null {
|
|
const normalizedSegment = trimNonEmpty(filePathSegment);
|
|
if (!normalizedSegment) {
|
|
return null;
|
|
}
|
|
const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
|
|
if (!decoded) {
|
|
return null;
|
|
}
|
|
return tryDecodeBase64UrlNoPadUtf8(decoded);
|
|
}
|
|
|
|
export function parseServerIdFromPathname(pathname: string): string | null {
|
|
const pathOnly = stripSearchAndHash(pathname);
|
|
const match = pathOnly.match(/^\/h\/([^/]+)(?:\/|$)/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const raw = match[1];
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
return trimNonEmpty(decodeSegment(raw));
|
|
}
|
|
|
|
export function parseHostAgentRouteFromPathname(
|
|
pathname: string,
|
|
): { serverId: string; agentId: string } | null {
|
|
const pathOnly = stripSearchAndHash(pathname);
|
|
const match = pathOnly.match(/^\/h\/([^/]+)\/agent\/([^/]+)(?:\/|$)/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const [, encodedServerId, encodedAgentId] = match;
|
|
if (!encodedServerId || !encodedAgentId) {
|
|
return null;
|
|
}
|
|
|
|
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
|
|
const agentId = trimNonEmpty(decodeSegment(encodedAgentId));
|
|
if (!serverId || !agentId) {
|
|
return null;
|
|
}
|
|
|
|
return { serverId, agentId };
|
|
}
|
|
|
|
export function parseHostWorkspaceRouteFromPathname(
|
|
pathname: string,
|
|
): { serverId: string; workspaceId: string } | null {
|
|
const pathOnly = stripSearchAndHash(pathname);
|
|
const match = pathOnly.match(/^\/h\/([^/]+)\/workspace\/([^/]+)\/?$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const serverId = trimNonEmpty(decodeSegment(match[1]));
|
|
if (!serverId) {
|
|
return null;
|
|
}
|
|
|
|
const rawWorkspaceId = match[2];
|
|
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
|
if (!workspaceId) {
|
|
return null;
|
|
}
|
|
return { serverId, workspaceId };
|
|
}
|
|
|
|
export function buildHostWorkspaceRoute(serverId: string, workspaceId: string): string {
|
|
const normalizedServerId = trimNonEmpty(serverId);
|
|
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
|
if (!normalizedServerId || !normalizedWorkspaceId) {
|
|
return "/";
|
|
}
|
|
const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(normalizedWorkspaceId);
|
|
if (!encodedWorkspaceId) {
|
|
return "/";
|
|
}
|
|
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
|
|
}
|
|
|
|
export function buildHostAgentDetailRoute(
|
|
serverId: string,
|
|
agentId: string,
|
|
workspaceId?: string,
|
|
): string {
|
|
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
|
if (normalizedWorkspaceId) {
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedAgentId) {
|
|
return "/";
|
|
}
|
|
const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId);
|
|
if (base === "/") {
|
|
return "/";
|
|
}
|
|
return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}`;
|
|
}
|
|
const normalizedServerId = trimNonEmpty(serverId);
|
|
const normalizedAgentId = trimNonEmpty(agentId);
|
|
if (!normalizedServerId || !normalizedAgentId) {
|
|
return "/";
|
|
}
|
|
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}`;
|
|
}
|
|
|
|
export function buildHostRootRoute(serverId: string): string {
|
|
const normalized = trimNonEmpty(serverId);
|
|
if (!normalized) {
|
|
return "/";
|
|
}
|
|
return `/h/${encodeSegment(normalized)}`;
|
|
}
|
|
|
|
export function buildHostSessionsRoute(serverId: string): string {
|
|
const base = buildHostRootRoute(serverId);
|
|
if (base === "/") {
|
|
return "/";
|
|
}
|
|
return `${base}/sessions`;
|
|
}
|
|
|
|
export function buildHostOpenProjectRoute(serverId: string): string {
|
|
const base = buildHostRootRoute(serverId);
|
|
if (base === "/") {
|
|
return "/";
|
|
}
|
|
return `${base}/open-project`;
|
|
}
|
|
|
|
export function buildHostSettingsRoute(serverId: string): string {
|
|
const base = buildHostRootRoute(serverId);
|
|
if (base === "/") {
|
|
return "/";
|
|
}
|
|
return `${base}/settings`;
|
|
}
|
|
|
|
export function mapPathnameToServer(pathname: string, nextServerId: string): string {
|
|
const normalized = trimNonEmpty(nextServerId);
|
|
if (!normalized) {
|
|
return "/";
|
|
}
|
|
|
|
const suffix = pathname.replace(/^\/h\/[^/]+\/?/, "");
|
|
const base = buildHostRootRoute(normalized);
|
|
if (suffix.startsWith("settings")) {
|
|
return `${base}/settings`;
|
|
}
|
|
if (suffix.startsWith("sessions")) {
|
|
return `${base}/sessions`;
|
|
}
|
|
if (suffix.startsWith("open-project")) {
|
|
return `${base}/open-project`;
|
|
}
|
|
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
|
if (workspaceRoute) {
|
|
return buildHostWorkspaceRoute(normalized, workspaceRoute.workspaceId);
|
|
}
|
|
if (suffix.startsWith("agent/")) {
|
|
return `${base}/${suffix}`;
|
|
}
|
|
return base;
|
|
}
|