DEV Community

Justin Hunter
Justin Hunter

Posted on • Originally published at Medium on

Build a Zero Dependency Notes App on IPFS — Part I

This is part one of a two-part tutorial. Part one focuses on authentication and part two focuses on posting and fetching content from IPFS.

Emerging Web 3.0 technologies promise a return of user control — not just of data, but of the internet. There was a time when the web we used was not ruled by an oligopoly. This was after we broke away from the walled-garden-model that was AOL. We became free, connected, engaged. But then, things began to close again. Amazon rose. Google became a power vacuum. Facebook decided it would be AOL 2.0. Now, with decentralized web technologies, we have an opportunity to take back the web we once had. IPFS is just one solution within the Web 3.0 space, but it offers us a glimpse at what may be possible. Today, we’re going to build a simple notes app, but first, let’s understand what IPFS is.

IPFS — Interplanetary File System — hopes to replace the http protocol as the way the world fetches content. The biggest difference between http and IPFS is that https is location-based and IPFS is content-based. But what does that actually mean? It means that in the web of today, you must fetch your content from a single-source (increasingly and AWS S3 bucket some company has stood up). IPFS being content-based means that you fetch a file based on its content. You tell the IPFS peer-to-peer network that you are looking for a file with the exact content, and the first peer in the network that has that content will return it. Many peers may have this content which provides more censorship resistance than the web of today, but it also provides more fault tolerance than the web of today. If that S3 bucket I mentioned before becomes inaccessible for any reason, you can’t fetch the content. However, if a peer that holds the content you are looking for on IPFS becomes inaccessible, the network will continue searching until a peer that does hold that content is accessible.

We could spend an entire article on what IPFS does and how it can help the web, but there are already plenty of articles about just that. Now that you have a basic understanding of IPFS, there’s just one more thing we need to have a grasp of before we can begin the tutorial:

Content Pinning.

When content is added to the IPFS network, it is not meant to be permanent. Much like the old music transfer services of the 90s, the IPFS network will continue to host content as long as there is demand for that content. However, when there is a dip in demand, that content can and likely will be garbage collected. But what if you need that file to remain accessible, even if you are the only one accessing it, even if you only access it once per year?

This is where content pinning comes into play. You can run an IPFS node and pin content yourself, but there are some drawbacks. A) It’s not easy for a novice to do, and B ) If you run that node on a local machine, you won’t have access to the content unless that machine is always connected to the internet.

This is why content pinning services have grown around the rise of IPFS. One such service is Pinata. Pinata runs multiple IPFS nodes and provides developers a simple API to plug into to store and fetch content on IPFS. They will also pin content to ensure its availability.

Today, we are going to use a combination of my own product, SimpleID, and Pinata to build a notes app on IPFS with zero dependencies. Let’s get started.

Getting Set Up

We’re going to build this app in vanilla JavaScript using the SimpleID API to make it as accessible to developers of the many different JavaScript frameworks as possible. So, you’ll need the following:

  • A text editor

That’s it.

Let’s get set up, by creating your project. From the terminal, create a directory wherever you’d like it to be in your machine’s folder hierarchy. For me, that’s in my /documents/development folder.

First, make a directory to hold the application: mkdir ipfs-notes. You can call the app something else if you’d like.

Then, switch into that directory: cd ipfs-notes. Once that’s done, we’re ready to go. Nothing else to install since we are using API endpoints that can be called from the browser.

Now, you’ll need to go get a SimpleID API key. You can sign up for the SimpleID free plan here. When you sign up, you’ll be asked to verify your email address. Do that, and then you’ll be able to create a project and select the modules you’d like to include in your project. Click the Create Project button, give it a name, and provide a project URL (note: for security reasons, the URL must be unique, so if you don’t have one, you can make one up or provide the URL you plan to deploy to eventually). Click the edit modules button and select Blockstack for your authentication module. Switch over to Storage Modules and choose Pinata. Hit Save in the bottom right, then go back to the Account page by clicking the menu in the top-left and choosing Account.

The last thing you need to do is grab your Developer ID and your API Key. Click View Project and you’ll see both of those there. Save them somewhere because we’re about ready to code!

Starting the project

From within Terminal in your project folder, let’s create just a single file:

touch index.html

For this tutorial, we are going to write all our JavaScript in a single JavaScript file and importing them into our index.html file with a script tag. This is going to be a Single Page App, so we will only need one html file.

