Przeglądaj źródła

feat: images on the Bluesky node, and a raw binary request body to carry them

The AT Protocol blob upload takes the image bytes as the request body, not
as multipart. http.request could not send that: body goes through
JS_ToCString, which UTF-8 validates, so image bytes were silently replaced
and the upload would have succeeded while storing a corrupt blob. The only
binary-capable path was files, which wraps everything in a multipart
envelope that uploadBlob would have stored as the image itself.

So http.request gains bodyBase64, decoded to raw bytes in C++ the same way
the multipart file parts already are. The response side had solved this
long ago with dataBase64; the request side just never got the same
treatment. It also unlocks S3 PUT and WebDAV, both of which the node
roadmap wants.

On top of it the Bluesky node takes up to four images from the file store,
each with alt text, uploaded before the record is created. Size is checked
here rather than at the server, whose error names a byte count but not
which picture.

Documented in nodes.md, including that utils.base64Encode is UTF-8 based
and so is not a byte-preserving round trip for bytes assembled in
JavaScript - binary should come from the file store.
fszontagh 1 miesiąc temu
rodzic
commit
6849ed5b7d

+ 34 - 1
docs/nodes.md

@@ -296,9 +296,42 @@ const response = smartbotic.http.request({
     followRedirects: true
 });
 
-// Response: { status, headers, data }
+// Response: { status, headers, data, dataBase64 }
 ```
 
+#### Sending and receiving binary
+
+`body` is a text field. It is read as a UTF-8 string, so any byte sequence that
+is not valid UTF-8 is replaced on the way out - an image sent that way uploads
+successfully and arrives corrupt. There are two binary-safe paths instead.
+
+For an API that takes a bare binary payload, such as an S3 PUT or the AT
+Protocol blob upload, use `bodyBase64`. It is decoded to raw bytes and sent as
+the whole body, and it wins over `body` when both are given:
+
+```javascript
+const file = smartbotic.storage.downloadFile(fileId);
+
+smartbotic.http.request({
+    method: 'POST',
+    url: 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob',
+    headers: { 'Content-Type': 'image/jpeg' },
+    bodyBase64: file.data
+});
+```
+
+For an API that expects a form upload, use `files` with `formData`, which builds
+a multipart body - see `nodes/integration/telegram-send.js`.
+
+On the way back, `data` is also UTF-8 text and cannot carry binary. Read
+`dataBase64` for image or file downloads.
+
+One trap worth knowing: `utils.base64Encode` encodes a JavaScript string as
+UTF-8, so it is not a byte-preserving round trip for arbitrary bytes you build
+in JavaScript with `String.fromCharCode`. Anything at or above 0x80 becomes two
+bytes. Binary should come from the file store, where it never passes through a
+JavaScript string, rather than being assembled by hand.
+
 ### Storage (Database)
 
 ```javascript

+ 101 - 3
nodes/integration/bluesky.js

@@ -55,6 +55,26 @@ const configSchema = {
             title: 'Reply To',
             description: 'An at:// URI of a post to reply to. The node looks up its thread root itself'
         },
+        images: {
+            type: 'array',
+            title: 'Images',
+            description: 'Up to four images to attach. Each is uploaded from the file store before the post is created',
+            items: {
+                type: 'object',
+                properties: {
+                    fileId: {
+                        type: 'string',
+                        title: 'Stored File ID',
+                        description: 'Id of the image in the file store'
+                    },
+                    alt: {
+                        type: 'string',
+                        title: 'Alt Text',
+                        description: 'Description of the image for people using a screen reader. Bluesky shows an ALT badge when this is set'
+                    }
+                }
+            }
+        },
         uri: {
             type: 'string',
             title: 'Post URI',
@@ -83,7 +103,8 @@ const outputSchema = {
         cid: { type: 'string', description: 'Content id of the created post' },
         url: { type: 'string', description: 'Browsable https://bsky.app address of the created post' },
         handle: { type: 'string', description: 'Handle the post was made as' },
-        did: { type: 'string', description: 'DID of the posting account' }
+        did: { type: 'string', description: 'DID of the posting account' },
+        imageCount: { type: 'number', description: 'How many images were attached' }
     }
 };
 
@@ -289,6 +310,72 @@ function findMentions(text, service, timeout) {
     return found;
 }
 
