First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import {
|
||||
resolveConfiguredSshTarget,
|
||||
resolveGatewaySshTargetFromGatewayUrl,
|
||||
runSshJson,
|
||||
} from "@/lib/ssh/gateway-host";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
const expandTildeLocal = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "~") return os.homedir();
|
||||
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const validateRawMediaPath = (raw: string): { trimmed: string; mime: string } => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) throw new Error("path is required");
|
||||
if (trimmed.length > 4096) throw new Error("path too long");
|
||||
if (/[^\S\r\n]*[\0\r\n]/.test(trimmed)) throw new Error("path contains invalid characters");
|
||||
|
||||
const ext = path.extname(trimmed).toLowerCase();
|
||||
const mime = MIME_BY_EXT[ext];
|
||||
if (!mime) throw new Error(`Unsupported media extension: ${ext || "(none)"}`);
|
||||
|
||||
return { trimmed, mime };
|
||||
};
|
||||
|
||||
const resolveAndValidateLocalMediaPath = (raw: string): { resolved: string; mime: string } => {
|
||||
const { trimmed, mime } = validateRawMediaPath(raw);
|
||||
|
||||
const expanded = expandTildeLocal(trimmed);
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
throw new Error("path must be absolute or start with ~/");
|
||||
}
|
||||
|
||||
const resolved = path.resolve(expanded);
|
||||
|
||||
const allowedRoot = path.join(os.homedir(), ".openclaw");
|
||||
const allowedPrefix = `${allowedRoot}${path.sep}`;
|
||||
if (!(resolved === allowedRoot || resolved.startsWith(allowedPrefix))) {
|
||||
throw new Error(`Refusing to read media outside ${allowedRoot}`);
|
||||
}
|
||||
|
||||
return { resolved, mime };
|
||||
};
|
||||
|
||||
const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: string } => {
|
||||
const { trimmed, mime } = validateRawMediaPath(raw);
|
||||
|
||||
if (!(trimmed.startsWith("/") || trimmed === "~" || trimmed.startsWith("~/"))) {
|
||||
throw new Error("path must be absolute or start with ~/");
|
||||
}
|
||||
|
||||
// Remote side enforces ~/.openclaw; this guard lets Studio on macOS request
|
||||
// /home/ubuntu/.openclaw/... without tripping local homedir checks.
|
||||
const normalized = trimmed.replaceAll("\\\\", "/");
|
||||
const inOpenclaw =
|
||||
normalized === "~/.openclaw" ||
|
||||
normalized.startsWith("~/.openclaw/") ||
|
||||
normalized.includes("/.openclaw/");
|
||||
if (!inOpenclaw) {
|
||||
throw new Error("Refusing to read remote media outside ~/.openclaw");
|
||||
}
|
||||
|
||||
return { remotePath: trimmed, mime };
|
||||
};
|
||||
|
||||
const readLocalMedia = async (resolvedPath: string): Promise<{ bytes: Buffer; size: number }> => {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("path is not a file");
|
||||
}
|
||||
if (stat.size > MAX_MEDIA_BYTES) {
|
||||
throw new Error(`media file too large (${stat.size} bytes)`);
|
||||
}
|
||||
const buf = await fs.readFile(resolvedPath);
|
||||
return { bytes: buf, size: stat.size };
|
||||
};
|
||||
|
||||
const REMOTE_READ_SCRIPT = `
|
||||
set -euo pipefail
|
||||
|
||||
python3 - "$1" <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
raw = sys.argv[1].strip()
|
||||
if not raw:
|
||||
print(json.dumps({"error": "path is required"}))
|
||||
raise SystemExit(2)
|
||||
|
||||
p = pathlib.Path(os.path.expanduser(raw))
|
||||
try:
|
||||
resolved = p.resolve(strict=True)
|
||||
except FileNotFoundError:
|
||||
print(json.dumps({"error": f"file not found: {raw}"}))
|
||||
raise SystemExit(3)
|
||||
|
||||
home = pathlib.Path.home().resolve()
|
||||
allowed = (home / ".openclaw").resolve()
|
||||
if resolved != allowed and allowed not in resolved.parents:
|
||||
print(json.dumps({"error": f"Refusing to read media outside {allowed}"}))
|
||||
raise SystemExit(4)
|
||||
|
||||
ext = resolved.suffix.lower()
|
||||
mime = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}.get(ext) or (mimetypes.guess_type(str(resolved))[0] or "")
|
||||
|
||||
if not mime.startswith("image/"):
|
||||
print(json.dumps({"error": f"Unsupported media extension: {ext or '(none)'}"}))
|
||||
raise SystemExit(5)
|
||||
|
||||
size = resolved.stat().st_size
|
||||
max_bytes = ${MAX_MEDIA_BYTES}
|
||||
if size > max_bytes:
|
||||
print(json.dumps({"error": f"media file too large ({size} bytes)"}))
|
||||
raise SystemExit(6)
|
||||
|
||||
data = base64.b64encode(resolved.read_bytes()).decode("ascii")
|
||||
print(json.dumps({"ok": True, "mime": mime, "size": size, "data": data}))
|
||||
PY
|
||||
`;
|
||||
|
||||
const resolveSshTarget = (): string | null => {
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url ?? "";
|
||||
if (isLocalGatewayUrl(gatewayUrl)) return null;
|
||||
const configured = resolveConfiguredSshTarget(process.env);
|
||||
if (configured) return configured;
|
||||
return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env);
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawPath = (searchParams.get("path") ?? "").trim();
|
||||
|
||||
const sshTarget = resolveSshTarget();
|
||||
|
||||
if (!sshTarget) {
|
||||
const { resolved, mime } = resolveAndValidateLocalMediaPath(rawPath);
|
||||
const { bytes, size } = await readLocalMedia(resolved);
|
||||
const body = new Blob([Uint8Array.from(bytes)], { type: mime });
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
"Content-Length": String(size),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { remotePath, mime } = validateRemoteMediaPath(rawPath);
|
||||
|
||||
const payload = runSshJson({
|
||||
sshTarget,
|
||||
argv: ["bash", "-s", "--", remotePath],
|
||||
label: "gateway media read",
|
||||
input: REMOTE_READ_SCRIPT,
|
||||
fallbackMessage: `Failed to fetch media over ssh (${sshTarget})`,
|
||||
maxBuffer: Math.ceil(MAX_MEDIA_BYTES * 1.6),
|
||||
}) as {
|
||||
ok?: boolean;
|
||||
data?: string;
|
||||
mime?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const b64 = payload.data ?? "";
|
||||
if (!b64) {
|
||||
throw new Error("Remote media fetch returned empty data");
|
||||
}
|
||||
|
||||
const buf = Buffer.from(b64, "base64");
|
||||
const responseMime = payload.mime || mime;
|
||||
const body = new Blob([Uint8Array.from(buf)], { type: responseMime });
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": responseMime,
|
||||
"Content-Length": String(buf.length),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch media";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user