Просмотр исходного кода

feat: publish a generated image to zsebhoki, and the three gaps that stopped a plain HTTP node doing it

A sub-workflow that takes an image and two languages of text and files a
post on the blog: it uploads the picture to the media bucket, creates the
post pointing at it, and returns the id, slug and public URLs. Draft by
default - the site needs both languages before a post should be published,
and a half-translated one going live by accident is not a mistake worth
risking.

It is built from the existing nodes. Three things had to be true for that,
none of them specific to this blog:

- An api_key credential could only send "HeaderName: key". Anything of
  the "Authorization: <word> <key>" shape - which is most APIs - could
  only be spelled by the bearer type, and that hardcodes the word
  "Bearer". Api-key credentials now carry a prefix, so Authorization plus
  "API" produces what this site wants, and the form shows the header that
  will be sent rather than leaving it to be guessed.
- The HTTP node could only send text. A text body is UTF-8 validated on
  the way out, so an image sent that way uploads successfully and arrives
  corrupt. It can now send raw bytes from a binary object on its input -
  what the image generators, downloads and attachment extractors all
  produce.
- Content-Type was only settable by hand-editing a headers map. It is a
  field now, with the common types listed and an exact box for anything
  else, and a stated precedence: what you chose, then a header you typed,
  then what the binary says it is, then the JSON guess.

Verified against the live site: the image uploads (27078 bytes, correct
mime type, and fetchable from its public URL), the post is created, and
its id comes back. Two test drafts were created and deleted again.

Two things worth knowing, both found by doing this rather than by reading:
- The create response returns the row flat. The site's own api.md example
  pipes it through .data.id, which is empty against the live server. The
  output reads both shapes.
- A code node cannot use await: its body is built with new Function, so
  await is a syntax error there. smartbotic.http.request works
  synchronously in that context.
fszontagh 1 месяц назад
Родитель
Сommit
6f23138523

+ 40 - 10
lib/credentials/credential_store.cpp

@@ -106,7 +106,7 @@ Result<CredentialInfo> CredentialStore::create(const CreateCredentialRequest& re
                 return Error(ErrorCode::InvalidArgument, "API key value is required");
                 return Error(ErrorCode::InvalidArgument, "API key value is required");
             }
             }
             data_to_encrypt = data.toJson();
             data_to_encrypt = data.toJson();
