Build a GDPR-Compliant Chat/Messaging App

Lanre A.
Lanre A.
Published March 25, 2020 Updated May 12, 2020

The General Data Protection Regulation (GDPR) is an EU data protection law (passed into law in 2018) that determines how companies use and protect EU citizens’ data. While you might feel this doesn’t concern you, it very likely does! As long as you have EU residents making use of your app, you need to follow the regulations on how the EU mandates that its residents’ data is stored.

GDPR is to be taken seriously; non-compliance will lead to a fine of 4% of global annual revenue, or EUR 20 million!

In this tutorial, I will be describing how to build a messaging app with the Stream Messaging API that allows users to export their chat history for a particular chat room. When requesting the chat data, an email address will be requested, and the data will be sent to the provided email, as an attachment that can be downloaded by the user:

Completed-App

Prerequisites

The application will consist of two parts:

  • A server (the backend): We need a backend server to process authentication for the user and to send emails.
  • A client (the frontend): The frontend will be built as a React.js application.

Creating a Stream Application

Before proceeding with the tutorial, you will need to get your credentials from Stream Chat. Once you’ve created your account, you will need to copy the credentials as depicted in the image below and save them somewhere safe, as they will be used to build the server.

  • Go to the Stream Dashboard
  • Create a new application
  • Click on Chat in the top navigation
  • Scroll down to "App Access Keys" to view your credentials.
Stream-Dashboard

Setting Up Your Environment

Now that we have our Stream information, let's set up our local environment by running the following command:

$ mkdir gdpr-export
$ cd gdpr-export
$ mkdir server
$ npx create-react-app client

We've just created a directory for our project and moved into it, then made directories (inside our main directory) for our server and client (using create-react-app).

Now that we have the framework for our app let's get to populating it!

Building the Server

To begin building out the server, navigate to the server directory that we just created, by running the following command:

$ cd server

Once the above has been done, the next step is to create the files required to build out the application. We'll do so using a mix of automatic and manual processes. You will need a package.json file, in addition to index.js and models.js files. You can create all of these with the following commands:

$ yarn init
$ touch index.js models.js

yarn init will prompt some questions to configure the file it will output. You can hit the enter key for it to select defaults. At the end of the process, a package.json file is going to be created.

The next step is to add the packages we'll be using by employing yarn. The following command will take care of all of those installations:

$ yarn add bcryptjs cors dotenv express lodash.omit 
$ yarn add mongoose mongoose-bcrypt mongoose-findoneorcreate mongoose-timestamp nodemailer stream-chat

Depending on your internet connection, installing these packages might take some time. Once it all succeeds, the next step is to open the models.js file in your editor and paste the following code in it:

const mongoose = require('mongoose');
const findOneOrCreate = require('mongoose-findoneorcreate');
const bcrypt = require('mongoose-bcrypt');
const timestamps = require('mongoose-timestamp');

const UserSchema = new mongoose.Schema(
	{
	    username: {
		    type: String,
			trim: true,
			required: true,
		},
		password: {
			type: String,
			required: true,
			bcrypt: true,
		},
	},
	{
		collection: 'users',
	}
);

UserSchema.plugin(findOneOrCreate);
UserSchema.plugin(bcrypt);
UserSchema.plugin(timestamps);

UserSchema.index({ createdAt: 1, updatedAt: 1 });

module.exports = mongoose.model('User', UserSchema);

In the above, we define a User model, as we will need to store users’ username and their password, to make it easy for them to return to the application at any given time.

Setting Up Routes and Handlers

The next step is to define HTTP routes and handlers. You will need to open up the index.js file in an editor and paste the following code:

const fs = require("fs");
const express = require("express");
const StreamChat = require("stream-chat").StreamChat;
const mongoose = require("mongoose");
const dotenv = require("dotenv");
const omit = require("lodash.omit");
const bcrypt = require("bcryptjs");
const cors = require("cors");
const nodemailer = require("nodemailer");
const User = require("./models");

dotenv.config();

transport = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: parseInt(process.env.SMTP_PORT),
  secure: process.env.SMTP_ENABLE_TLS === "0" ? false : true,
  auth: {
    user: process.env.SMTP_USERNAME,
    pass: process.env.SMTP_PASSWORD
  }
});

transport.verify(function(error, success) {
  if (error) {
    console.error(error);
    process.exit(1);
  }

  console.log("SMTP connection was successfully made");
});

