Probando Gun.js La mejor 'arma' de la Web3

miércoles, 6 de octubre de 2021

Gun.js es por resumirlo una versión opensource de Firebase, una base de datos en tiempo real, con autentificación, persistencia offline, sin limite de almacenamiento, pero es GRATIS y descentralizada, ¿y eso que significa?. Para no hacer esta historia muy larga he ir directo al grano intentare resumir este concepto: es un sistema que no esta centralizado, duh.

Gun.js: una base de datos descentralizada

Firebase es una base de datos centralizada que pertenece a Google, así que al crear una base de datos con Firebase, esta pertenece en ultima instancia a Google. Gun.js es una base de datos descentralizada, así que al crear una base de datos con Gun.js esta le pertenece a los usuarios que participan en esa base de datos, ya que cada uno de esos usuarios es el dueño y usuario al mismo tiempo. En resumidas cuentas; la única forma en la que tu base de datos desaparezca es eliminando todos los nodos (tantos nodos como personas o relays existan en tu red), no hay una empresa detrás y eso es fantástico, pero ya hablaremos de las comunidades opensource y del excelente trabajo que hacen las personas cuando el dinero no es el objetivo.

¿Esta Gun.js a favor de Marx? Sí, y ¿tu no?

La crítica a la economía capitalista. La democratización de los medios de producción. Gun.js es puro materialismo histórico pero me temo que no estoy preparado para tener esta conversación, solo me parecía gracioso señalar a Gun.js de esta manera.

¿Qué puedo hacer con Gun.js?

Salvar el mundo estaría bien pero en para un primer contacto es demasiado, eso te toca a ti con ayuda de los demás, para el propósito de este ¿ejercicio? haremos…Una web app para registrar y checkear colonias de gatitos.

Tecnologías para complementar Gun.js

Quiero hacer esto lo más rápido que pueda pero sin que quede cutre. Soy un fan de Vue.js pero por comodidad usare Nuxt.js como framework principal. Para la parte visual usaré tailwind, tenemos algo de prisa pero queremos que quede kawai ^^.

Creando una web app con Gun.js Nuxt.js y Tailwing

El primer paso será crear el proyecto. Abriremos nuestra terminal, nos situaremos en el directorio en que vayamos a trabajar y escribiremos lo siguiente:

npm init nuxt-app cat-colonies-map

Aunque no estaría mal echarle un vistazo a la pagina oficial en caso de que esto cambie.

Una vez elegimos las opciones del instalador de nuxt que nos convengan (como usar tailwind, semantic pull request o que dependabot se encargue de actualizar las dependencias), seguimos las instrucciones para ejecutar nuestro proyecto.

cd cat-colonies-map
npm run dev

El siguiente paso es instalar Gun.js. Como podemos usar plugins de Vue con Nuxt usaremos un plugin que alguien en internet ya ha implementado: vue-gun

npm install vue-gun --save

Una vez instalado crearemos el archivo vue-gun.client.js bajo la carpeta /plugins. En este archivo registraremos nuestro plugin de Gun escribiendo lo siguiente:

import Vue from 'vue'
import VueGun from 'vue-gun'
Vue.use(VueGun)

Además de esto tendremos que registrar el plugin en nuestro archivo nuxt.config.js

plugins: ['~/plugins/vue-gun.client.js']

Para probar si la instalación es correcta vamos a crear un pequeño formulario para añadir nuevas colonias de gatos, lo haremos en el archivo index bajo la carpeta /pages, quedaría tal que así:

<template>
  <div>
    <form @click.prevent="addColony()">
      <input v-model="newColony.name" type="text" />
      <input v-model.number="newColony.aproxPopulation" type="text" />
      <button type="submit">crear colonia</button>
    </form>
    <ul>
      <li v-for="(colony, key) in colonies" :key="key">
        <pre>&#123;&#123; colony }}</pre>
      </li>
    </ul>
  </div>
</template>

