[Guide] How to Upload Files to the File Manager from Plugin Actions

I recently got asked this question and realized how poorly documented the answer is, and that the solution is scattered across the forum (and will soon be different, since Node 18 is rolling out): “how do you upload a base64 file to the file manager”?

I’ll just dump my experiences as a plugin developer for anyone who stumbles across them.

FYI

  • this is advice IMHO; I don’t pretend this is a perfect answer, and it may be incomplete and imperfect (such as my lack-of-knowledge surrounding how to upload private files, as noted below; I’ve never needed to learn how, so I just don’t know)
  • this is meant to be a good resource for anyone just starting out with plugin development and needs a quick answer to the question (as to how to get a raw file into the file manager from a plugin action)
  • I’m trying to save others the time and energy that took me to learn the information below, not tell others the correct way or best way (again, this is all IMHO)

Basics

  • Bubble uses an unprotected file upload endpoint /fileupload relative to the website home address (yes, I know, this is awful, I’ve warned them privately and publicly and Bubble just doesn’t care). Since the endpoint is here and here to stay, I choose to utilize it.
  • To upload a file, you need to POST a json to website-home-url/fileupload with the following config:
    • headers: set content-type to application/json
    • body: an object containing the filename and data (more below)
  • I recommend using Axios (more below) to handle your HTTP request
  • server-side actions (make a private plugin for yourself, even, if this is something you need for only your app) with the HTTP dependency (such as Axios) have proved the best for me
    • you can take in the website home url dynamically by creating a dynamic text input and just specifying in the documentation/help-text to please enter the dynamic bubble value 'website home url' (you can’t enforce this, but it will likely break if you/your plugin’s users don’t follow this
    • the website home url value will correctly adjust to your test environments versus your production environments and still work correctly

Structure of the POST Request

The body of the POST request should be a JSON object (note that, depending on which library you use to make the HTTP request, you may need to first JSON.strinigfy the data [required to use the fetch API (example Bubble implementation), optional in Axios: POST JSON with Axios - Mastering JS]) with the following structure (in typescript, for type demonstration):

interface BubbleFileUpload {
  name: string;
  contents: string;
  private: false; // as mentioned below, I never use the private option and do not have experience with it, so I always set it to false
}

Example

Let us assume we have:

  • a base64 uri for a PDF
  • our website home url is https://test-example.my-bubble-app.co
  • we want to use the filename invoice-12345.pdf

Our server-side plugin action would look like (this is a quick example that I’m pulling from several of my plugins for and is intended as a demonstration; I may have syntax and/or logic errors. Please read through the example and structure your calls similarly, not copy-and-paste. This also leaves numerous room for errors (such as failing to include file extension in filename, if taken in dynamically) so you’ll want to add your own error-handling, as robust as your use-case requires):

async function (properties, context) {
  const axios = require("axios");

  // assumes we defined a dynamic text field with the `name` option set to `website_home_url`
  const home = properties.website_home_url; // in our case, the exact string would be "https://test-example.my-bubble-app.co/", note the trailing `/` would be included automatically if using Bubble's `website home url` dynamic variable
  const uploadEndpoint = `${home}fileupload`; 

  // you'll likely get this from an external API call or take it as input like with `website_home_url`
  const filename = "invoice-12345.pdf";

  // you'll likely get this from an external API call or take it as input like with `website_home_url`
  const base64URI = "data:application/pdf;base64,JVBERi0xLjYK.............TSszDR5NIPSCx";
  const uploadableBase64Data = base64URI.split(';base64,').pop(); // we only want everything after the comma, and this removes `data:application/pdf;base64,` as we need

  // our BubbleFileUpload interface type from above, the data being POSTed
  const payload = {
    name: filename,
    contents: uploadableBase64Data,
    private: false // again, I don't pretend to know enough about this to explain it
  }
  
  const result = await axios.post(uploadEndpoint, payload);
  const fileURL = result.data;
  const httpsFileURL = `https:${fileURL}`; // Bubble will return the url without `https:` and just as `//website.com/...`, so we need to add that back in for most use-cases

  // make sure to define your return type to include a `url` key with type `text` in your plugin action
  return {
    url: httpsFileURL
  }

}

You would beed to check off the this action uses node modules box and have the following:

{
    "dependencies": {
        "axios": "latest"
    }
}

Note that I do not recommend using ‘latest’ for the version, but the Bubble editor often automatically puts this here and overrides whatever manual version you enter; I left ‘latest’ in this example for new developers who notice the Bubble editor is automatically and sometimes overridingly putting ‘latest’ for the version; stable/fixed versions that you test with are always better (you may swap out the latest for a version like 1.3.6 or 1.4.0, but like I said, be careful because the editor likes to auto-swap it for whatever reason, unless they’ve fixed this issue when you’re reading this, but they haven’t at time of writing).

Real-World Example

To see a real implementation of the above logic, check out the source code (use the convert svg to png action in the left, it’s the most concise example) for my Free Barcode & QR code Generator Plugin.

What I’m Leaving Out

My file is not base64, how do I upload it?

Simple: convert it to base64. Bubble only allows base64 file uploads (to my knowledge), so the easiest thing is to convert your Buffer/ArrayBuffer/ReadableStream/etc into a base64 data URI. StackOverflow is your best friend here (although ChatGPT may work for a quick-and-dirty fix, too).

Node 18 Changes

As I mentioned in my intro, part of this post is motivated by that fact that most of the forum posts on this topic use context.async, which is being deprecated. Instead, Bubble is now moving to allowing async functions, and @aaronsheldon did a great job documenting the many ways you can use this: Promise Patterns in the Plugin API Version 4. His methods, of course, have direct applications here, as file uploads are all asynchronous operations.

Private File Uploads

I don’t know, care to know, nor use the private file uploads. They serve a purpose, surely; I’ve just never needed them, so I’ve never had to learn how to use them (and write/debug the code for them). I did some quick searching, and it seems like you might need to set the private to true and pass in an attach_to (I’m guessing the Bubble object’s id property, but do your own research to confirm (here’s a quick start in the right direction: [SOLVED] How to upload an image using POST API - #36 by eric3).

Protected File Upload Endpoints

You can create protected endpoints by using backend workflow actions and setting privacy on who can run that action; basically, you would POST your data to that endpoint, with whatever authentication method you choose, then utilize a file upload. However, I’m very unfamiliar with this method (I only know of it from conversing with @vini_brito and how his PDF Conjurer achieves file uploads [note that this is also a free/open-source plugin, go look at the source code to see how Vine achieved file uploads if you want another example]). Since the /fileupload endpoint is what Bubble uses for the File Uploader, and it’s clearly here to stay, I myself choose to utilize it as the fastest and easiest way to develop plugin-action file-uploads (this doesn’t mean you have to do the same). My point is simply that even if you go through all of the effort to implement a protected file upload, there is no way (currently, as of writing this) to disable the /fileupload endpoint; if it’s there anyway, and we have no choice and no way to disable it, perhaps we use it?

Closing Thoughts

You’ll likely want to generate a good, robust, copy-and-pastable version of the above logic (similar to the one I created in my Real-World Example) to use across your plugins. I hope this gives you a good place to start with your own!

8 Likes

Well I guess I’ll give away my secret sauce. Here is how I restructured file uploads to work with the version 4 API, and in particular with the fetch global. This code assumes the base64 is passed in properties.contents, the filename in properties.filename, the website home in properties.homeurl, the privacy flag in properties.private, and the thing to scope the privacy to in properties.attachto. If the file is private but no scoping thing is supplied it defaults to the current user. Note that in version 4 you can safely return a promise, in this case containing the URL in the field savedfile. I have also exploited the new id() function for things. Finally pay carefully attention to the Accept header as Bubble returns the URL in a bare string:

// Ingest
const url = properties.homeurl + "fileupload";
const protocol = properties.private ? "" : "https:";
const payload = {
    name: properties.filename,
    contents: properties.contents
};

// Private and assigned
if (properties.private && properties.attachto) {
    payload.private = true;
    payload.attach_to = properties.attachtto.id();
}

// Private and default
else if (properties.private) {
    payload.private = true;
    payload.attach_to = context.currentUser.id();
}

// POST options
const options = {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "Accept": "text/plain"
    },
    body: JSON.stringify(payload)
};

// Digest
const response = fetch(url, options)
.then((response) => { return response.text(); })
.then((text) => { return { savedfile: protocol + text }; });

// Excrete
return response;
5 Likes

Does anyone know the hard limit for file size in plugin uploads? Am I missing something somewhere?

You just save me @nicholasrbarrow ! Thanks so much! How can a file upload be unprotected though? This is a huge security risk.

Can we somehow help to escalate this with the bubble.io team?

@jared.gibb sorry for the delay; the limit is 5 GB: Input Forms - Bubble Docs

I must’ve done something wrong! Thanks dude, will try again