Преглед на файлове

Add proper multipart file upload with binary support to HTTP client

- Add files[] option to http.request for multipart file uploads
- Properly decode base64 file data to binary before sending
- Add formData option for form fields in multipart requests
- Update OCR Upload node to use new file upload API
- Remove manual multipart body construction from OCR node
- Fix "Unsupported file type" error from OCR API
fszontagh преди 6 месеца
родител
ревизия
a2cae96776
променени са 2 файла, в които са добавени 390 реда и са изтрити 66 реда
  1. 24 65
      nodes/ocr/ocr-upload.js
  2. 366 1
      src/runner/engine/script_engine.cpp

+ 24 - 65
nodes/ocr/ocr-upload.js

@@ -2,7 +2,7 @@
  * @node ocr-upload
  * @name OCR Upload
  * @category ocr
- * @version 1.2.0
+ * @version 1.3.0
  * @description Upload a file for OCR processing via pdftoimageapi
  * @icon file-scan
  */
@@ -113,59 +113,6 @@ function resolveBinaryData(file) {
     return file;
 }
 
-function generateBoundary() {
-    return '----SmartBoticBoundary' + smartbotic.utils.uuid().replace(/-/g, '');
-}
-
-function buildMultipartBody(boundary, file, options) {
-    const crlf = '\r\n';
-    let body = '';
-
-    body += `--${boundary}${crlf}`;
-    body += `Content-Disposition: form-data; name="file"; filename="${file.filename}"${crlf}`;
-    body += `Content-Type: ${file.mimeType}${crlf}`;
-    body += `Content-Transfer-Encoding: base64${crlf}`;
-    body += crlf;
-    body += file.data;
-    body += crlf;
-
-    if (options.processingMode && options.processingMode !== 'auto') {
-        body += `--${boundary}${crlf}`;
-        body += `Content-Disposition: form-data; name="processing_mode"${crlf}`;
-        body += crlf;
-        body += options.processingMode;
-        body += crlf;
-    }
-
-    if (options.batchSize) {
-        body += `--${boundary}${crlf}`;
-        body += `Content-Disposition: form-data; name="batch_size"${crlf}`;
-        body += crlf;
-        body += String(options.batchSize);
-        body += crlf;
-    }
-
-    if (options.targetWidth) {
-        body += `--${boundary}${crlf}`;
-        body += `Content-Disposition: form-data; name="target_width"${crlf}`;
-        body += crlf;
-        body += String(options.targetWidth);
-        body += crlf;
-    }
-
-    if (options.groupId) {
-        body += `--${boundary}${crlf}`;
-        body += `Content-Disposition: form-data; name="group_id"${crlf}`;
-        body += crlf;
-        body += options.groupId;
-        body += crlf;
-    }
-
-    body += `--${boundary}--${crlf}`;
-
-    return body;
-}
-
 module.exports = {
     configSchema,
     inputSchema,
@@ -240,23 +187,35 @@ module.exports = {
             headers[auth.headerName] = auth.headerValue;
         }
 
-        const boundary = generateBoundary();
-        headers['Content-Type'] = `multipart/form-data; boundary=${boundary}`;
-
-        const body = buildMultipartBody(boundary, file, {
-            processingMode: config.processingMode,
-            batchSize: config.batchSize,
-            targetWidth: config.targetWidth,
-            groupId: config.groupId
-        });
+        smartbotic.log.info(`OCR Upload: ${file.filename} (${file.size} bytes, type: ${file.mimeType})`);
 
-        smartbotic.log.info(`OCR Upload: ${file.filename} (${file.size} bytes)`);
+        // Build form data for additional parameters
+        const formData = {};
+        if (config.processingMode && config.processingMode !== 'auto') {
+            formData.processing_mode = config.processingMode;
+        }
+        if (config.batchSize) {
+            formData.batch_size = String(config.batchSize);
+        }
+        if (config.targetWidth) {
+            formData.target_width = String(config.targetWidth);
+        }
+        if (config.groupId) {
+            formData.group_id = config.groupId;
+        }
 
+        // Use the new multipart file upload support with proper binary encoding
         const response = smartbotic.http.request({
             method: 'POST',
             url: `${baseUrl}/api/ocr/upload`,
             headers: headers,
-            body: body,
+            files: [{
+                name: 'file',
+                filename: file.filename,
+                mimeType: file.mimeType,
+                data: file.data  // base64 encoded - will be decoded to binary by http.request
+            }],
+            formData: formData,
             timeout: 120000
         });
 

+ 366 - 1
src/runner/engine/script_engine.cpp

@@ -10,6 +10,8 @@
 #include <iomanip>
 #include <algorithm>
 #include <regex>
+#include <fstream>
+#include <filesystem>
 #include <openssl/sha.h>
 #include <openssl/evp.h>
 #include <openssl/buffer.h>
@@ -303,6 +305,104 @@ static JSValue js_http_request(JSContext* ctx, JSValue this_val, int argc, JSVal
     }
     JS_FreeValue(ctx, body_val);
 
+    // Check for multipart file upload (files array with {name, filename, data, mimeType})
+    JSValue files_val = JS_GetPropertyStr(ctx, options, "files");
+    if (JS_IsArray(ctx, files_val)) {
+        // Build multipart/form-data body with binary file support
+        std::string boundary = "----SmartBoticBoundary" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
+        headers["Content-Type"] = "multipart/form-data; boundary=" + boundary;
+
+        body.clear();
+        std::string crlf = "\r\n";
+
+        // First add any form fields from formData option
+        JSValue form_data_val = JS_GetPropertyStr(ctx, options, "formData");
+        if (JS_IsObject(form_data_val)) {
+            JSPropertyEnum* props;
+            uint32_t prop_count;
+            JS_GetOwnPropertyNames(ctx, &props, &prop_count, form_data_val, JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY);
+            for (uint32_t i = 0; i < prop_count; i++) {
+                const char* key = JS_AtomToCString(ctx, props[i].atom);
+                JSValue val = JS_GetProperty(ctx, form_data_val, props[i].atom);
+                const char* val_str = JS_ToCString(ctx, val);
+                if (key && val_str) {
+                    body += "--" + boundary + crlf;
+                    body += "Content-Disposition: form-data; name=\"" + std::string(key) + "\"" + crlf;
+                    body += crlf;
+                    body += val_str;
+                    body += crlf;
+                }
+                if (key) JS_FreeCString(ctx, key);
+                if (val_str) JS_FreeCString(ctx, val_str);
+                JS_FreeValue(ctx, val);
+                JS_FreeAtom(ctx, props[i].atom);
+            }
+            js_free(ctx, props);
+        }
+        JS_FreeValue(ctx, form_data_val);
+
+        // Add files
+        int64_t files_len = 0;
+        JSValue len_val = JS_GetPropertyStr(ctx, files_val, "length");
+        JS_ToInt64(ctx, &files_len, len_val);
+        JS_FreeValue(ctx, len_val);
+
+        for (int64_t i = 0; i < files_len; i++) {
+            JSValue file_obj = JS_GetPropertyUint32(ctx, files_val, i);
+            if (JS_IsObject(file_obj)) {
+                // Get file properties
+                std::string field_name = "file";
+                std::string filename = "file";
+                std::string mime_type = "application/octet-stream";
+                std::string file_data;
+
+                JSValue name_val = JS_GetPropertyStr(ctx, file_obj, "name");
+                if (JS_IsString(name_val)) {
+                    const char* s = JS_ToCString(ctx, name_val);
+                    if (s) { field_name = s; JS_FreeCString(ctx, s); }
+                }
+                JS_FreeValue(ctx, name_val);
+
+                JSValue filename_val = JS_GetPropertyStr(ctx, file_obj, "filename");
+                if (JS_IsString(filename_val)) {
+                    const char* s = JS_ToCString(ctx, filename_val);
+                    if (s) { filename = s; JS_FreeCString(ctx, s); }
+                }
+                JS_FreeValue(ctx, filename_val);
+
+                JSValue mime_val = JS_GetPropertyStr(ctx, file_obj, "mimeType");
+                if (JS_IsString(mime_val)) {
+                    const char* s = JS_ToCString(ctx, mime_val);
+                    if (s) { mime_type = s; JS_FreeCString(ctx, s); }
+                }
+                JS_FreeValue(ctx, mime_val);
+
+                JSValue data_val = JS_GetPropertyStr(ctx, file_obj, "data");
+                if (JS_IsString(data_val)) {
+                    const char* s = JS_ToCString(ctx, data_val);
+                    if (s) {
+                        // Decode base64 to binary
+                        file_data = base64Decode(s);
+                        JS_FreeCString(ctx, s);
+                    }
+                }
+                JS_FreeValue(ctx, data_val);
+
+                // Build file part with binary data
+                body += "--" + boundary + crlf;
+                body += "Content-Disposition: form-data; name=\"" + field_name + "\"; filename=\"" + filename + "\"" + crlf;
+                body += "Content-Type: " + mime_type + crlf;
+                body += crlf;
+                body.append(file_data);  // Binary data
+                body += crlf;
+            }
+            JS_FreeValue(ctx, file_obj);
+        }
+
+        body += "--" + boundary + "--" + crlf;
+    }
+    JS_FreeValue(ctx, files_val);
+
     // Get timeout (default 30000ms)
     long timeout_ms = 30000;
     JSValue timeout_val = JS_GetPropertyStr(ctx, options, "timeout");
@@ -887,6 +987,263 @@ void ScriptEngine::setupBuiltinAPIs() {
 
     JS_SetPropertyStr(ctx, smartbotic, "http", http);
 
+    // smartbotic.fs - Filesystem API for binary storage
+    JSValue fs = JS_NewObject(ctx);
+
+    // fs.writeFile(path, data, encoding) - Write data to file
+    // encoding: 'base64' (default) or 'utf8'
+    JS_SetPropertyStr(ctx, fs, "writeFile", JS_NewCFunction(ctx, [](JSContext* ctx, JSValue this_val, int argc, JSValue* argv) -> JSValue {
+        if (argc < 2) {
+            return JS_ThrowTypeError(ctx, "fs.writeFile requires path and data arguments");
+        }
+
+        const char* path = JS_ToCString(ctx, argv[0]);
+        if (!path) {
+            return JS_ThrowTypeError(ctx, "path must be a string");
+        }
+        std::string path_str(path);
+        JS_FreeCString(ctx, path);
+
+        const char* data = JS_ToCString(ctx, argv[1]);
+        if (!data) {
+            return JS_ThrowTypeError(ctx, "data must be a string");
+        }
+        std::string data_str(data);
+        JS_FreeCString(ctx, data);
+
+        // Get encoding (default: base64)
+        std::string encoding = "base64";
+        if (argc >= 3 && !JS_IsUndefined(argv[2])) {
+            const char* enc = JS_ToCString(ctx, argv[2]);
+            if (enc) {
+                encoding = enc;
+                JS_FreeCString(ctx, enc);
+            }
+        }
+
+        try {
+            // Create parent directories if needed
+            std::filesystem::path file_path(path_str);
+            if (file_path.has_parent_path()) {
+                std::filesystem::create_directories(file_path.parent_path());
+            }
+
+            std::ofstream file(path_str, std::ios::binary);
+            if (!file) {
+                JSValue response = JS_NewObject(ctx);
+                JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+                JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, ("Failed to open file for writing: " + path_str).c_str()));
+                return response;
+            }
+
+            if (encoding == "base64") {
+                // Decode base64 and write binary
+                std::string decoded = base64Decode(data_str);
+                file.write(decoded.data(), decoded.size());
+            } else {
+                // Write as-is (utf8)
+                file.write(data_str.data(), data_str.size());
+            }
+
+            file.close();
+
+            JSValue response = JS_NewObject(ctx);
+            JS_SetPropertyStr(ctx, response, "success", JS_TRUE);
+            JS_SetPropertyStr(ctx, response, "path", JS_NewString(ctx, path_str.c_str()));
+            JS_SetPropertyStr(ctx, response, "size", JS_NewInt64(ctx, std::filesystem::file_size(path_str)));
+            return response;
+        } catch (const std::exception& e) {
+            JSValue response = JS_NewObject(ctx);
+            JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+            JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, e.what()));
+            return response;
+        }
+    }, "writeFile", 3));
+
+    // fs.readFile(path, encoding) - Read file data
+    // encoding: 'base64' (default) or 'utf8'
+    JS_SetPropertyStr(ctx, fs, "readFile", JS_NewCFunction(ctx, [](JSContext* ctx, JSValue this_val, int argc, JSValue* argv) -> JSValue {
+        if (argc < 1) {
+            return JS_ThrowTypeError(ctx, "fs.readFile requires path argument");
+        }
+
+        const char* path = JS_ToCString(ctx, argv[0]);
+        if (!path) {
+            return JS_ThrowTypeError(ctx, "path must be a string");
+        }
+        std::string path_str(path);
+        JS_FreeCString(ctx, path);
+
+        std::string encoding = "base64";
+        if (argc >= 2 && !JS_IsUndefined(argv[1])) {
+            const char* enc = JS_ToCString(ctx, argv[1]);
+            if (enc) {
+                encoding = enc;
+                JS_FreeCString(ctx, enc);
+            }
+        }
+
+        try {
+            if (!std::filesystem::exists(path_str)) {
+                JSValue response = JS_NewObject(ctx);
+                JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+                JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, ("File not found: " + path_str).c_str()));
+                return response;
+            }
+
+            std::ifstream file(path_str, std::ios::binary);
+            if (!file) {
+                JSValue response = JS_NewObject(ctx);
+                JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+                JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, ("Failed to open file: " + path_str).c_str()));
+                return response;
+            }
+
+            std::ostringstream ss;
+            ss << file.rdbuf();
+            std::string content = ss.str();
+            file.close();
+
+            JSValue response = JS_NewObject(ctx);
+            JS_SetPropertyStr(ctx, response, "success", JS_TRUE);
+            JS_SetPropertyStr(ctx, response, "size", JS_NewInt64(ctx, content.size()));
+
+            if (encoding == "base64") {
+                std::string encoded = base64Encode(content);
+                JS_SetPropertyStr(ctx, response, "data", JS_NewString(ctx, encoded.c_str()));
+            } else {
+                JS_SetPropertyStr(ctx, response, "data", JS_NewString(ctx, content.c_str()));
+            }
+
+            return response;
+        } catch (const std::exception& e) {
+            JSValue response = JS_NewObject(ctx);
+            JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+            JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, e.what()));
+            return response;
+        }
+    }, "readFile", 2));
+
+    // fs.exists(path) - Check if file exists
+    JS_SetPropertyStr(ctx, fs, "exists", JS_NewCFunction(ctx, [](JSContext* ctx, JSValue this_val, int argc, JSValue* argv) -> JSValue {
+        if (argc < 1) {
+            return JS_ThrowTypeError(ctx, "fs.exists requires path argument");
+        }
+
+        const char* path = JS_ToCString(ctx, argv[0]);
+        if (!path) {
+            return JS_FALSE;
+        }
+        std::string path_str(path);
+        JS_FreeCString(ctx, path);
+
+        return std::filesystem::exists(path_str) ? JS_TRUE : JS_FALSE;
+    }, "exists", 1));
+
+    // fs.mkdir(path) - Create directory (recursive)
+    JS_SetPropertyStr(ctx, fs, "mkdir", JS_NewCFunction(ctx, [](JSContext* ctx, JSValue this_val, int argc, JSValue* argv) -> JSValue {
+        if (argc < 1) {
+            return JS_ThrowTypeError(ctx, "fs.mkdir requires path argument");
+        }
+
+        const char* path = JS_ToCString(ctx, argv[0]);
+        if (!path) {
+            return JS_ThrowTypeError(ctx, "path must be a string");
+        }
+        std::string path_str(path);
+        JS_FreeCString(ctx, path);
+
+        try {
+            std::filesystem::create_directories(path_str);
+            JSValue response = JS_NewObject(ctx);
+            JS_SetPropertyStr(ctx, response, "success", JS_TRUE);
+            JS_SetPropertyStr(ctx, response, "path", JS_NewString(ctx, path_str.c_str()));
+            return response;
+        } catch (const std::exception& e) {
+            JSValue response = JS_NewObject(ctx);
+            JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+            JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, e.what()));
+            return response;
+        }
+    }, "mkdir", 1));
+
+    // fs.unlink(path) - Delete file
+    JS_SetPropertyStr(ctx, fs, "unlink", JS_NewCFunction(ctx, [](JSContext* ctx, JSValue this_val, int argc, JSValue* argv) -> JSValue {
+        if (argc < 1) {
+            return JS_ThrowTypeError(ctx, "fs.unlink requires path argument");
+        }
+
+        const char* path = JS_ToCString(ctx, argv[0]);
+        if (!path) {
+            return JS_ThrowTypeError(ctx, "path must be a string");
+        }
+        std::string path_str(path);
+        JS_FreeCString(ctx, path);
+
+        try {
+            if (!std::filesystem::exists(path_str)) {
+                JSValue response = JS_NewObject(ctx);
+                JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+                JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, ("File not found: " + path_str).c_str()));
+                return response;
+            }
+            std::filesystem::remove(path_str);
+            JSValue response = JS_NewObject(ctx);
+            JS_SetPropertyStr(ctx, response, "success", JS_TRUE);
+            return response;
+        } catch (const std::exception& e) {
+            JSValue response = JS_NewObject(ctx);
+            JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+            JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, e.what()));
+            return response;
+        }
+    }, "unlink", 1));
+
+    // fs.stat(path) - Get file info
+    JS_SetPropertyStr(ctx, fs, "stat", JS_NewCFunction(ctx, [](JSContext* ctx, JSValue this_val, int argc, JSValue* argv) -> JSValue {
+        if (argc < 1) {
+            return JS_ThrowTypeError(ctx, "fs.stat requires path argument");
+        }
+
+        const char* path = JS_ToCString(ctx, argv[0]);
+        if (!path) {
+            return JS_ThrowTypeError(ctx, "path must be a string");
+        }
+        std::string path_str(path);
+        JS_FreeCString(ctx, path);
+
+        try {
+            if (!std::filesystem::exists(path_str)) {
+                JSValue response = JS_NewObject(ctx);
+                JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+                JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, ("File not found: " + path_str).c_str()));
+                return response;
+            }
+
+            auto status = std::filesystem::status(path_str);
+            auto size = std::filesystem::is_regular_file(path_str) ? std::filesystem::file_size(path_str) : 0;
+            auto mtime = std::filesystem::last_write_time(path_str);
+            auto mtime_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+                mtime.time_since_epoch()
+            ).count();
+
+            JSValue response = JS_NewObject(ctx);
+            JS_SetPropertyStr(ctx, response, "success", JS_TRUE);
+            JS_SetPropertyStr(ctx, response, "size", JS_NewInt64(ctx, size));
+            JS_SetPropertyStr(ctx, response, "mtime", JS_NewInt64(ctx, mtime_ms));
+            JS_SetPropertyStr(ctx, response, "isFile", std::filesystem::is_regular_file(path_str) ? JS_TRUE : JS_FALSE);
+            JS_SetPropertyStr(ctx, response, "isDirectory", std::filesystem::is_directory(path_str) ? JS_TRUE : JS_FALSE);
+            return response;
+        } catch (const std::exception& e) {
+            JSValue response = JS_NewObject(ctx);
+            JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+            JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, e.what()));
+            return response;
+        }
+    }, "stat", 1));
+
+    JS_SetPropertyStr(ctx, smartbotic, "fs", fs);
+
     JS_SetPropertyStr(ctx, global, "smartbotic", smartbotic);
 
     // Console for compatibility