const port = process.env.PORT || 5200;

mongoose.promise = global.Promise;

mongoose.connect(process.env.MONGODB_URI, {
  useNewUrlParser: true,
  useUnifiedTopology: true
});
mongoose.set("useCreateIndex", true);
mongoose.set("useFindAndModify", false);

const db = mongoose.connection;

db.on("error", err => {
  console.error(err);
});

db.on("disconnected", () => {
  console.info("Database disconnected!");
});

process.on("SIGINT", () => {
  mongoose.connection.close(() => {
    process.exit(0);
  });
});

const app = express();
app.use(express.json());
app.use(cors());

const client = new StreamChat(process.env.API_KEY, process.env.API_SECRET);

const channel = client.channel("messaging", "gdpr-chat-export", {
  name: "GDPR Chat export",
  created_by: { id: "admin" }
});

app.post("/users/auth", async (req, res) => {
  const { username, password } = req.body;

  if (username === undefined || username.length == 0) {
    res.status(400).send({
      status: false,
      message: "Please provide your username"
    });
    return;
  }

  if (password === undefined || password.length == 0) {
    res.status(400).send({
      status: false,
      message: "Please provide your password"
    });
    return;
  }

  let user = await User.findOne({ username: username.toLowerCase() });

  if (!user) {
    let user = await User.create({
      username: username,
      password: password
    });

    user = omit(user._doc, ["__v", "createdAt", "updatedAt"]); // and remove data we don't need with the lodash omit

    const token = client.createToken(user._id.toString());

    await client.updateUser({ id: user._id, name: username }, token);

    await channel.create();

    await channel.addMembers([user._id, "admin"]);

    delete user.password;

    user.id = user._id;

    res.json({
      status: true,
      user,
      token
    });
    return;
  }

  const match = await bcrypt.compare(password, user.password);

  if (!match) {
    res.status(403);
    res.json({ message: "Password does not match", status: false });
    return;
  }

  const token = client.createToken(user._id.toString());

  user = omit(user._doc, ["__v", "createdAt", "updatedAt"]);

  delete user.password;

  user.id = user._id;

  res.json({
    status: true,
    user,
    token
  });
});


app.listen(port, () => console.log(`App listening on port ${port}!`));

There is quite a lot going on in the above code; let's do a breakdown of what it does:

  • Creates and maintains a connection to MongoDB. We close the connection from when a SIGINT is received to prevent leakages. A SIGINT is a shutdown event that is sent when you hit CMD+C or CTRL+C - depending on your operating system.
  • It creates an SMTP connection, which will allow us to send the user their chat history.
  • Establishes a connection to Stream Chat.
  • Creates an API endpoint, users/auth. This acts as the authentication resource. It takes a username and password combination and decides if the user should be let into the application or not. If the username can be found in the database, password verification checks are run to make sure access is granted to the right person. If the username does not exist yet, a new account is created, and the user can proceed to make use of the app.
Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

We still need a second API endpoint, to act as the resource for exporting a user’s chat history, and also sending it out to their email. That can be done with the following code:

// This code should come on Line 142 or 143

app.post("/users/export", async (req, res) => {
  const userID = req.body.user_id;

  const email = req.body.email;

  if (email === undefined || email === "") {
    res
      .status(400)
      .send({ status: true, message: "Please provide your email address" });
    return;
  }

  if (userID == "" || userID === undefined) {
    res
      .status(400)
      .send({ status: false, message: "Please provide your user ID" });
    return;
  }

  try {
    const data = await client.exportUser(userID);

    res.status(200).send({
      status: true,
      message: `Your exported data has been sent to your email address, ${email}`
    });

    transport
      .sendMail({
        from: process.env.SMTP_FROM_ADDRESS,
        to: email,
        subject: "Your exported data",
        text: "Kindly find the exported data as an attachment",
        html: "<p>Kindly find the exported data as an attachment</p>",
        attachments: [
          { filename: "data.json", content: Buffer.from(JSON.stringify(data)) }
        ]
      })
      .catch(err => {
        console.log("an error occurred while sending an error", err);
      });
  } catch (err) {
    console.log(err);
    res.status(400).send({ status: false, message: "user not found" });
  }
});

With the above, the server functionality has been built out, but we need to set up the environment variables, to make sure it has access to the right credentials to run correctly. You will need to create a .env file, which you can do using the following command:

$ touch .env

In the .env file, paste the following configuration and edit it to use the credentials for your environment:

API_KEY=STREAM_CHAT_API_KEY
API_SECRET=STREAM_CHAT_API_SECRET
MONGODB_URI=mongodb://127.0.0.1:27017/
SMTP_HOST='127.0.0.1'
SMTP_PORT=1025
SMTP_ENABLE_TLS=0
SMTP_USERNAME=name
SMTP_PASSWORD=pass
SMTP_FROM_ADDRESS=email@domain.com

Please note that for the SMTP connection, you have a few options. If you have docker installed on your computer, you can run docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog, and leave the configuration as it is, except for changing SMTP_FROM_ADDRESS to an address of your choice. If you don’t have docker installed, you can make use of Gmail’s SMTP information or Mailtrap.

As a final step, you need to start the server:

$ node index.js

Building the Client

The first step here will be to return to the client directory we created when we ran npx create-react-app client:

# If you are in the server directory
$ cd ../client

The command we ran to initiate the client directory created many files we'll need. Still, we’ll also need to create two more files (Login.js and ChatView.js) and install some packages (stream-chat-react, stream-chat, axios, and bootstrap) to create our app buildout successfully. The following command can be used to do this:

$ touch Login.js ChatView.js 
$ yarn add stream-chat-react stream-chat axios bootstrap

Depending on your internet connection, this might take a minute.

Let's Get Coding

Our application is going to consist of two pages:

  • The authentication page: This doubles as the homepage. The user will be required to provide their password to gain access to the application.
  • The chat page: This is where the user will be able to chat with other users and export their chat history.

The first step in building these out is to update your App.js file with the following code, replacing its existing contents:

import React, { Component } from "react";
import "./App.css";
import "bootstrap/dist/css/bootstrap.min.css";
import "stream-chat-react/dist/css/index.css";
import Login from "./Login";
import ChatView from "./ChatView";
import { StreamChat } from "stream-chat";

export default class App extends Component {
  state = { isAuthenticated: false };

  constructor(props) {
    super(props);
    this.chatClient = new StreamChat("STREAM_CHAT_API_KEY");
  }

  setUser = (user, token) => {
    this.chatClient.setUser(user, token);
    this.setState({ isAuthenticated: true });
  };

  render() {
    return (
      <div className="App">
        <header className="App-header">
          {this.state.isAuthenticated ? (
            <ChatView chatClient={this.chatClient} />
          ) : (
            <Login cb={this.setUser} />
          )}
        </header>
      </div>
    );
  }
}

Please replace STREAM_CHAT_API_KEY with the unique key you grabbed from your Stream dashboard.

Next in line is going to be the newly created Login.js file! This file is going to contain the component that will render the authentication page. In App.js, there is a check that says "show Login page if the user is not authenticated". In the Login.js file, you will need to paste the following code:

import React, { Component } from "react";
import Form from "react-bootstrap/Form";
import Button from "react-bootstrap/Button";
import axios from "axios";

export default class Login extends Component {
  state = {
    username: "",
    password: ""
  };

  handleSubmit = e => {
    e.preventDefault();

    axios
      .post("http://localhost:5200/users/auth", this.state)
      .then(res => {
        if (!res.data.status) {
          alert(res.data.message);
          return;
        }

        this.props.cb(res.data.user, res.data.token);
      })
      .catch(err => {
        console.error(err);
        alert(
          "Could not log you in now. Please check if password and username matches"
        );
      });
  };

  handlePasswordChange = e => {
    this.setState({
      password: e.target.value
    });
  };

  handleUsernameChange = e => {
    this.setState({
      username: e.target.value
    });
  };

  render() {
    return (
      <div className="Login">
        <Form onSubmit={this.handleSubmit}>
          <Form.Group controlId="email" bsSize="large">
            <Form.Control
              autoFocus
              type="text"
              value={this.state.username}
              onChange={this.handleUsernameChange}
            />
          </Form.Group>
          <Form.Group controlId="password" bsSize="large">
            <Form.Control
              value={this.state.password}
              onChange={this.handlePasswordChange}
              type="password"
            />
          </Form.Group>
          <Button
            block
            bsSize="large"
            disabled={
              !(
                this.state.username.length > 0 && this.state.password.length > 0
              )
            }
            type="submit"
          >
            Login
          </Button>
        </Form>
      </div>
    );
  }
}

