mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-16 20:40:45 +00:00
Onboarding/Media: harden QR scanner and data URL parsing
This commit is contained in:
committed by
Mariano Belinky
parent
06adadc759
commit
bc05143e4e
@@ -7,16 +7,35 @@ struct QRScannerView: UIViewControllerRepresentable {
|
||||
let onError: (String) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> DataScannerViewController {
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
guard DataScannerViewController.isSupported else {
|
||||
context.coordinator.reportError("QR scanning is not supported on this device.")
|
||||
return UIViewController()
|
||||
}
|
||||
guard DataScannerViewController.isAvailable else {
|
||||
context.coordinator.reportError("Camera scanning is currently unavailable.")
|
||||
return UIViewController()
|
||||
}
|
||||
let scanner = DataScannerViewController(
|
||||
recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
||||
isHighlightingEnabled: true)
|
||||
scanner.delegate = context.coordinator
|
||||
try? scanner.startScanning()
|
||||
do {
|
||||
try scanner.startScanning()
|
||||
} catch {
|
||||
context.coordinator.reportError("Could not start QR scanner.")
|
||||
}
|
||||
return scanner
|
||||
}
|
||||
|
||||
func updateUIViewController(_: DataScannerViewController, context _: Context) {}
|
||||
func updateUIViewController(_: UIViewController, context _: Context) {}
|
||||
|
||||
static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) {
|
||||
if let scanner = uiViewController as? DataScannerViewController {
|
||||
scanner.stopScanning()
|
||||
}
|
||||
coordinator.parent.onDismiss()
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(parent: self)
|
||||
@@ -25,11 +44,20 @@ struct QRScannerView: UIViewControllerRepresentable {
|
||||
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||||
let parent: QRScannerView
|
||||
private var handled = false
|
||||
private var reportedError = false
|
||||
|
||||
init(parent: QRScannerView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func reportError(_ message: String) {
|
||||
guard !self.reportedError else { return }
|
||||
self.reportedError = true
|
||||
Task { @MainActor in
|
||||
self.parent.onError(message)
|
||||
}
|
||||
}
|
||||
|
||||
func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) {
|
||||
guard !self.handled else { return }
|
||||
for item in items {
|
||||
@@ -58,8 +86,11 @@ struct QRScannerView: UIViewControllerRepresentable {
|
||||
|
||||
func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {}
|
||||
|
||||
func dataScanner(_: DataScannerViewController, becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable) {
|
||||
self.parent.onError("Camera is not available on this device.")
|
||||
func dataScanner(
|
||||
_: DataScannerViewController,
|
||||
becameUnavailableWithError _: DataScannerViewController.ScanningUnavailable)
|
||||
{
|
||||
self.reportError("Camera is not available on this device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +76,34 @@ import Testing
|
||||
timeoutSeconds: nil,
|
||||
key: nil)))
|
||||
}
|
||||
|
||||
@Test func parseGatewayLinkParsesCommonFields() {
|
||||
let url = URL(
|
||||
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
|
||||
#expect(
|
||||
DeepLinkParser.parse(url) == .gateway(
|
||||
.init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def")))
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
|
||||
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
|
||||
#expect(link == .init(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: true,
|
||||
token: "tok",
|
||||
password: "pw"))
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeRejectsInvalidInput() {
|
||||
#expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,4 +413,32 @@ describe("local media root guard", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads base64 data URLs", async () => {
|
||||
const pngBuffer = await sharp({
|
||||
create: { width: 2, height: 2, channels: 3, background: "#00aaff" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const dataUrl = `data:image/png;base64,${pngBuffer.toString("base64")}`;
|
||||
|
||||
const result = await loadWebMedia(dataUrl, 1024 * 1024);
|
||||
|
||||
expect(result.kind).toBe("image");
|
||||
expect(result.contentType).toBe("image/jpeg");
|
||||
expect(result.buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects non-base64 data URLs", async () => {
|
||||
await expect(loadWebMedia("data:image/png,hello", 1024)).rejects.toThrow(
|
||||
/Only base64 data: URLs are supported/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized base64 data URLs before decode", async () => {
|
||||
const body = Buffer.alloc(4096, 1).toString("base64");
|
||||
const dataUrl = `data:application/octet-stream;base64,${body}`;
|
||||
|
||||
await expect(loadWebMediaRaw(dataUrl, 128)).rejects.toThrow(/exceeds .*limit/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,6 +91,8 @@ async function assertLocalMediaAllowed(
|
||||
|
||||
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
|
||||
const HEIC_EXT_RE = /\.(heic|heif)$/i;
|
||||
const DATA_URL_RE = /^data:([^;,]+)?(;base64)?,(.*)$/i;
|
||||
const BASE64_BODY_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function formatMb(bytes: number, digits = 2): string {
|
||||
@@ -130,6 +132,38 @@ function toJpegFileName(fileName?: string): string | undefined {
|
||||
return path.format({ dir: parsed.dir, name: parsed.name, ext: ".jpg" });
|
||||
}
|
||||
|
||||
function parseBase64DataUrl(mediaUrl: string): { contentType: string; base64Body: string } | null {
|
||||
const match = mediaUrl.match(DATA_URL_RE);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
if (!match[2]) {
|
||||
throw new Error("Only base64 data: URLs are supported");
|
||||
}
|
||||
const contentType = match[1] || "application/octet-stream";
|
||||
const base64Body = (match[3] || "").replaceAll(/\s+/g, "");
|
||||
if (base64Body.length === 0) {
|
||||
return { contentType, base64Body };
|
||||
}
|
||||
if (!BASE64_BODY_RE.test(base64Body) || base64Body.length % 4 === 1) {
|
||||
throw new Error("Invalid base64 data in data: URL");
|
||||
}
|
||||
return { contentType, base64Body };
|
||||
}
|
||||
|
||||
function estimateDecodedBase64Bytes(base64Body: string): number {
|
||||
if (base64Body.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
let padding = 0;
|
||||
if (base64Body.endsWith("==")) {
|
||||
padding = 2;
|
||||
} else if (base64Body.endsWith("=")) {
|
||||
padding = 1;
|
||||
}
|
||||
return Math.floor((base64Body.length * 3) / 4) - padding;
|
||||
}
|
||||
|
||||
type OptimizedImage = {
|
||||
buffer: Buffer;
|
||||
optimizedSize: number;
|
||||
@@ -273,16 +307,22 @@ async function loadWebMediaInternal(
|
||||
};
|
||||
};
|
||||
|
||||
// Handle data: URLs (base64-encoded inline data)
|
||||
if (mediaUrl.startsWith("data:")) {
|
||||
const match = mediaUrl.match(/^data:([^;,]+)?(?:;base64)?,(.*)$/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid data: URL format");
|
||||
}
|
||||
const contentType = match[1] || "application/octet-stream";
|
||||
const base64Data = match[2];
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const parsedDataUrl = parseBase64DataUrl(mediaUrl);
|
||||
if (parsedDataUrl) {
|
||||
const { contentType, base64Body } = parsedDataUrl;
|
||||
const kind = mediaKindFromMime(contentType);
|
||||
const defaultFetchCap = maxBytesForKind("unknown");
|
||||
const decodeCap =
|
||||
maxBytes === undefined
|
||||
? defaultFetchCap
|
||||
: optimizeImages && kind === "image"
|
||||
? Math.max(maxBytes, defaultFetchCap)
|
||||
: maxBytes;
|
||||
const estimatedBytes = estimateDecodedBase64Bytes(base64Body);
|
||||
if (estimatedBytes > decodeCap) {
|
||||
throw new Error(formatCapLimit("Media", decodeCap, estimatedBytes));
|
||||
}
|
||||
const buffer = Buffer.from(base64Body, "base64");
|
||||
return await clampAndFinalize({ buffer, contentType, kind });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user