-            public_data = {{"header_name", data.header_name}};
+            public_data = {{"header_name", data.header_name}, {"value_prefix", data.value_prefix}};
             break;
             break;
         }
         }
         case CredentialType::OAuth2: {
         case CredentialType::OAuth2: {
@@ -251,26 +251,56 @@ Result<void> CredentialStore::update(const std::string& id, const UpdateCredenti
         nlohmann::json data_to_encrypt;
         nlohmann::json data_to_encrypt;
         nlohmann::json public_data;
         nlohmann::json public_data;
 
 
+        // Merged over what is stored rather than replacing it. Every branch
+        // below parses the request into a typed struct and re-encrypts the
+        // result, so a field the caller left out came back as an empty string
+        // and overwrote the secret with nothing. The edit form says
+        // "(unchanged)" next to a blank password box - so renaming a
+        // credential silently destroyed the very thing it exists to hold.
+        //
+        // A key present but empty means "leave it alone"; a key present with a
+        // value means "use this". Booleans and numbers pass through either way,
+        // so a port of 0 or an SSL flag of false can still be set.
+        nlohmann::json merged = request.data;
+        {
+            auto existing = decryptData(doc.encrypted_data);
+            if (existing.ok() && existing.value().is_object()) {
+                const nlohmann::json& stored = existing.value();
+                for (auto it = stored.begin(); it != stored.end(); ++it) {
+                    const bool absent = !merged.contains(it.key());
+                    const bool blank = !absent && merged[it.key()].is_string() &&
+                                       merged[it.key()].get<std::string>().empty();
+                    if (absent || blank) {
+                        merged[it.key()] = it.value();
+                    }
+                }
+            } else {
+                LOG_WARN("Could not read the stored secret for {} while updating it - "
+                         "anything the caller left out cannot be preserved", id);
+            }
+        }
+        const nlohmann::json& request_data = merged;
+
         switch (doc.metadata.type) {
         switch (doc.metadata.type) {
             case CredentialType::Basic: {
             case CredentialType::Basic: {
-                auto data = BasicAuthData::fromJson(request.data);
+                auto data = BasicAuthData::fromJson(request_data);
                 data_to_encrypt = data.toJson();
                 data_to_encrypt = data.toJson();
                 public_data = {{"username", data.username}};
                 public_data = {{"username", data.username}};
                 break;
                 break;
             }
             }
             case CredentialType::Bearer: {
             case CredentialType::Bearer: {
-                auto data = BearerTokenData::fromJson(request.data);
+                auto data = BearerTokenData::fromJson(request_data);
                 data_to_encrypt = data.toJson();
                 data_to_encrypt = data.toJson();
                 break;
                 break;
             }
             }
             case CredentialType::ApiKey: {
             case CredentialType::ApiKey: {
-                auto data = ApiKeyData::fromJson(request.data);
+                auto data = ApiKeyData::fromJson(request_data);
                 data_to_encrypt = data.toJson();
                 data_to_encrypt = data.toJson();
-                public_data = {{"header_name", data.header_name}};
+                public_data = {{"header_name", data.header_name}, {"value_prefix", data.value_prefix}};
                 break;
                 break;
             }
             }
             case CredentialType::OAuth2: {
             case CredentialType::OAuth2: {
-                auto data = OAuth2Data::fromJson(request.data);
+                auto data = OAuth2Data::fromJson(request_data);
                 data_to_encrypt = data.toJson();
                 data_to_encrypt = data.toJson();
                 public_data = {
                 public_data = {
                     {"grant_type", data.grant_type},
                     {"grant_type", data.grant_type},
@@ -282,7 +312,7 @@ Result<void> CredentialStore::update(const std::string& id, const UpdateCredenti
                 break;
                 break;
             }
             }
             case CredentialType::Imap: {
             case CredentialType::Imap: {
-                auto data = ImapData::fromJson(request.data);
+                auto data = ImapData::fromJson(request_data);
                 data_to_encrypt = data.toJson();
                 data_to_encrypt = data.toJson();
                 public_data = {
                 public_data = {
                     {"host", data.host},
                     {"host", data.host},
@@ -293,7 +323,7 @@ Result<void> CredentialStore::update(const std::string& id, const UpdateCredenti
                 break;
                 break;
             }
             }
             case CredentialType::Smtp: {
             case CredentialType::Smtp: {
-                auto data = SmtpData::fromJson(request.data);
+                auto data = SmtpData::fromJson(request_data);
                 data_to_encrypt = data.toJson();
                 data_to_encrypt = data.toJson();
                 public_data = {
                 public_data = {
                     {"host", data.host},
                     {"host", data.host},
@@ -306,7 +336,7 @@ Result<void> CredentialStore::update(const std::string& id, const UpdateCredenti
                 break;
                 break;
             }
             }
             case CredentialType::Mysql: {
             case CredentialType::Mysql: {
-                auto data = MysqlData::fromJson(request.data);
+                auto data = MysqlData::fromJson(request_data);
                 data_to_encrypt = data.toJson();
                 data_to_encrypt = data.toJson();
                 public_data = {
                 public_data = {
                     {"host", data.host},
                     {"host", data.host},
@@ -318,7 +348,7 @@ Result<void> CredentialStore::update(const std::string& id, const UpdateCredenti
                 break;
                 break;
             }
             }
             case CredentialType::Postgresql: {
             case CredentialType::Postgresql: {
-                auto data = PostgresqlData::fromJson(request.data);
+                auto data = PostgresqlData::fromJson(request_data);
                 data_to_encrypt = data.toJson();
                 data_to_encrypt = data.toJson();
                 public_data = {
                 public_data = {
                     {"host", data.host},
                     {"host", data.host},

+ 13 - 2
lib/credentials/credential_types.cpp

@@ -109,7 +109,8 @@ HttpAuth BearerTokenData::toHttpAuth() const {
 nlohmann::json ApiKeyData::toJson() const {
 nlohmann::json ApiKeyData::toJson() const {
     return {
     return {
         {"headerName", header_name},
         {"headerName", header_name},
-        {"keyValue", key_value}
+        {"keyValue", key_value},
+        {"valuePrefix", value_prefix}
     };
     };
 }
 }
 
 
@@ -117,13 +118,23 @@ ApiKeyData ApiKeyData::fromJson(const nlohmann::json& j) {
     ApiKeyData data;
     ApiKeyData data;
     data.header_name = j.value("headerName", j.value("header_name", "X-API-Key"));
     data.header_name = j.value("headerName", j.value("header_name", "X-API-Key"));
     data.key_value = j.value("keyValue", j.value("key_value", ""));
     data.key_value = j.value("keyValue", j.value("key_value", ""));
+    data.value_prefix = j.value("valuePrefix", j.value("value_prefix", ""));
     return data;
     return data;
 }
 }
 
 
 HttpAuth ApiKeyData::toHttpAuth() const {
 HttpAuth ApiKeyData::toHttpAuth() const {
     HttpAuth auth;
     HttpAuth auth;
     auth.header_name = header_name;
     auth.header_name = header_name;
-    auth.header_value = key_value;
+    // A prefix given without a trailing space is what somebody means every
+    // time - "API" and "API " are the same intention, and joining them without
+    // one produces a header no server accepts.
+    if (value_prefix.empty()) {
+        auth.header_value = key_value;
+    } else if (value_prefix.back() == ' ') {
+        auth.header_value = value_prefix + key_value;
+    } else {
+        auth.header_value = value_prefix + " " + key_value;
+    }
     return auth;
     return auth;
 }
 }
 
 

+ 6 - 0
lib/credentials/credential_types.hpp

@@ -56,6 +56,12 @@ struct BearerTokenData {
 struct ApiKeyData {
 struct ApiKeyData {
     std::string header_name;  // e.g., "X-API-Key" or "Authorization"
     std::string header_name;  // e.g., "X-API-Key" or "Authorization"
     std::string key_value;
     std::string key_value;
+    // What goes in front of the key in the header, if anything. Plenty of APIs
+    // want "Authorization: Bearer <key>", "Authorization: API <key>" or
+    // "Authorization: Token <key>", and without this the only way to send one
+    // was to bake the word into the secret itself - where nobody can see it
+    // and every rotation has to remember it.
+    std::string value_prefix;
 
 
     nlohmann::json toJson() const;
     nlohmann::json toJson() const;
     static ApiKeyData fromJson(const nlohmann::json& j);
     static ApiKeyData fromJson(const nlohmann::json& j);

+ 93 - 4
nodes/core/http-request.js

@@ -50,7 +50,52 @@ const configSchema = {
     body: {
     body: {
       type: 'string',
       type: 'string',
       title: 'Body',
       title: 'Body',
-      description: 'Request body (for POST/PUT/PATCH)'
+      description: 'Request body (for POST/PUT/PATCH). Supports {{variable}} interpolation.'
+    },
+    contentType: {
+      type: 'string',
+      title: 'Content Type',
+      description: 'What the body is. Left on Automatic it is JSON for a text body, and whatever a binary object says it is - which is right often enough, and wrong exactly when it matters.',
+      enum: [
+        '',
+        'application/json',
+        'application/x-www-form-urlencoded',
+        'text/plain',
+        'text/html',
+        'application/xml',
+        'text/csv',
+        'application/octet-stream',
+        'image/png',
+        'image/jpeg',
+        'image/webp',
+        'other'
+      ],
+      enumLabels: [
+        'Automatic',
+        'JSON',
+        'Form (urlencoded)',
+        'Plain text',
+        'HTML',
+        'XML',
+        'CSV',
+        'Binary (octet-stream)',
+        'PNG',
+        'JPEG',
+        'WebP',
+        'Other...'
+      ],
+      default: ''
+    },
+    contentTypeCustom: {
+      type: 'string',
+      title: 'Content Type (exact)',
+      description: 'The header value to send, for anything not in the list',
+      showWhen: { field: 'contentType', value: 'other' }
+    },
+    bodyFrom: {
+      type: 'string',
+      title: 'Send Binary From',
+      description: 'Send raw bytes instead of the Body above - the path to a binary object on the input, e.g. "file" for the file a download or an image generator produced. The text body goes through UTF-8 validation, which silently corrupts an image; this path does not. Leave empty to send the Body.'
     },
     },
     timeout: {
     timeout: {
       type: 'number',
       type: 'number',
@@ -267,8 +312,21 @@ async function execute(config, input, context) {
     headers[auth.headerName] = auth.headerValue;
     headers[auth.headerName] = auth.headerValue;
   }
   }
 
 
-  // Set default content-type for POST/PUT/PATCH
-  if (['POST', 'PUT', 'PATCH'].includes(config.method) && !headers['Content-Type']) {
+  // What the body is, in order of how deliberate the answer is: what the
+  // person chose, then what they typed into the headers themselves, then what
+  // the payload says about itself, and only then a guess.
+  const chosenContentType = config.contentType === 'other'
+    ? (config.contentTypeCustom || '')
+    : (config.contentType || '');
+  const callerSetType = Object.keys(headers).some((k) => k.toLowerCase() === 'content-type');
+
+  if (chosenContentType) {
+    // Replace whatever case the header was written in rather than sending two.
+    for (const key of Object.keys(headers)) {
+      if (key.toLowerCase() === 'content-type') delete headers[key];
+    }
+    headers['Content-Type'] = chosenContentType;
+  } else if (['POST', 'PUT', 'PATCH'].includes(config.method) && !callerSetType) {
     headers['Content-Type'] = 'application/json';
     headers['Content-Type'] = 'application/json';
   }
   }
 
 
@@ -287,6 +345,36 @@ async function execute(config, input, context) {
     }
     }
   }
   }
 
 
+  // Raw bytes, when asked for. A binary object here is what the download mode
+  // of this node, the image generators and the attachment extractors all
+  // produce, so an image can go straight from one to the other without a script
+  // in between.
+  let bodyBase64 = null;
+  if (config.bodyFrom) {
+    let value = data;
+    for (const key of String(config.bodyFrom).split('.')) {
+      value = value == null ? undefined : value[key];
+    }
+    if (value == null) {
+      throw new Error(`Send Binary From: nothing at "${config.bodyFrom}" on the input`);
+    }
+    // Either a binary object as the other nodes pass it around, or bare base64.
+    bodyBase64 = typeof value === 'string' ? value : value.data;
+    if (typeof bodyBase64 !== 'string' || !bodyBase64) {
+      throw new Error(`Send Binary From: "${config.bodyFrom}" holds no base64 data`);
+    }
+    // A binary object usually knows what it is; saying so beats letting the
+    // server guess from the bytes. This overrides the application/json default
+    // applied above for POST and PUT - that default is a guess for a text body
+    // and would label a PNG as JSON - but never a Content-Type the caller set
+    // themselves.
+    const typeWasChosen = !!chosenContentType || !!(config.headers && Object.keys(config.headers)
+      .some((k) => k.toLowerCase() === 'content-type'));
+    if (typeof value === 'object' && value.mimeType && !typeWasChosen) {
+      headers['Content-Type'] = value.mimeType;
+    }
+  }
+
   smartbotic.log.info(`HTTP ${config.method} ${url}`);
   smartbotic.log.info(`HTTP ${config.method} ${url}`);
 
 
   try {
   try {
@@ -294,7 +382,8 @@ async function execute(config, input, context) {
       method: config.method,
       method: config.method,
       url: url,
       url: url,
       headers: headers,
       headers: headers,
-      body: body,
+      body: bodyBase64 ? undefined : body,
+      bodyBase64: bodyBase64 || undefined,
       timeout: config.timeout,
       timeout: config.timeout,
       followRedirects: config.followRedirects,
       followRedirects: config.followRedirects,
       // Retrying is off unless asked for, and the waiting is done by the HTTP
       // Retrying is off unless asked for, and the waiting is done by the HTTP

+ 21 - 1
webui/src/pages/CredentialsPage.tsx

@@ -389,6 +389,7 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
 
 
   // API Key fields
   // API Key fields
   const [headerName, setHeaderName] = useState((pd.header_name as string) || 'X-API-Key')
   const [headerName, setHeaderName] = useState((pd.header_name as string) || 'X-API-Key')
+  const [valuePrefix, setValuePrefix] = useState((pd.value_prefix as string) || '')
   const [apiKey, setApiKey] = useState('')
   const [apiKey, setApiKey] = useState('')
 
 
   // OAuth2 fields
   // OAuth2 fields
@@ -458,7 +459,7 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
       case 'bearer':
       case 'bearer':
         return { token }
         return { token }
       case 'api_key':
       case 'api_key':
-        return { header_name: headerName, key_value: apiKey }
+        return { header_name: headerName, key_value: apiKey, value_prefix: valuePrefix }
       case 'oauth2':
       case 'oauth2':
         return {
         return {
           grant_type: grantType,
           grant_type: grantType,
@@ -765,6 +766,25 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
                     className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
                     className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
                   />
                   />
                 </div>
                 </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Prefix
+                  </label>
+                  <input
+                    type="text"
+                    value={valuePrefix}
+                    onChange={(e) => setValuePrefix(e.target.value)}
+                    placeholder="none - e.g. Bearer, Token, API"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                  {/* The word in front of the key. Without it the only way to
+                      send "Authorization: API <key>" was to bake the word into
+                      the secret, where nobody can see it and every rotation has
+                      to remember it. */}
+                  <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
+                    Sent as <code className="font-mono">{headerName || 'Header'}: {valuePrefix ? valuePrefix.trim() + ' ' : ''}&lt;key&gt;</code>
+                  </p>
+                </div>
                 <div>
                 <div>
                   <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
                   <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
                     API Key *
                     API Key *