With that done, the last required change will be to ChatView.js; this is the page where the chatting experience happens, and also where the user will have the opportunity to export their chat history. In ChatView.js, paste the following code:

import React, { useState, Component } from "react";
import {
  Chat,
  MessageList,
  MessageInput,
  Thread,
  Channel,
  Window,
  ChannelList,
  withChannelContext
} from "stream-chat-react";
import axios from "axios";
import Button from "react-bootstrap/Button";
import Modal from "react-bootstrap/Modal";
import InputGroup from "react-bootstrap/InputGroup";
import FormControl from "react-bootstrap/FormControl";

function GDPRExporter(props) {
  const [show, setShow] = useState(false);
  const [email, setEmail] = useState("");

  const handleClose = () => setShow(false);
  const handleShow = () => setShow(true);

  return (
    <div>
      <Button variant="primary" onClick={handleShow}>
        Export Data
      </Button>

      <Modal show={show} onHide={handleClose}>
        <Modal.Header closeButton>
          <Modal.Title>Modal heading</Modal.Title>
        </Modal.Header>
        <Modal.Body>
          <InputGroup className="mb-3">
            <FormControl
              placeholder="Recipient's email"
              aria-label="Recipient's username"
              aria-describedby="basic-addon2"
              type="email"
              onChange={e => setEmail(e.target.value)}
            />
            <InputGroup.Append>
              <Button
                variant="primary"
                onClick={() => {
                  axios
                    .post("http://localhost:5200/users/export", {
                      user_id: props.user,
                      email: email
                    })
                    .then(res => {
                      alert(res.data.message);
                      handleClose();
                    })
                    .catch(err => {
                      console.error(err);
                      alert("Could not export data");
                    });
                }}
              >
                Export
              </Button>
            </InputGroup.Append>
          </InputGroup>
        </Modal.Body>
        <Modal.Footer>
          An email containing the exported data will be sent to you
        </Modal.Footer>
      </Modal>
    </div>
  );
}

class MyChannelPreview extends Component {
  render() {
    const { setActiveChannel, channel } = this.props;
    const unreadCount = channel.countUnread();

    return (
      <div className="channel_preview">
        <a href="#" onClick={e => setActiveChannel(channel, e)}>
          {channel.data.name}
        </a>

        <span>Unread messages: {unreadCount}</span>
      </div>
    );
  }
}

class MyMessageComponent extends Component {
  render() {
    return (
      <div>
        <b>{this.props.message.user.name}</b> {this.props.message.text}
      </div>
    );
  }
}

const CustomChannelHeader = withChannelContext(
  class CustomChannelHeader extends React.PureComponent {
    render() {
      return (
        <div className="str-chat__header-livestream">
          <div className="str-chat__header-livestream-left">
            <p className="str-chat__header-livestream-left--title">
              {this.props.channel.data.name}
            </p>
            <p className="str-chat__header-livestream-left--members">
              {Object.keys(this.props.members).length} members,{" "}
              {this.props.watcher_count} online
            </p>
          </div>
          <div className="str-chat__header-livestream-right">
            <div className="str-chat__header-livestream-right-button-wrapper">
              <GDPRExporter user={this.props.client.user.id} />
            </div>
          </div>
        </div>
      );
    }
  }
);

export default class ChatView extends Component {
  render() {
    return (
      <Chat client={this.props.chatClient} theme={"messaging light"}>
        <ChannelList Preview={MyChannelPreview} />
        <Channel Message={MyMessageComponent}>
          <Window>
            <CustomChannelHeader />
            <MessageList />
            <MessageInput />
          </Window>
          <Thread />
        </Channel>
      </Chat>
    );
  }
}

One thing to note here is that this also describes how to extend the chat UI with a custom made component. In the example above, we have created a button which, when clicked, will open a modal to collect the user’s email address, where the chat data will be sent.

Seeing the App in Action

Finally, you will need to run the client app as a whole. That can be done using:

$ yarn start

The app displayed when you run the above command should look a lot like this:
Gif-of-Functioning-GDPR-App

Wrapping Up

In this tutorial, we made use of Stream Chat to build a fully functional chat application that allows users to communicate, while also allowing them to export their chat data, whenever needed. You can improve on this by making the user request for data deletion.

You can find the full source code on GitHub.

Happy Coding!

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->