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:
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.
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, apackage.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. ASIGINT
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 ausername
andpassword
combination and decides if the user should be let into the application or not. If theusername
can be found in the database,password
verification checks are run to make sure access is granted to the right person. If theusername
does not exist yet, a new account is created, and the user can proceed to make use of the app.
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 rundocker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
, and leave the configuration as it is, except for changingSMTP_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:
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!