javascript

App de notas y listas con encriptación – Open source

El día de hoy te enseñaré una aplicación simple, gratuita y open source para gestionar notas y listas (To Do app). Está hecha con JavaScript usando Svelte, utiliza SQLite3 para el almacenamiento, Tailwind para el diseño y se agrega encriptación con la web crypto API.

Con esta app de notas podrás agregar notas y listas de pendientes, además de poder encriptarlas usando AES en modo CBC. Cada nota y cada lista puede ser encriptada de manera independiente y con una contraseña distinta, generado el vector de inicialización y derivando la clave a partir de una contraseña.

App de notas con encriptación – Open source

Puedes agregar etiquetas a cada lista y nota, para que puedas filtrarlas más adelante.

Lo mejor de esto es que la app puede ser instalada como una aplicación nativa gracias al poder de las PWA, así que puedes usarla en móviles y dispositivos de escritprio.

Me emocioné tanto con la llegada de SQLite3 a la web con OPFS que hice esta app para aprender Svelte y Tailwind, pero terminé haciendo también la app de cumpleaños y luego experimenté un poco con WASM.

Veamos entonces esta webapp de tareas pendientes totalmente open source.

Mostrar notas y listas

En la base de datos, cada lista y nota es independiente. Además, los elementos de las listas se guardan en otra tabla. Entonces para empezar hay 3 tablas: las notas, las listas y los elementos de las listas.

Para mostrar las listas y notas combinadas primero se obtienen por separado, después se combinan con concat y finalmente se ordenan por la fecha de creación.

const mostrarTodasLasListasYNotas = async () => {
    listas = await notasService.obtenerListas(busqueda);
    notas = await notasService.obtenerNotas(busqueda);
    notasYListasCombinadas = listas.concat(notas);
    notasYListasCombinadas.sort((notaOLista, otraNotaOLista) => {
        return otraNotaOLista.fechaCreacion - notaOLista.fechaCreacion;
    });
    cargando = false;
};

Una vez que tenemos la lista combinada como un Array, las mostramos con Svelte:

{#each notasYListasCombinadas as notaOLista}
    {#if notaOLista.contenido}
        <VistaPreviaDeNota on:editarNota={editarNota} nota={notaOLista} />
    {:else}
        <VistaPreviaDeLista
            on:editarLista={editarLista}
            lista={notaOLista}
        />
    {/if}
{/each}

Para diferenciar si es una nota o una lista se comprueba la propiedad contenido; las notas tienen esa propiedad, las listas no. Estoy utilizando componentes de Svelte para mostrar las vistas previas. Por ejemplo, el de la lista se ve así:

<script>
    import { resolverFondo } from "../fondos.js";
    export let lista;
    import { createEventDispatcher } from "svelte";
    import ListarEtiquetas from "./ListarEtiquetas.svelte";
    import { Icon, LockClosed } from "svelte-hero-icons";
    const dispatch = createEventDispatcher();

    const editarLista = (lista) => {
        dispatch("editarLista", lista);
    };
</script>

<div
    on:click={() => {
        editarLista(lista);
    }}
    class="relative w-full items-center overflow-hidden p-4 shadow-sm rounded-lg"
>
    <div
        style="background-image: url('{resolverFondo(
            lista.fondo
        )}'); z-index: -1; height: 100%; position: absolute; top: 0; left:0; width: 100%; opacity: 0.1;"
    />
    <h1 class="text-lg font-bold my-4 text-amber-950 fuente-titulo">
        {lista.titulo}
    </h1>
    <div class="space-y-3 mb-2">
        {#each lista.elementos as elemento, indice}
            <div class="flex items-center">
                <input
                    disabled
                    name="comments"
                    type="checkbox"
                    checked={elemento.terminado}
                    class="rounded border-gray-300 text-pink-400 focus:ring-pink-400 h-4 w-4"
                    class:invisible={lista.encriptada}
                />
                <div
                    id={"elemento_" + indice}
                    class:tachado={elemento.terminado}
                    class="flex-1 input-elemento-lista text-sm ml-2 text-amber-950"
                    class:desenfocado={lista.encriptada}
                >
                    {elemento.contenido}
                </div>
            </div>
        {/each}
    </div>
    {#if lista.encriptada}
        <Icon src={LockClosed} class="w-4 h-4 text-green-500" />
    {/if}
    <ListarEtiquetas etiquetas={lista.etiquetas} />
</div>

De hecho hay varios componentes para esta app de pendientes, pues también existe el componente que muestra las etiquetas, pero eso lo podrás ver en los vídeos del desarrollo de la app o en GitHub.

La encriptación

En esta aplicación open source con JavaScript podemos encriptar el contenido de las notas y listas usando la Web Crypto API y el algoritmo AES con CBC, derivando la clave a partir de una contraseña y generando una sal; todo muy seguro.

Ya te había mostrado cómo encriptar información con JavaScript en el lado del cliente en otro post, así que esto no es nuevo. Lo nuevo es encriptar el contenido cada vez que se guarda una nota o lista:

async desencriptar(contraseña, encriptadoEnBase64) {
    const decoder = new TextDecoder();
    const datosEncriptados = encriptacionService.base64ABuffer(encriptadoEnBase64);
    const sal = datosEncriptados.slice(0, LONGITUD_SAL);
    const vectorInicializacion = datosEncriptados.slice(0 + LONGITUD_SAL, LONGITUD_SAL + LONGITUD_VECTOR_INICIALIZACION);
    const clave = await encriptacionService.derivacionDeClaveBasadaEnContraseña(contraseña, sal, ITERACIONES, LONGITUD_CLAVE, 'SHA-256');
    const datosDesencriptadosComoBuffer = await window.crypto.subtle.decrypt(
        { name: "AES-CBC", iv: vectorInicializacion },
        clave,
        datosEncriptados.slice(LONGITUD_SAL + LONGITUD_VECTOR_INICIALIZACION)
    );
    return decoder.decode(datosDesencriptadosComoBuffer);
},
async encriptar(contraseña, textoPlano) {
    const encoder = new TextEncoder();
    const sal = window.crypto.getRandomValues(new Uint8Array(LONGITUD_SAL));
    const vectorInicializacion = window.crypto.getRandomValues(new Uint8Array(LONGITUD_VECTOR_INICIALIZACION));
    const bufferTextoPlano = encoder.encode(textoPlano);
    const clave = await encriptacionService.derivacionDeClaveBasadaEnContraseña(contraseña, sal, ITERACIONES, LONGITUD_CLAVE, 'SHA-256');
    const encriptado = await window.crypto.subtle.encrypt(
        { name: "AES-CBC", iv: vectorInicializacion },
        clave,
        bufferTextoPlano
    );
    return encriptacionService.bufferABase64([
        ...sal,
        ...vectorInicializacion,
        ...new Uint8Array(encriptado)
    ]);
},

Te he mostrado lo más importante, obviamente en el código fuente completo podrás ver todas las funciones.

Después, en la vista previa solo agrego la clase “desenfocado” si la nota está encriptada. Esto es solo para que se vea “seguro”, pero realmente el contenido es una cadena en base64 que contiene la información encriptada y que nadie puede desencriptar al menos que tenga la contraseña.

El estilo CSS es el siguiente:

.desenfocado {
    color: transparent;
    text-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
}

Menú

App de notas – Menú para agregar lista o nota encriptada

Cuando tocas el botón para agregar una cosa, puedes elegir entre agregar una nota, lista, nota encriptada o lista encriptada. Ese menú está hecho con Svelte y se ve así:

<script>
    let mostrarMenu = false;
    import { link } from "svelte-spa-router";
    import { Icon, PlusCircle } from "svelte-hero-icons";
    import { fly } from "svelte/transition";
</script>

<div class="fixed right-0 bottom-0 origin-bottom-right text-right z-10">
    {#if mostrarMenu}
        <div
            class="mr-2 w0 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
            role="menu"
            aria-orientation="vertical"
            aria-labelledby="menu-button"
            transition:fly
            tabindex="-1"
        >
            <div class="py-1" role="none">
                <a
                    href="/agregar-lista/normal"
                    use:link
                    class="text-gray-700 block px-4 py-2 text-sm"
                    role="menuitem"
                    tabindex="-1">Lista</a
                >

                <a
                    href="/agregar-nota/normal"
                    use:link
                    class="text-gray-700 block px-4 py-2 text-sm"
                    role="menuitem"
                    tabindex="-1">Nota</a
                >
                <a
                    href="/agregar-nota/encriptada"
                    use:link
                    class="text-gray-700 block px-4 py-2 text-sm"
                    role="menuitem"
                    tabindex="-1">Nota protegida</a
                >
                <a
                    href="/agregar-lista/encriptada"
                    use:link
                    class="text-gray-700 block px-4 py-2 text-sm"
                    role="menuitem"
                    tabindex="-1">Lista protegida</a
                >
            </div>
        </div>
    {/if}
    <button
        on:click={() => {
            mostrarMenu = !mostrarMenu;
        }}
        class="right-0 w-12 h-12 text-pink-400"
    >
        <Icon src={PlusCircle} solid />
    </button>
</div>

Los elementos del menú son simples enlaces que llevan a otro componente de la app, ya que estoy usando un enrutador para Svelte. Lo demás está hecho con animaciones propias de Svelte así como iconos y HTML.

Base de datos

Como lo dije anteriormente, esta aplicación web de elementos pendientes usa SQLite3 para almacenar todos los datos. Utiliza relaciones entre las tablas y consultas SQL.

Debido a que solo se puede interactuar con OPFS desde un Web Worker, he expuesto el funcionamiento con Comlink. Al final queda así:

import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
import * as Comlink from "comlink"
const NOMBRE_BASE_DE_DATOS = "notas.sqlite3";
const log = (...args) => { console.log(...args) };
const error = (...args) => { console.log(...args) };
class EnvolturaDeBaseDeDatos {
    db = null;
    async iniciar() {
        const sqlite3 = await sqlite3InitModule({
            print: log,
            printErr: error,
        });
        if ('opfs' in sqlite3) {
            this.db = new sqlite3.oo1.OpfsDb(NOMBRE_BASE_DE_DATOS);
            log('OPFS is available, created persisted database at', this.db.filename);
        } else {
            this.db = new sqlite3.oo1.DB(NOMBRE_BASE_DE_DATOS, 'ct');
            log('OPFS is not available, created transient database', this.db.filename);
        }
        this.db.exec(`CREATE TABLE IF NOT EXISTS listas(
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    titulo TEXT NOT NULL,
    fechaCreacion INTEGER NOT NULL,
    ultimaModificacion INTEGER NOT NULL,
    encriptada INTEGER NOT NULL DEFAULT 0
  );`);
        this.db.exec(`CREATE TABLE IF NOT EXISTS elementos_listas(
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    id_lista INTEGER,
    contenido TEXT NOT NULL,
    terminado INTEGER NOT NULL DEFAULT 0,
    FOREIGN KEY (id_lista) REFERENCES listas(id) ON DELETE CASCADE ON UPDATE CASCADE
  );`);
        this.db.exec(`CREATE TABLE IF NOT EXISTS fondos_listas(
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    id_lista INTEGER,
    nombre TEXT NOT NULL,
    FOREIGN KEY (id_lista) REFERENCES listas(id) ON DELETE CASCADE ON UPDATE CASCADE
  );`);
        this.db.exec(`CREATE TABLE IF NOT EXISTS etiquetas(
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    nombre TEXT NOT NULL
  );`);
        this.db.exec(`CREATE TABLE IF NOT EXISTS etiquetas_listas(
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    id_lista INTEGER,
    id_etiqueta INTEGER,
    FOREIGN KEY (id_lista) REFERENCES listas(id) ON DELETE CASCADE ON UPDATE CASCADE,
    FOREIGN KEY (id_etiqueta) REFERENCES etiquetas(id) ON DELETE CASCADE ON UPDATE CASCADE
  );`);
        this.db.exec(`CREATE TABLE IF NOT EXISTS notas(
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    titulo TEXT NOT NULL,
    contenido TEXT NOT NULL,
    fechaCreacion INTEGER NOT NULL,
    ultimaModificacion INTEGER NOT NULL,
    encriptada INTEGER NOT NULL DEFAULT 0
  );`);
        this.db.exec(`CREATE TABLE IF NOT EXISTS fondos_notas(
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    id_nota INTEGER,
    nombre TEXT NOT NULL,
    FOREIGN KEY (id_nota) REFERENCES notas(id) ON DELETE CASCADE ON UPDATE CASCADE
  );`);
        this.db.exec(`CREATE TABLE IF NOT EXISTS etiquetas_notas(
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    id_nota INTEGER,
    id_etiqueta INTEGER,
    FOREIGN KEY (id_nota) REFERENCES notas(id) ON DELETE CASCADE ON UPDATE CASCADE,
    FOREIGN KEY (id_etiqueta) REFERENCES etiquetas(id) ON DELETE CASCADE ON UPDATE CASCADE
  );`);
        this.exponerFuncionesDeDB();
    }

    constructor() {
    }
    exponerFuncionesDeDB() {
        for (const key in this.db) {
            if (typeof this.db[key] === 'function') {
                this[key] = this.db[key].bind(this.db);
            }
        }
    }
}
Comlink.expose(EnvolturaDeBaseDeDatos);

Para que la aplicación tenga un rendimiento aceptable he colocado la base de datos en un singleton usando las stores de Svelte, así que ahora en donde sea que quiera interactuar con la BD primero hago un servicio:

export class EtiquetasService {
    baseDeDatos = null;
    constructor(baseDeDatos) {
        this.baseDeDatos = baseDeDatos;
    }
    async quitarEtiquetaDeNota(idNota, idEtiqueta) {
        return new Promise(async (resolve, reject) => {
            try {
                await this.baseDeDatos.exec({
                    sql: "DELETE FROM etiquetas_notas WHERE id_nota = ? AND id_etiqueta = ?",
                    bind: [idNota, idEtiqueta],
                    rowMode: "object",
                });
                resolve(true);
            } catch (error) {
                reject(error);
            }
        });
    }
    async agregarEtiquetaANota(idNota, idEtiqueta) {
        return new Promise(async (resolve, reject) => {
            try {
                const etiquetas = await this.baseDeDatos.exec({
                    sql: `INSERT INTO etiquetas_notas (id_nota, id_etiqueta)
                    VALUES
                    (?, ?)`,
                    bind: [idNota, idEtiqueta],
                    rowMode: "object",
                });
                resolve(etiquetas);
            } catch (error) {
                reject(error);
            }
        });
    }
    async quitarEtiquetaDeLista(idLista, idEtiqueta) {
        return new Promise(async (resolve, reject) => {
            try {
                await this.baseDeDatos.exec({
                    sql: "DELETE FROM etiquetas_listas WHERE id_lista = ? AND id_etiqueta = ?",
                    bind: [idLista, idEtiqueta],
                    rowMode: "object",
                });
                resolve(true);
            } catch (error) {
                reject(error);
            }
        });
    }
    async agregarEtiquetaALista(idLista, idEtiqueta) {
        return new Promise(async (resolve, reject) => {
            try {
                const etiquetas = await this.baseDeDatos.exec({
                    sql: `INSERT INTO etiquetas_listas (id_lista, id_etiqueta)
                    VALUES
                    (?, ?)`,
                    bind: [idLista, idEtiqueta],
                    rowMode: "object",
                });
                resolve(etiquetas);
            } catch (error) {
                reject(error);
            }

        });
    }
    async obtenerEtiquetasDeNota(idNota) {
        return new Promise(async (resolve, reject) => {
            try {
                const etiquetas = await this.baseDeDatos.exec({
                    sql: `SELECT etiquetas.id, nombre FROM etiquetas 
                    INNER JOIN
                    etiquetas_notas ON etiquetas_notas.id_etiqueta = etiquetas.id 
                    WHERE etiquetas_notas.id_nota = ?`,
                    bind: [idNota],
                    rowMode: "object",
                });
                resolve(etiquetas);
            } catch (error) {
                reject(error);
            }

        });
    }

    async obtenerEtiquetasDeLista(idLista) {
        return new Promise(async (resolve, reject) => {
            try {
                const etiquetas = await this.baseDeDatos.exec({
                    sql: `SELECT etiquetas.id, nombre FROM etiquetas 
                    INNER JOIN
                    etiquetas_listas ON etiquetas_listas.id_etiqueta = etiquetas.id 
                    WHERE etiquetas_listas.id_lista = ?`,
                    bind: [idLista],
                    rowMode: "object",
                });
                resolve(etiquetas);
            } catch (error) {
                reject(error);
            }

        });
    }

    async guardarEtiqueta(nombre) {
        return new Promise(async (resolve, reject) => {
            try {
                const filasInsertadas = await this.baseDeDatos.exec({
                    sql: "INSERT INTO etiquetas(nombre) VALUES (?) RETURNING id, nombre",
                    bind: [nombre,],
                    rowMode: "object",
                    returnValue: "resultRows"
                });
                if (filasInsertadas.length <= 0) {
                    reject("Error insertando");
                }
                const etiquetaRecienInsertada = filasInsertadas[0];
                resolve(etiquetaRecienInsertada);
            } catch (error) {
                reject(error);
            }
        });
    }
    async buscarEtiquetas(busqueda) {
        return new Promise(async (resolve, reject) => {
            try {
                const etiquetas = await this.baseDeDatos.exec({
                    sql: `SELECT id, nombre FROM etiquetas WHERE nombre LIKE ?`,
                    bind: [`%${busqueda}%`],
                    rowMode: "object",
                });
                resolve(etiquetas);
            } catch (error) {
                reject(error);
            }

        });
    }
    async obtenerEtiquetas() {
        return new Promise(async (resolve, reject) => {
            try {
                const etiquetas = await this.baseDeDatos.exec({
                    sql: `SELECT id, nombre FROM etiquetas`,
                    rowMode: "object",
                });
                resolve(etiquetas);
            } catch (error) {
                reject(error);
            }

        });
    }
}

Y en el constructor del servicio espero recibir la instancia de la base de datos. Ahora dentro de los componentes simplemente lo invoco usando el store previamente mencionado. Por ejemplo, en el onMount:

onMount(async () => {
 singleton.subscribe(async (bd) => {
  etiquetasService = new EtiquetasService(bd);
  notasService = new NotasService(bd);
  const notaOLista = esLista()
   ? await notasService.obtenerListaPorId(params.id)
   : await notasService.obtenerNotaPorId(params.id);
  estaEncriptada = notaOLista.encriptada;
  await obtenerEtiquetasDeListaONota();
  buscarEtiquetas();
 });
});

De este modo solo tengo una instancia de la base de datos; las consultas (DELETE, SELECT, UPDATE e INSERT) las hago en los servicios. Con ello puedo invocar a los servicios desde cualquier lugar pasándoles la base de datos gracias al singleton.

Poniendo todo junto

Te he explicado los detalles más importantes, pero si quieres puedes estudiar el código fuente completo en GitHub: https://github.com/parzibyte/notas_sqlite3_svelte/

También he grabado casi todo el proceso de creación de esta webapp, ya que aunque parezca sencillo no lo es. Puedes revisar las mejoras aplicadas y las complicaciones encontradas en la siguiente lista de reproducción: https://www.youtube.com/playlist?list=PLat1rFhO_zZiYdjPRpCGkAYSFWSDsoR9e

Hice esta app para probar el rendimiento de SQLite3 en el lado del cliente, y al parecer todo funciona de maravilla, así que seguramente escribiré más programas usando estas tecnologías. También me gustó la facilidad de las stores de Svelte y los estilos de Tailwind.

Para terminar te dejo con más tutoriales de JavaScript y Svelte en mi blog.

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.
parzibyte

Programador freelancer listo para trabajar contigo. Aplicaciones web, móviles y de escritorio. PHP, Java, Go, Python, JavaScript, Kotlin y más :) https://parzibyte.me/blog/software-creado-por-parzibyte/

Ver comentarios

Entradas recientes

Desplegar PWA creada con Vue 3, Vite y SQLite3 en Apache

Ya te enseñé cómo convertir una aplicación web de Vue 3 en una PWA. Al…

3 días hace

Arquitectura para wasm con Go, Vue 3, Pinia y Vite

En este artículo voy a documentar la arquitectura que yo utilizo al trabajar con WebAssembly…

3 días hace

Vue 3 y Vite: crear PWA (Progressive Web App)

En un artículo anterior te enseñé a crear un PWA. Al final, cualquier aplicación que…

3 días hace

Errores de Comlink y algunas soluciones

Al usar Comlink para trabajar con los workers usando JavaScript me han aparecido algunos errores…

3 días hace

Esperar promesa para inicializar Store de Pinia con Vue 3

En este artículo te voy a enseñar cómo usar un "top level await" esperando a…

3 días hace

Solución: Apache – Server unable to read htaccess file

Ayer estaba editando unos archivos que son servidos con el servidor Apache y al visitarlos…

4 días hace

Esta web usa cookies.