+// Bluesky rejects anything over a megabyte on the blob upload, and the message
+// it returns names a byte count rather than the picture, so the check happens
+// here where the file is still identifiable.
+const MAX_IMAGE_BYTES = 1000000;
+const MAX_IMAGES = 4;
+
+function uploadImages(service, token, images, timeout) {
+    if (images.length > MAX_IMAGES) {
+        throw new Error('Bluesky: a post takes at most ' + MAX_IMAGES + ' images, got ' + images.length);
+    }
+
+    const uploaded = [];
+
+    for (const entry of images) {
+        const fileId = entry && entry.fileId;
+        if (!fileId) {
+            throw new Error('Bluesky: an image entry has no stored file id');
+        }
+
+        const info = smartbotic.storage.getFileInfo(fileId);
+        const file = smartbotic.storage.downloadFile(fileId);
+        if (!file || file.success !== true || !file.data) {
+            throw new Error('Bluesky: stored file ' + fileId + ' could not be read');
+        }
+
+        const mimeType = (info && info.mimeType) || 'image/jpeg';
+        if (mimeType.indexOf('image/') !== 0) {
+            throw new Error('Bluesky: stored file ' + fileId + ' is ' + mimeType +
+                ', which is not an image');
+        }
+
+        // file.data is base64, so its decoded length is what the server sees.
+        const padding = (file.data.match(/=+$/) || [''])[0].length;
+        const size = Math.floor(file.data.length / 4) * 3 - padding;
+        if (size > MAX_IMAGE_BYTES) {
+            throw new Error('Bluesky: image ' + ((info && info.name) || fileId) + ' is ' +
+                Math.round(size / 1024) + ' KB, over the ' +
+                Math.round(MAX_IMAGE_BYTES / 1024) + ' KB limit. Resize it before posting');
+        }
+
+        const blob = call({
+            method: 'POST',
+            url: service + '/xrpc/com.atproto.repo.uploadBlob',
+            headers: {
+                'Authorization': 'Bearer ' + token,
+                'Content-Type': mimeType
+            },
+            bodyBase64: file.data,
+            timeout: timeout,
+            what: 'uploading ' + ((info && info.name) || fileId)
+        });
+
+        if (!blob || !blob.blob) {
+            throw new Error('Bluesky: the upload of ' + ((info && info.name) || fileId) +
+                ' returned no blob reference');
+        }
+
+        uploaded.push({
+            alt: (entry && entry.alt) || '',
+            image: blob.blob
+        });
+    }
+
+    return uploaded;
+}
+
 function buildReply(service, token, replyTo, timeout) {
     const parent = parseAtUri(replyTo, 'Reply To');
     const record = call({
@@ -402,6 +489,14 @@ async function execute(config, input, context) {
         record.reply = buildReply(service, token, config.replyTo, timeout);
     }
 
+    const images = Array.isArray(config.images) ? config.images : [];
+    if (images.length > 0) {
+        record.embed = {
+            $type: 'app.bsky.embed.images',
+            images: uploadImages(service, token, images, timeout)
+        };
+    }
+
     const created = call({
         method: 'POST',
         url: service + '/xrpc/com.atproto.repo.createRecord',
@@ -424,14 +519,17 @@ async function execute(config, input, context) {
 
     const rkey = created.uri.substring(created.uri.lastIndexOf('/') + 1);
     const facetCount = record.facets ? record.facets.length : 0;
-    smartbotic.log.info('Bluesky: posted as ' + session.handle + ' with ' + facetCount + ' facets');
+    const imageCount = record.embed ? record.embed.images.length : 0;
+    smartbotic.log.info('Bluesky: posted as ' + session.handle + ' with ' + facetCount +
+        ' facets and ' + imageCount + ' images');
 
     return {
         uri: created.uri,
         cid: created.cid || '',
         url: 'https://bsky.app/profile/' + session.handle + '/post/' + rkey,
         handle: session.handle,
-        did: session.did
+        did: session.did,
+        imageCount: imageCount
     };
 }
 

+ 16 - 0
src/runner/engine/script_engine.cpp

@@ -998,6 +998,22 @@ static JSValue js_http_request(JSContext* ctx, JSValue this_val, int argc, JSVal
     }
     JS_FreeValue(ctx, body_val);
 
+    // bodyBase64 sends raw bytes. "body" above goes through JS_ToCString, which
+    // UTF-8 validates, so it silently mangles anything that is not text - an
+    // image posted that way uploads successfully and arrives corrupt. APIs that
+    // take a bare binary payload rather than multipart, such as the AT Protocol
+    // blob upload or an S3 PUT, need this path. It wins over "body" when both
+    // are given, since asking for raw bytes is the more specific request.
+    JSValue body_b64_val = JS_GetPropertyStr(ctx, options, "bodyBase64");
+    if (JS_IsString(body_b64_val)) {
+        const char* encoded = JS_ToCString(ctx, body_b64_val);
+        if (encoded) {
+            body = base64Decode(encoded);
+            JS_FreeCString(ctx, encoded);
+        }
+    }
+    JS_FreeValue(ctx, body_b64_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)) {

+ 18 - 0
tests/nodes/http-body-base64.json

@@ -0,0 +1,18 @@
+{
+  "name": "verify-http-body-base64",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Raw Body Is Sent", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "const payload = JSON.stringify({ username: 'admin', password: 'admin' });\n\nconst viaBase64 = smartbotic.http.request({\n    method: 'POST',\n    url: 'http://localhost:8090/api/v1/auth/login',\n    headers: { 'Content-Type': 'application/json' },\n    bodyBase64: smartbotic.utils.base64Encode(payload),\n    timeout: 15000\n});\n\nlet parsed = viaBase64.data;\nif (typeof parsed === 'string') { parsed = JSON.parse(parsed); }\n\nconst garbled = smartbotic.http.request({\n    method: 'POST',\n    url: 'http://localhost:8090/api/v1/auth/login',\n    headers: { 'Content-Type': 'application/json' },\n    bodyBase64: smartbotic.utils.base64Encode('{\"username\":\"admin\",\"password\":\"wrong\"}'),\n    timeout: 15000\n});\n\nreturn {\n    status: viaBase64.status,\n    gotToken: typeof parsed.accessToken === 'string' && parsed.accessToken.length > 20,\n    wrongPasswordRejected: garbled.status >= 400\n};"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
+  ],
+  "expect": {
+    "n2": {"status": "completed", "output": {"result": {
+      "status": 200,
+      "gotToken": true,
+      "wrongPasswordRejected": true
+    }}}
+  }
+}