Now, you can open your project in your favorite code editor and we can start coding. In the index.html file, let’s add some standard HTML boilerplate:

<!DOCTYPE html>
<html>
   <head>
     <meta charset="UTF-8">
     <title>IPFS Notes</title>
   </head>
   <body>
     <!--The app code will go here-->

     <script type="application/javascript" src="main.js"></script>
   </body>
</html>
Enter fullscreen mode Exit fullscreen mode

In the root of your project directory create a file called main.js. In this project’s entirety, you will have just two files:

  • index.html
  • main.js

Let’s make sure everything is working. In your main.js file, add this:

testing();

function testing() {
  console.log("testing");
}
Enter fullscreen mode Exit fullscreen mode

Now, open your index.html file and it should load up in your browser. Open the console and you should see the word “testing.”

OK, now let’s add some real code. Before we edit any of the HTML, let’s work on the JavaScript. From the SimpleID Docs, we can see that we need to set up our functions and initial configuration. So, delete the test code in your main.js file and add this:

const config = {
  apiKey: ${yourApiKey},
  devId: ${yourDevId},
  authProviders: ['blockstack'], 
  storageProviders: ['blockstack', 'pinata'], 
  appOrigin: "https://yourapp.com", 
  scopes: ['publish\_data', 'store\_write', 'email'] 
}

async function signUp() {

//sign up code here
}


async function signIn() {

//sign in code here
}
Enter fullscreen mode Exit fullscreen mode

Ok, so what’s going on here? Well, we declared a global variable which is an object housing our configuration options. In that config object, we include our Developer ID and our API Key. But we also need to tell SimpleID what authentication modules and storage modules we are using (remember: we are using Blockstack for auth and Pinata for storage). The appOrigin should match whatever you entered when creating your project and generating your API Key. The scopes array just grants you access to certain functionality.

You’ll notice the async keyword in front of both of our functions. We’re going to make http requests that result in a promise and we need to wait for the promise to resolve before executing additional code, so we need to make these functions async/await functions. You’ll see what I mean soon.

Now, we need to create a sign up form and pass the results of that form into the signUp function. For me, I think the sign up form page is what should show first if a user is not logged in, so I’m going to create a variable that specifies that. At the top of JavaScript code in between the script tags I’m adding this:

let startPage = "signup";

While we’re there, we can also create two more variables that we’ll need.

let loggedIn = false;
let loading = false;
Enter fullscreen mode Exit fullscreen mode

Now, let’s create our sign up form in the body section of your html:

<div style="display: none;" id="signup-form">
  <form onsubmit="event.preventDefault()">
    <input type="text" id="username-signup" placeholder="username" /><br/>
    <label>Username</label><br/>
    <input type="password" id="password-signup" placeholder="password" /><br/>
    <label>Password</label><br/>
    <input type="text" id="email-signup" placeholder="email" /><br/>     
    <label>Email</label><br/>
    <button onclick="signUp(event)" type="submit">Sign Up</button>
  <form>
</div>
Enter fullscreen mode Exit fullscreen mode

Pretty basic form.We will style this up later, but you will notice a style attribute in the opening div. Why are we not displaying this form? Well, we need to conditionally display it based on a few variables:

  • Is the user logged in already? Don’t display it.
  • Has the user decided to login and not sign up? Don’t display it.
  • Is the page loading? Don’t display it.

How do we make use of the variables we created earlier to conditionally render this form? Well, before we do that, let’s create our login form as well. We can just copy the sign up form and make a few tweaks. You should end up with something like this:

<div style="display: none;" id="signin-form">
  <form onsubmit="event.preventDefault()">
    <input type="text" id="username-signin" placeholder="username" /><br/>
    <label>Username</label><br/>
    <input type="password" id="password-signin" placeholder="password" /><br/>
    <label>Password</label><br/>
    <button onclick="signIn(event)" type="submit">Sign In</button>
  <form>
</div>
Enter fullscreen mode Exit fullscreen mode

We don’t need the email field when a user is signing in. We also need to change the element ids to be specific to sign in, not sign up. We also updated the function to be called on form submission and button label. Pretty simple.

Now, we can make those variables we created do some work for us. We want this new function that will handle the updates to be called every time the page is loaded and every time certain actions are taken. So let’s do that like this. At the top of your JavaScript code right after all the variables are declared, let’s call a function called, pageLoad() and create the actual function below it:

pageLoad();

function pageLoad() {
  console.log("page loaded");
}
Enter fullscreen mode Exit fullscreen mode

If you save your file and reload the index.html file in your browser, you should see in console “page loaded”. That’s not what we want to have happen, though. Let’s make this code actually worksfor us. Time for conditional logic:

pageLoad();

function pageLoad() {
  if(loading) {
    //Need to render a loading screen here.
  } else if(loggedIn) {
    //Need to render the page for logged in users
  } else {
    //Here we are deciding whether to show the sign up or sign in form
    if(startPage === "signup") {
      document.getElementById('signin-form').style.display = "none";
      document.getElementById('signup-form').style.display = "block";
    }  
  }

}
Enter fullscreen mode Exit fullscreen mode

If we save that and reload our page, we should now see the sign up page! But what if the user wants to sign in? How do we handle that. Simple! We can set a couple buttons up that let the user switch between the sign in and sign up forms. Above your sign up and sign in forms in the body section of you html, add these buttons, wrapped in a div we can hide and display as we need:

<div id="auth-buttons">
  <button onclick="changeForm('signup')">Sign Up</button>
  <button onclick="changeForm('signin')">Sign In</button>
</div>
Enter fullscreen mode Exit fullscreen mode

Then we can create another function below our pageLoad function called changeForm.

function changeForm(page) {
  if(page === "signin") {
    document.getElementById('signin-form').style.display = "block";
    document.getElementById('signup-form').style.display = "none";
  } else {
    document.getElementById('signin-form').style.display = "none";
    document.getElementById('signup-form').style.display = "block";
  }
}
Enter fullscreen mode Exit fullscreen mode

There are a lot of different ways to handle this, but this is a pretty simple solution, so I’m going with it. You’re welcome to implement a different strategy if you prefer.

We should add a couple more conditionally rendered sections to the body of our html. We need one for a loading screen and one to house the logged in user’s app screen. We can mock this up pretty simply like so:

<div style="display: none" id="loading">
  <h1>Loading...</h1>
</div>

<div style="display: none" id="root-app">
  <h1>This is the app</h1>
</div>
Enter fullscreen mode Exit fullscreen mode

Then all we need to do is update our pageLoad function and call it anytime we update the application state. That function should now look like this:

function pageLoad() {
  if(loading) {
    document.getElementById('loading').style.display = "block";
    document.getElementById('auth-buttons').style.display = "none";
    document.getElementById('root-app').style.display = "none";
    document.getElementById('signup-form').style.display = "none";
    document.getElementById('signin-form').style.display = "none";
  } else if(loggedIn) {
    document.getElementById('loading').style.display = "none";
    document.getElementById('auth-buttons').style.display = "none";
    document.getElementById('signup-form').style.display = "none";
    document.getElementById('signin-form').style.display = "none";
    document.getElementById('root-app').style.display = "block";
  } else {
    //Here we are deciding whether to show the sign up or sign in form
    if(startPage === "signup") {
      document.getElementById('signin-form').style.display = "none";
      document.getElementById('signup-form').style.display = "block";
    } else if(startPage === "signin") {
      document.getElementById('signin-form').style.display = "block";
      document.getElementById('signup-form').style.display = "none";
    } else {
      document.getElementById('root-app').style.display = "block";
      document.getElementById('auth-buttons').style.display = "none";
      document.getElementById('signup-form').style.display = "none";
      document.getElementById('signin-form').style.display = "none";
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we’re ready to actually sign up and sign in! Let’s get started with that. You’ve already previously created your config object. So now all we need to do is capture the user’s sign up credentials.

There are a few ways to do this, but to make it as simple as possible, we’re going to just grab the input values. Inside your signUp function, add this:

async function signUp(e) {
  e.preventDefault();
  //Keychain request
  loading = true;
  const username = document.getElementById('username-signup').value;
  const password = document.getElementById('password-signup').value;
  const email = document.getElementById('email-signup').value;
  pageLoad();
  const data = `username=${username}&password=${password}&email=${email}&development=true&devId=${config.devId}`;
  const urlKeychain = "https://api.simpleid.xyz/keychain";
  const urlAppKeys = "https://api.simpleid.xyz/appkeys";
}
Enter fullscreen mode Exit fullscreen mode

This is just the beginning of what we need to eventually make our post request to the SimpleID API. We’ve flipped the loading variable to true and we are capturing the input fields for the sign up form. You’ll notice we call our pageLoad() function AFTER the input elements are captured as variables. This is just a safety measure since we’re updating the page state to show the loading screen rather than the form and we want to make sure we have the username, password, and email stored and ready to use.

I’ll take a second to point out I’m not doing any validation on these input elements. That’s outside the scope of this tutorial, but you definitely should do that.

We have also stored two SimpleID API endpoints as variables. We actually need both of these for account creation. The first one creates the user’s keychain (user info, private keys, etc). The second one returns the necessary app-specific information to be used in building a user session.

Now, before we continue the signUp() function code, we should set up our HTTP request code. We’re going to call this function anytime we need to make a request to the SimpleID API and the result will be a promise. So, go ahead and create a new function called postToApi():

function postToApi(data, url) {
  return new Promise((resolve, reject) => {
    var xhr = new XMLHttpRequest();
    xhr.withCredentials = true;
    xhr.addEventListener("readystatechange", function () {
      if (this.readyState === 4) {
        resolve(this.responseText);
      }
    });

    xhr.open("POST", url);

    xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");

    xhr.setRequestHeader("Authorization", config.apiKey);

    xhr.send(data);
  })
}
Enter fullscreen mode Exit fullscreen mode

Per the SimpleID API Docs, we know we are sending form data, so that content type is set. You may have noticed in the signUp function previously, we captured a variable called data and that variable had the urlencoded format we need to pass this form data to the API.

Make sure you replace apiKey with the API Key you received when creating your project in SimpleID.

Because we have made this a promise, the response will be sent back when the promise resolves. So now, we can do this in our signUp function below the code we wrote previously:

const keychain = await postToApi(data, urlKeychain);
console.log(keychain);
Enter fullscreen mode Exit fullscreen mode

You could actually save this and test your sign up form with just the code we’ve written so far. But all it’s going to do is return an identity address which won’t be very useful by itself. Remember, we need to also call the second SimpleID API endpoint. But we should only do so if the keychain value we receive is not an error. Below the console.log(keychain) add the following:

if(!keychain.includes("KEYCHAIN\_ERROR")) {
  //Now we need to fetch the user data from a second API call
  let profile = {
    '@type': 'Person',
    '@context': 'http://schema.org',
    'apps': {}
  }
  profile.apps[window.location.origin] = "";
  const url = encodeURIComponent(window.location.orign);
  const uriEncodedProfile = encodeURIComponent(JSON.stringify(profile))
  const keyData = `username=${username}&password=${password}&profile=${uriEncodedProfile}&url=${url}&development=true&devId=${congig.devId}`

  const userData = await postToApi(keyData, urlAppKeys);
  console.log(userData)

} else {
  loading = false;
  loggedIn = false;
  pageLoad();
  console.log("Failed")
}
Enter fullscreen mode Exit fullscreen mode

Ok, so it seems like a lot is going on here, but it’s not that bad. We’re saying that if there is not an error on the first API call, let’s kick off the second API call. Per the SimpleID docs, we need to provide our devID the user’s username, the user’s password, our app’s url, and a profile object. Let’s talk about that profile object real quick.

SimpleID supports multiple Web 3.0 protocols, and one of those protocols is Blockstack. Blockstack has this profile concept that allows users to interact with each other and share data. We want to make sure we always have a profile object ready to go in case you as a developer ever decides to build an app that uses Blockstack storage or if the user ever decided to use another app that also has SimpleID implemented. See, users can use the same account credentials across multiple apps. So, just know the profile format (as documented in the SimpleID Docs) takes the form illustrated above. And, of course, we have to URIEncode it to support the form data post to the API.

The url variable and the url you provide to the profile object need to be well-formed urls, so if you’re developing without a web server, you can’t just use window.location.origin. If that’s the case, you should provide an origin like http://localhost:3000 or the eventual url you plan to deploy the app to.

You may have also noted in the data we’re sending to the API a flag for development. In production, make sure this is always true.

In our else statement, if the first API call fails, we want to flip the app state back to the log in screen and console log some sort of message. But if everything is successful and we call the second API endpoint, we need to nest some additional logic to support success and failure on that endpoint. Add the following below the console.log(userData):

if(!userData.includes('ERROR')) {
  let userSession = JSON.parse(userData);
  userSession.username = username;
  localStorage.setItem('user-session', JSON.stringify(userSession));
  loading = false;
  loggedIn = true;
  pageLoad();
} else {
  loading = false;
  loggedIn = false;
  pageLoad();
  console.log("Error");
}
Enter fullscreen mode Exit fullscreen mode

Ok, so here we are saying if the second API call is successful, let’s parse the response (which is a string returned by the API). We want to then add the user’s username to the object we’ve now created (since the API does not return the username). Once we’ve done that, we need a way to persist the user’s logged in session. There are differing methods to handling this, and there is a debate on what type of data should be stored in local storage. Educate yourself on these topics, but know for the sake of this tutorial, I’ll be dropping the session information into local storage.

We also need to flip our state variables and update the application state with a call to pageLoad(). Of course, if the second API call fails, the user will not be logged in and we need to return them to the sign up screen.

If you save this, you can actually go ahead and sign up for an account. One thing to note here is that if you are just opening your index.html file in a browser rather than running a local web server, you’re going to need a CORS extention like this one. The reason is the API endpoint expects a browser-based request to have a well-formed origin. An origin that starts with File:// is not acceptable. But if you use the CORS extension, you’ll be all set for development.

If all goes well with you’re sign up, you’ll see your sign up form go away, a loading indicator pop up, and then, the This is the app text we used as placeholder earlier will show up.

This is fantastic, but there are two more things we need to do. One, what happens if the user refreshes the page? Well, try it. Looks like the user is automatically sign out? Also, how does the user sign in? We haven’t wired up the sign in function.

Let’s take care of the session persistency quickly. In our main.js file under the pageLoad() function, we just need to do a quick check of local storage for the user’s session information. If you followed my code from above, you can add this to the top of the pageLoad() function:

if(localStorage.getItem('user-session')) {
  loggedIn = true;
}
Enter fullscreen mode Exit fullscreen mode

Simple as that!

Finally, we want to support sign in and sign out. This is going to be way easier than setting up the sign in code. For sign in, we’re going to copy half of the sign up code. Let’s do that now. Under the signUp() function, add the following:

async function signIn(e) {
  e.preventDefault();
  loading = true;
  const username = document.getElementById('username-signin').value;
  const password = document.getElementById('password-signin').value;
  pageLoad();
  const urlAppKeys = "https://api.simpleid.xyz/appkeys";

  let profile = {
    '@type': 'Person',
    '@context': 'http://schema.org',
    'apps': {}
  }
  profile.apps[window.location.origin] = "";
  const url = encodeURIComponent(window.location.origin);
  const uriEncodedProfile = encodeURIComponent(JSON.stringify(profile))

  const keyData = `username=${username}&password=${password}&profile=${uriEncodedProfile}&url=${url}&development=true&devId=${config.devId}`
  const userData = await postToApi(keyData, urlAppKeys);
  if(!userData.includes('ERROR')) {
    console.log(userData);
    let userSession = JSON.parse(userData);
    userSession.username = username;
    localStorage.setItem('user-session', JSON.stringify(userSession));
    loading = false;
    loggedIn = true;
    pageLoad();
  } else {
    loading = false;
    loggedIn = false;
    pageLoad();
    console.log("Error");
  }
}
Enter fullscreen mode Exit fullscreen mode

This code is doing the same thing the sign up code did, except it’s skipping the first API call. So if you are still on the sign up page, click the sign in button at the top of the screen, enter the credentials you previously used to sign up, and you should see the page update to loading and then to the This is the app text.

Now, because we have persisted the user session, you should be able to refresh the page and the user will still be logged in. If that works as it should, we have just one last thing to do. We need to let the user sign out.

Back in your html file, find the app code where we wrote This is the app. Let’s add a button above that text that says Sign Out.

<div style="display: none" id="root-app">
  <button onclick="signOut()">Sign Out</button>
  <h1>This is the app</h1>
</div>
Enter fullscreen mode Exit fullscreen mode

Now, back in the main.js file, we can add the very simple signOut() function like this:

function signOut() {
  localStorage.removeItem('user-session');
  window.location.reload();
}
Enter fullscreen mode Exit fullscreen mode

Save that, refresh your page, then click sign out. Should work beautifully.

Congratulations! You’ve just built a Single Page Application with zero dependencies that lets users sign up, sign in, and sign out. In part two of this tutorial, you’ll add IPFS functionality, drop in some styling, and make this into a real app.


Top comments (0)