<script>
export default &#123;
  data() &#123;
    return &#123;
      newColony: &#123;
        name: '',
        aproxPopulation: 0,
      },
      colonies: &#123;},
    }
  },
  mounted() &#123;
    this.$gun
      .get('cat-colonies')
      .map()
      .on((node, key) => &#123;
        if (node !== null) &#123;
          this.colonies = &#123;
            ...this.colonies,
            [key]: &#123; ...node },
          }
        }
      })
  },
  methods: &#123;
    addColony() &#123;
      const colonyName = this.newColony.name
      const aproxPopulation = this.newColony.aproxPopulation
      const newColony = this.$gun.get(colonyName).put(&#123;
        name: colonyName,
        'aprox-population': aproxPopulation,
      })
      this.$gun.get('cat-colonies').set(newColony)
    },
  },
}
</script>

Probaremos nuestro código y comprobaremos que efectivamente podemos añadir nuevos datos manteniendo la reactividad. Lo único a destacar aquí seria la forma de mantener la reactividad del objeto colonies ya que Vue haces cosas raras cuando agregas valores anidados a los objetos. Por eso es más sencillo volcar todo el objeto anterior más los nuevos valores:

this.colonies = &#123;
  ...this.colonies,
  [key]: &#123; ...node },
}

Ahora que ya sabemos que la base de datos funciona correctamente, vamos a quitarnos de encima la configuración de la API de geolocalización.

Para conseguir la ubicación del usuario que registra una nueva colonia usaremos la API de geolocalización que en teoría es muy sencilla de usar. La función que se encarga de esto quedaría tal que así:

getGeolocation() &#123;
  return new Promise((resolve, reject) => &#123;
    // comprobamos que el navegador tenga la API de geolocalización
    if (!('geolocation' in navigator)) &#123;
      reject(new Error('Geolocalización no disponible.'))
    }

    // Esto será para reflejar en la interfaz que el proceso ha iniciado
    this.gettingLocation = true

    navigator.geolocation.getCurrentPosition(
      (pos) => &#123;
        this.gettingLocation = false
        resolve(pos)
      },
      (err) => &#123;
        this.gettingLocation = false
        reject(err)
      }
    )
  })
},

Ahora actualizamos la función que se encarga de registrar las nuevas colonias para añadir los datos de posición:

async addColony() &#123;
  const pos = await this.getGeolocation()
  const lat = pos.coords.latitude
  const long = pos.coords.longitude
  const colonyName = this.newColony.name
  const aproxPopulation = this.newColony.aproxPopulation
  const newColony = this.$gun.get(colonyName).put(&#123;
    name: colonyName,
    'aprox-population': aproxPopulation,
    location: &#123;
      lat,
      long,
    },
  })
  this.$gun.get('cat-colonies').set(newColony)
},

Perfecto, con esto ya podemos crear registrar nuevas colonias con su ubicación aproximada. El siguiente paso será añadir una imagen de la colonia…y de momento no podre usar Gun.js para eso, así que usare el viejo y confiable firebase para almacenar las imágenes y referenciarlas en la base de datos, al menos hasta que se sepa como usar IPFS correctamente para guardar fotos.

Para guardar las fotos usaremos un plugin llamado vue-image-upload-resize ya que gatos + internet = muchos gigas de datos, así que este plugin se encargara de reducir el tamaño de las imágenes antes de subirlas.

Manos a la obra, vamos a necesitar instalar tanto firebase como vue-image-upload-resize, empecemos por el plugin:

npm install --save vue-image-upload-resize

Una vez instalado repetimos el proceso de configurar el plugin en la ruta /plugins, aquí crearemos el archivo vue-image-uploader.client.js con lo siguiente:

import Vue from 'vue'
import ImageUploader from 'vue-image-upload-resize'
Vue.use(ImageUploader)

Vamos a crear una nueva ruta para separar la funcionalidad de registrar colonias de la página principal, para eso creamos el archivo colony-register en la ruta /pages.

Teniendo en cuenta que podemos guardas las fotos en base64 y que haciendo pruebas la calidad de la imagen no se ve comprometida, vamos a guardar la imagen directamente en Gun.js sin hacer ningún uso de firebase. Así quedaría el código encargado de guardar la imagen:

setImage(img) &#123;
  this.newColony.img = img
},
processFile(e) &#123;
  this.$refs.fileStoreUploader.uploadFile(e)
},
pickFile() &#123;
  this.$refs.fileUpload.click()
},