@@ -1570,6 +1927,14 @@ static JSValue js_imap_fetch(JSContext* ctx, JSValue this_val, int argc, JSValue
     }
     JS_FreeValue(ctx, mailbox_val);
 
+    // Get peek option (default false) - if true, don't mark as read
+    bool peek = false;
+    JSValue peek_val = JS_GetPropertyStr(ctx, options, "peek");
+    if (!JS_IsUndefined(peek_val) && JS_IsBool(peek_val)) {
+        peek = JS_ToBool(ctx, peek_val);
+    }
+    JS_FreeValue(ctx, peek_val);
+
     // Get credentials
     auto cred_result = script_ctx->credentials_get_imap(credential_id);
     if (cred_result.failed()) {
@@ -1583,7 +1948,7 @@ static JSValue js_imap_fetch(JSContext* ctx, JSValue this_val, int argc, JSValue
     imap::ImapCredentials imap_creds{cred.host, cred.port, cred.username, cred.password, cred.use_ssl};
     imap::ImapClient client(imap_creds);
 
-    auto fetch_result = client.fetch(mailbox, uid);
+    auto fetch_result = client.fetch(mailbox, uid, peek);
     if (fetch_result.failed()) {
         return JS_ThrowInternalError(ctx, "IMAP fetch failed: %s",
                                      fetch_result.error().message().c_str());