DEV Community

loading...
Cover image for How I built a browser extension with Dropbox as a DB

How I built a browser extension with Dropbox as a DB

kontsedal profile image Bohdan ・5 min read

I like to collect funny images and use them in chats, so I decided to build a Chrome extension to ease my life. Functionality is next:

  • Upload images from a computer
  • Upload images by clicking on special browser context menu action
  • Set tags to images and perform a search

Here is a video demonstration

Here is a repo (code smell alert!)

Accessing Dropbox API

Apparently, we need some DB to store this info, also we need some storage to upload images there. So I've decided to kill two birds with one stone and store images and DB as JSON in some file hosting service (Dropbox, OneDrive, GoogleDrive etc).

After digging in these service's docs I realized that most of them require a public URL to redirect a user after authorization success, and this URL will contain a token to work with their API. It didn't work for me, because extensions live on their browser protocol chrome-extension:// which is obviously not supported.

And then I've found out that Dropbox has another way of authentication for users.
We just need to open the next URL
https://www.dropbox.com/oauth2/authorize?response_type=code&client_id={{YOUR_APP_KEY}}

Dropbox initial page

It will ask a user to create an isolated folder for your app and in the end, opens the page with a special code in it.

page with a Dropbox code

We need to take this code and send it to Dropbox API with your app secret code. In return, you get a token to work with this newly created folder.

Extracting part is pretty easy, all we need is to inject a special script to this page. To do that, we need to define it in the manifest.json file:

 "content_scripts": [
    {
      "matches": ["https://www.dropbox.com/1/oauth2/authorize_submit"],
      "js": ["dropboxCodeExtractor.js"]
    }
  ],
Enter fullscreen mode Exit fullscreen mode

dropboxCodeExtractor.js:

function getToken() {
  const tokenSelector = "[data-token]";
  const tokenAttr = "data-token";
  const element = document.querySelector(tokenSelector);
  if (element) {
    const code = element.getAttribute(tokenAttr);
    CommunicationService.authenticate(code);
  }
}

window.onload = getToken;

Enter fullscreen mode Exit fullscreen mode

Now we need to send this code with our application secret key to the Dropbox API. I could do that directly from the extension code, but in this case, we'd have an application secret key inside our client code, which is bad. So I decided to create a simple lambda function that takes this code and sends it to Dropbox with an application secret. Here is the code:

const axios = require("axios");
const URLSearchParams = require("url").URLSearchParams;

exports.auth = async event => {
  let body = JSON.parse(event.body);
  const params = new URLSearchParams();
  params.append("grant_type", "authorization_code");
  params.append("code", body.code);
  params.append("client_id", process.env.DROPBOX_APP_KEY);
  params.append("client_secret", process.env.DROPBOX_APP_SECRET);

  try {
    let token = await axios
      .post("https://api.dropbox.com/oauth2/token", params, {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
      })
      .then(async response => {
        return response.data.access_token;
      });
    return {
      statusCode: 200,
      body: JSON.stringify({ token }),
    };
  } catch (error) {
    console.error(error);
    return {
      statusCode: 500,
      body: JSON.stringify({
        error: "Failed to get token",
      }),
    };
  }
};

Enter fullscreen mode Exit fullscreen mode

Here is the whole flow on the diagram:
dropbox auth flow

Amazing, now we can upload anything we need.

Shared state between popup and background scripts

For those who don't know, an extension doesn't have a single runtime as most web applications. It has:

  • background script - script that runs in the background 😀 and works all the time(if you don't disable that in the manifest.json)
  • popup script - script that runs in the popup when you click on the extension icon
  • content script - a script which you inject directly into particular pages (as in the code extraction part above)

I like to use Redux(Redux Toolkit) and this runtime separation is a problem because we don't have a single instance of the store. Yes, we can initialize the store in one place(background script) and then send an event to all runtimes when it changes, but this would lead to full render on each store change because it'd be a new state object all the time. Also, we would be able to dispatch actions only from one place.

So I decided to make a dirty trick. Here is the logic:

  1. We initialize store in the background script
  2. When a user opens the popup, it sends an event to the background to get a current state and sets it to its own store.
  3. We substitute a dispatch function for a popup page. When a user dispatches something it does nothing with a local store and just sends an event to the background script, the background script dispatches it and sends it back to the popup, and only then popup applies an action to its store. It creates a sort of master-slave relation between stores in several runtimes.

Also, on each state change, the background script uploads it to the Dropbox

Alt Text

Here is the code of described logic:

const populateBackgroundActionsMiddleware = () => (next) => (action) => {
  CommunicationService.dispatch(action);
  next(action);
};

export const getStore = (isBackground) => {
  const middleware = compact([
    isBackground && populateBackgroundActionsMiddleware
  ]);

  const store = configureStore({
    reducer: slice.reducer,
    middleware,
  });

  if (isBackground) {
    CommunicationService.onGetState((respond) => {
      respond(store.getState());
    });
    return store;
  }

  const originalDispatch = store.dispatch;
  store.dispatch = (action) => {
    CommunicationService.safeDispatch(action);
  };
  CommunicationService.onDispatch((action) => {
    originalDispatch(action);
  });
  CommunicationService.getState((newState) =>
    originalDispatch(slice.actions.setState(newState))
  );
  return store;
}
Enter fullscreen mode Exit fullscreen mode

Now we can use Redux as if we use the single runtime 🎉 🎉 🎉

Data loss protection

As was mentioned, background script uploads state to Dropbox on each change and there is a high chance of data loss if a user uses two computers simultaneously. It is because we download a state from Dropbox only on the first run, then we upload it to the Dropbox when it changes. To solve this, we generate a unique id for each user session. Then, when a user uploads state to the Dropbox, we also upload the small file called "session.json" with the session id. Before each state upload, we download this session file and compare ID with the current one, if they are different, we download a state from the Dropbox and merge it with the current state, and then upload. This whole flow makes extension slow(on adding images and tags, not for search), so it is disabled by default and a user should enable it in the settings page.

There is the diagram of the flow:

remote state flow

Conclusion

Pros:

  • it's free and fun
  • makes users data fully private, because they work directly with own Dropbox

Cons:

  • utilizes a lot of bandwidth in case of frequent data changes
  • makes an application slow if DB is huge

This is my first article ever, hope it will help somebody, would appreciate any feedback. Cheers

Discussion (0)

pic
Editor guide