La funcíon setImage() se encargar de guardar la imagen en el objeto que vamos a guardar en Gun.js, las otras dos funciones hacen un pequeño truco para poder usar el plugin vue-image-upload-resize sin tener que usarlo en la interfaz y permitiéndonos usar el input que queramos. La plantilla HTML quedaría así:

<client-only>
  <image-uploader
    ref="fileStoreUploader"
    :auto-rotate="true"
    :quality="0.7"
    :preview="false"
    style="display: none"
    capture="environment"
    output-format="string"
    @input="setImage"
    @onUpload="loadingImg = true"
    @onComplete="loadingImg = false"
  />
</client-only>
<input
  ref="fileUpload"
  style="display: none"
  class="cursor-pointer absolute block opacity-0 pin-r pin-t"
  type="file"
  @change="processFile($event)"
/>
<img :src="newColony.img" />
<button v-show="!loadingImg" class="underline" @click.prevent="pickFile()">
  &#123;&#123; newColony.img ? 'cambiar foto' : 'hacer foto' }}
</button>
<span v-show="loadingImg">...cargando</span>

Ahora que ya podemos añadir foto a la colonia vamos a añadir la funcionalidad de “checkear” esas colonias, esto consiste en añadir una foto a la galería de la colonia y la fecha, cuando configuremos la parte correspondiente a la sesión de usuario añadiremos estos datos también.

Esto será fácil en teoría, añadimos un enlace a las tarjetas de colonias para “checkearlas”, luego copiaremos la lógica que creamos anteriormente para subir la foto a la colonia y listo.

Comencemos por el enlace:

 <li v-for="(colony, key) in colonies" :key="key">
   <pre>&#123;&#123; colony.name }}</pre>
   <img :src="colony.img" :alt="`imagen de $&#123;colony.name}`" />
   <nuxt-link :to="`/check-colony/$&#123;key}`">comprobar colonia</nuxt-link>
 </li>

Creamos el archivo _id.vue bajo la ruta /pages/check-colony y copiamos la lógica que usamos para sacar fotos. Luego añadimos la parte de Gun.js donde referenciamos esta foto a la colonia original:

addColonyImg() &#123;
  this.addingColonyImg = true
  const route = this.$route.params.id
  const img = this.colonyImg
  const newColonyImg = this.$gun.get(`$&#123;route}-images`).set(&#123;
    img,
  })
  this.$gun.get(route).get('gallery').set(newColonyImg)
  this.addingColonyImg = false
},

Por otro lado vamos a crear otro archivo _id.vue bajo la ruta pages/colony-gallery, aquí cargaremos las fotos de la colonia usando el metodo .map() de Gun.js.

<template>
  <div>
    <ul>
      <li v-for="(item, key) in images" :key="key">
        <img :src="item.img" />
      </li>
    </ul>
  </div>
</template>
<script>
export default &#123;
  data() &#123;
    return &#123;
      images: &#123;},
    }
  },
  mounted() &#123;
    this.route = this.$route.params.id
    this.$gun
      .get(this.route)
      .get('gallery')
      .map()
      .once((node, key) => &#123;
        this.images = &#123;
          ...this.images,
          [key]: &#123;
            ...node,
          },
        }
      })
  },
}
</script>

Con eso ya tendríamos las funcionalidades básicas, queda una funcionalidad más pero nos encargaremos de ella y del apartado visual una vez integremos el registro de usuarios.

Para registrar nuevos usuarios vamos a crear el archivo signup en nuestra ruta /pages. Para registrar a un nuevo usuario necesitamos su nombre de usuario y contraseña, también nos encargaremos de los posibles errores que puedan ocurrir.

Empecemos por el código que se encarga de crear un nuevo usuario:

createUser() &#123;
  return new Promise((resolve, reject) => &#123;
    this.$gun.user().create(
      this.username,
      this.password,
      (ack) => &#123;
        if (ack.ok === 0) &#123;
          resolve(&#123; error: false })
        } else &#123;
          resolve(&#123; error: ack.err })
        }
      },
      &#123;}
    )
  })
},

Este código devuelve una promesa para poder manipular el resultado de la operación con más facilidad.

Antes de continuar con el resto vamos a declarar las variables que vayamos a necesitar para los errores y el proceso de registro, también usaremos una propiedad computada para mostrar dichos errores:

data() &#123;
  return &#123;
    password: null,
    username: null,
    creatingAccount: false,
    errors: &#123;
      username: [],
      password: [],
    },
  }
},
computed: &#123;
  errorsDisplay() &#123;
    return this.errors.username.length > 0 || this.errors.password.length > 0
  },
},

Ya tenemos la parte que se encarga de crear un usuario, pero antes de crearlo deberíamos validar si los datos que ha introducido son correctos, o si el nombre de usuario ya ha sido registrado antes. Para comprobar si el usuario ha sido registrado antes, comprobaremos si ya existe un usuario con ese nombre:

checkUsername(username) &#123;
  return new Promise((resolve, reject) => &#123;
    const lowkeyUsername = username.toLowerCase()
    this.$gun.get(`~@$&#123;lowkeyUsername}`).once((user) => &#123;
      if (!user) &#123;
        resolve(&#123; available: true })
      } else &#123;
        resolve(&#123; available: false })
      }
    })
  })
},

Ahora vamos a encargarnos de la validación de los demás datos, tenemos que comprobar un par de cosas;

  • Que se introduzca un nombre de usuario
  • Que el nombre de usuario sea válido
  • Que el nombre de usuario no se haya usado previamente (por eso checkUsername() devuelve una promesa)
  • Que se introduzca una contraseña
  • Que la contraseña tenga más de 8 caracteres
async validateForm() &#123;
  this.errors.username = []
  this.errors.password = []
  const usernamePttr = /^[a-zA-Z0-9._]+$/
  const username = this.username.trim().toLowerCase()
  const password = this.password
  const validUsername = await this.checkUsername(username)
  if (!username) &#123;
    this.errors.username.push('Por favor, escriba un nombre de usuario.')
  } else if (!usernamePttr.test(username)) &#123;
    this.errors.username.push(
      'Por favor escriba un nombre de usuario válido'
    )
  }
  if (!validUsername.available) &#123;
    this.errors.username.push('El nombre de usuario ya existe')
  }
  if (!password) &#123;
    this.errors.password.push('Por favor introduzca una contraseña')
  } else if (password.length < 8) &#123;
    this.errors.password.push(
      'La contraseña debe tener al menos 8 caracteres.'
    )
  }
  if (!this.errorsDisplay) &#123;
    return true
  } else &#123;
    return false
  }
},

Con esto listo ahora sí podemos registrar al usuario:

async signUp() &#123;
  this.creatingAccount = true
  try &#123;
    const validated = await this.validateForm()
    if (validated) &#123;
      const response = await this.createUser()
      if (!response.error) &#123;
        this.$gun.user().recall(&#123; sessionStorage: true })        
        this.$router.push('/')
      } else if (response.error === 'User already created!') &#123;
        this.errors.username.push('Este usuario ya ha sido creado')
      } else if (response.error === 'Password too short!') &#123;
        this.errors.password.push(
          'La contraseña debe tener al menos 8 caracteres, solo numeros y letras'
        )
      }
      this.creatingAccount = false
    } else &#123;
      this.creatingAccount = false
    }
  } catch (e) &#123;
    this.creatingAccount = false
  }
},

Esta sería toda la lógica de registro, pero seguimos necesitando una interfaz donde el usuario pueda introducir sus datos y ver los errores. La plantilla quedaría así:

<template>
  <div>
    <h3>¡Únete la colonia!</h3>
    <form @submit.prevent="signUp">
      <label for="username" class="invisible">Nombre de usuario</label>
      <input
        id="username"
        v-model.trim="username"
        type="text"
        required
        placeholder="Nombre de usuario"
      />
      <p v-if="errors.username.length">
        <span v-for="error in errors.username" :key="error">&#123;&#123; error }}</span>
      </p>
      <input
        id="loginPassword"
        v-model="password"
        type="password"
        placeholder="Contraseña"
        required
      />
      <p v-if="errors.password.length">
        <span v-for="error in errors.password" :key="error">&#123;&#123; error }}</span>
      </p>
      <label for="loginPassword">Contraseña de inicio de sesión</label>
      <button type="submit" :disabled="creatingAccount">
        &#123;&#123; !creatingAccount ? 'Crear cuenta' : 'Creando...' }}
      </button>
      <p>¿Ya tienes una cuenta?</p>
      <nuxt-link to="/signin">Iniciar sesion</nuxt-link>
    </form>
  </div>
</template>

Genial! Nuestra aplicación ya puede registrar nuevos usuarios!

Antes de emocionarnos tanto vamos a crear una forma en la que nuestros usuarios ya registrados puedan iniciar sesión, por suerte será mucho más fácil que la parte del registro, de hecho solo hay que cambiar el metodo this.$gun.user().create() por this.$gun.user().auth().

Una vez nuestra app pueda registrar usuario vamos a guardar los datos de este en los nodos que vaya a crear, en nuestro caso serían dos nodos, uno para registrar una colonia y otro para checkearla. Vamos a encargarnos del nodo de registrar colonias, para eso necesitamos conseguir los datos del usuario que ha iniciado sesión, comprobaremos esto en cada página en la que vayamos a necesitar estos datos:

async mounted() &#123;
  const user = this.$gun.user()
  if (user.is) &#123;
    await user.recall(&#123; sessionStorage: true })
    console.log(user.is.pub)
    this.user = user.is
  } else &#123;
    console.log('no iniciado')
    this.$router.push('/signin')
  }
},

Una vez conseguimos los datos solo tenemos que añadirlos al nodo que vayamos a subir, además también tenemos que añadir este nodo al usuario:

async addColony() &#123;
  this.addingColony = true
  const pos = await this.getGeolocation()
  const lat = pos.coords.latitude
  const long = pos.coords.longitude
  const colonyName = this.newColony.name
  const aproxPopulation = this.newColony.aproxPopulation
  const img = this.newColony.img
  const createdByAlias = this.user.alias
  const createdByPub = this.user.pub
  const newColony = this.$gun.get(colonyName).put(&#123;
    name: colonyName,
    'aprox-population': aproxPopulation,
    img,
    createdByAlias,
    createdByPub,
    location: &#123;
      lat,
      long,
    },
  })
  this.$gun.get('cat-colonies').set(newColony)
  this.$gun.user().get('registered-colonies').set(newColony)
  this.addingColony = false
},

Recordemos guardar estos datos cuando el usuario compruebe una colonia en nuestro archivo /pages/check-colony/_id:

addColonyImg() &#123;
  this.addingColonyImg = true
  const route = this.$route.params.id
  const img = this.colonyImg
  const takenByAlias = this.user.alias
  const takenByPub = this.user.pub
  const newColonyImg = this.$gun.get(`$&#123;route}-images`).set(&#123;
    img,
    takenByAlias,
    takenByPub,
  })
  this.$gun.get(route).get('gallery').set(newColonyImg)
  this.$gun.user().get('checked-colonies').set(newColonyImg)
  this.addingColonyImg = false
},

Con esto ya tenemos cubierta casi toda la lógica de nuestra aplicación usando Gun.js, ahora vamos a la parte visual para darle un poco de color. No voy a pararme a detallar este proceso ya que usare tailwind y la idea es que hagas el diseño que quieras, además no es mi punto fuerte, la lógica ya base esta lista.

Vamos a crear el componente colonyCard para ver un ejemplo de como quedaría con tailwind:

<div class="flex flex-col justify-between h-full mb-4 rounded-md shadow">
  <div class="relative overflow-hidden">
    <p
      class="
        absolute
        w-full
        py-2
        pl-2
        top-0
        inset-x-0
        bg-gray-100
        text-xs text-gray-700
        leading-4
      "
    >
      Registrado por: <span class="font-semibold">&#123;&#123; createdByAlias }}</span>
    </p>
    <img
      loading="lazy"
      class="object-cover h-full w-full rounded-t-md"
      :src="colonyImg"
      :alt="`imagen de $&#123;colonyName}`"
    />
    <nuxt-link
      :to="`/colony-gallery/$&#123;colonyKey}`"
      class="
        absolute
        underline
        w-full
        py-2
        pr-2
        top-0
        inset-x-0
        text-right text-xs text-gray-700
        leading-4
      "
    >
      ver galería
    </nuxt-link>
    <div
      class="
        absolute
        w-full
        py-2
        pl-2
        bottom-0
        inset-x-0
        bg-transparent
        text-white text-xl
        leading-4
      "
    >
      <p class="font-black pb-2">&#123;&#123; colonyName }}</p>
      <p class="text-xs">Población Aprox: &#123;&#123; aproxPopulation }}</p>
    </div>
  </div>
  <div class="py-4 px-2 sm:flex sm:justify-between">
    <div class="w-full sm:w-auto inline-flex">
      <nuxt-link
        to="/"
        class="
          w-full
          underline
          inline-flex
          items-center
          justify-center
          border border-transparent
          text-base
          leading-6
          font-medium
          text-gray-800
          hover:text-gray-600
          focus:outline-none focus:shadow-outline
          transition
          duration-150
          ease-in-out
        "
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          height="24px"
          viewBox="0 0 24 24"
          width="24px"
          class="pr-1 fill-current"
        >
          <path d="M0 0h24v24H0z" fill="none" />
          <path
            d="M12 12c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm6-1.8C18 6.57 15.35 4 12 4s-6 2.57-6 6.2c0 2.34 1.95 5.44 6 9.14 4.05-3.7 6-6.8 6-9.14zM12 2c4.2 0 8 3.22 8 8.2 0 3.32-2.67 7.25-8 11.8-5.33-4.55-8-8.48-8-11.8C4 5.22 7.8 2 12 2z"
          />
        </svg>
        Visitar colonia
      </nuxt-link>
    </div>
    <div class="w-full mt-3 sm:mt-0 sm:w-auto inline-flex rounded-md shadow">
      <nuxt-link
        :to="`/check-colony/$&#123;colonyKey}`"
        class="
          w-full
          inline-flex
          items-center
          justify-center
          px-5
          py-3
          border border-transparent
          text-base
          leading-6
          font-medium
          rounded-md
          text-white
          bg-pink-400
          hover:bg-pink-300 hover:text-gray-600
          focus:outline-none focus:shadow-outline
          transition
          duration-150
          ease-in-out
        "
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          height="24px"
          viewBox="0 0 24 24"
          width="24px"
          class="pr-1 fill-current"
        >
          <path d="M0 0h24v24H0z" fill="none" />
          <path
            d="M21 6h-3.17L16 4h-6v2h5.12l1.83 2H21v12H5v-9H3v9c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-                 1.1-.9-2-2-2zM8 14c0 2.76 2.24 5 5 5s5-2.24 5-5-2.24-5-5-5-5 2.24-5 5zm5-3c1.65 0 3 1.35 3 3s-1.35 3-3 3-3-1.35-3-3 1.35-3 3-3zM5 6h3V4H5V1H3v3H0v2h3v3h2z"
          />
        </svg>
        Comprobar colonia
      </nuxt-link>
    </div>
  </div>
</div>

Y con esto y un bizcocho no estoy seguro de como acaba el refrán pero viene a decir que ya hemos terminado, o no.

Vamos a añadir un archivo llamado now.json en la ruta de nuestro directorio para poder desplegar nuestra aplicación en vercel. Por suerte ya existe una configuración predefinida para desplegar una app creada con Nuxt.

  "builds": [
    &#123;
      "src": "nuxt.config.js",
      "use": "@nuxtjs/vercel-builder"
    }
  ]
}

Ahora visitamos vercel para crear un nuevo proyecto desde nuestro repositorio y listo, ya podemos visitar nuestra aplicación con un enlace, el nuestro es https://cat-colonies-map.vercel.app.

Gun.js tiene mucho potencial de convertirse en la base de datos predilecta para la Web3, desafortunadamente al momento de escribir esto Gun.js no esta lista para usarse en un entorno de producción (aún). Lo que más me gusto de usar esta base de datos descentralizada fue su API, es realmente sencilla de usar a pesar de que la documentación oficial este presentada de forma engorrosa. Espero que en el futuro y para cuando leas esto Gun.js este lista para ser la mejor arma de la Web3.

Gracias por haber llegado hasta aquí, por cada vez que alguien lee este artículo se salva un gatito. Si quieres echarle un ojo al código no dudes en pasarte por el repositorio de este proyecto, siéntete libre de contribuir o crear dulces issues.