Fonto Why & How: How do I implement profiles!?

Fonto Why & How: How do I implement profiles!?

In this weekly series Martin describes a question that was raised by a Fonto developer, how it was resolved and why Fonto behaved like that in the first place. This week something different. We recently launched the Profiles feature. In this post I’ll describe how we implemented this in our Documentation editor!

Profiles API

The Profiles API is used in many different places. Anywhere where an author is displayed by name, we can now show some additional information and an avatar! Some examples are the balloons in Fonto Review, Comments, Track textual changes, and Fonto Document History.

We maintain an internal DITA editor that we use to write our documentation. All our narrative documentation (including these blog posts) are written in Fonto. We peer review any new documentation using this editor, and we leave comments with feedback. We run the editor locally and we get user information from the git configuration. The editor is not backed by FDT but by a very simple CMS written in NodeJS with Express.

Implementing the Profiles API

Profiles introduce a new CMS API endpoint. This endpoint is not implemented by default. We have to implement the Profiles API in this CMS.

This first step is to see where we can get the avatar. A centralized provider like Libravatar seems like a good approach. You can send an (MD5 hash of an) email address and it will try to retrieve any avatars from the web. It’s an open-source version of Gravatar. Note that we do not endorse or recommend using Libravatar in your environment!

Next, we’ll need to implement the two new endpoints. They are a POST /profiles/get and GET /profiles/avatar. Let’s go ahead and implement the /profiles/get first. The API will send us multiple profile ids (which are e-mail addresses in our case) and it will expect us to give more information on the author, plus the availability of an avatar. If there is no avatar (in our case, if the author has not signed up with Libravatar yet), we should not tell the editor we have any avatars available.

Let’s start with the boilerplate. We are using Express here. This code will resolve profile ids (which are e-mail addresses) to simple profile objects. They will not have avatars yet.

	router.post(
		'/profiles/get',
		[
			body('context')
				.custom(contextValidator)
				.customSanitizer(contextSanitizer),
			body('profileIds').isArray(),
			body('profileIds.*').isString()
		],
		async (req, res, _next) => {
			try {
				validationResult(req).throw();
			} catch (e) {
				res.status(400).send({ errors: e.mapped() });
				return;
			}

			const profileIds = req.body.profileIds;

			// ... TODO: images!
			
		
			res.json({
				profiles: profileIds.map((profileId, index) => {
					const username = profileId.substring(0, profileId.indexOf('@'));
					// Fonto e-mail addresses are the full name of colleages, separated with '.'
					const nameParts = username.split('.').map(part => {
						switch(part) {
							case 'van':
							case 'der':
							case 'de':
								// Note: these are not capitalized in Dutch
								return part;
							default:
								return part.substring(0, 1).toUpperCase() + part.substring(1);
						}
					});
					return {
						profileId,
						status: 200,
						body: {
							displayName: nameParts.join(' '),
							headLine: 'Writer at Fonto',
							// Getting the correct avatars is the next step!
							avatarVariants: []
						}
					};
				})
			}).status(200).end();
		});

Next we should interface with Libravatar: getting image URLs from email addresses. Talking with Libravatar is quite easy. This function transforms an email address to a URL.

async function generateAvatarUrl(email: string): string {
	const lowerEmail = email.toLowerCase();
	const emailUint8 = new TextEncoder().encode(lowerEmail);
	const hashBuffer = await crypto.subtle.digest('SHA-256', emailUint8);
	const hashArray = Array.from(new Uint8Array(hashBuffer));
	const hashHex = hashArray
		.map((b) => b.toString(16).padStart(2, '0'))
		.join('');
	const url = `https://seccdn.libravatar.org/avatar/${hashHex}`;

	return url;
}

If this URL resolves to a 404, we know that the email address is unknown and the author did not yet sign up. We should return an empty avatar array, so the built-in default avatar can kick in. We should not return a 404 for the whole profile, since the author is still known to the system! We can wire this up with the route to make something like this:

	router.post(
		'/get',
		[
			body('context')
				.custom(contextValidator)
				.customSanitizer(contextSanitizer),
			body('profileIds').isArray(),
			body('profileIds.*').isString()
		],
		async (req, res, _next) => {
			try {
				validationResult(req).throw();
			} catch (e) {
				res.status(400).send({ errors: e.mapped() });
				return;
			}

			const profileIds = req.body.profileIds;

			const profileUrls = await Promise.all(profileIds.map(id => generateAvatarUrl(id)));

			const avatarAvailability = await Promise.all(profileUrls.map(async url => await fetch(url).status === 200));
			res.json({
				profiles: profileIds.map((profileId, index) => {
					const username = profileId.substring(0, profileId.indexOf('@'));
					const nameParts = username.split('.').map(part => {
						switch(part) {
							case 'van':
							case 'der':
							case 'de':
								return part;
							default:
								return part.substring(0, 1).toUpperCase() + part.substring(1);
						}
					});

					const avatarIsAvailable = avatarAvailability[index];

					return {
						profileId,
						status: 200,
						body: {
							displayName: nameParts.join(' '),
							headLine: 'Writer at Fonto',
							avatarVariants: avatarIsAvailable ? [] : ['small']
						}
					};
				})
			}).status(200).end();
		});

We can try this using a cURL command:

➜  ~ curl 'http://localhost:8090/api/profile/get' \
  -H 'content-type: application/json' \
  --data-raw '{"context":{"editSessionToken":"a4157c70-814b-41f9-baf8-58a6d5603d9f"},"profileIds":["martin.middel@fontoxml.com"]}'                

{"profiles":[{"profileId":"martin.middel@fontoxml.com","status":200,"body":{"displayName":"Martin Middel","headLine":"Writer at Fonto","avatarVariants":["small"]}}]}%  

Next is the GET /profiles/avatar endpoint. This can use the same approach as the POST/avatar/get endpoint, but now we do need to download the image. We should proxy this image to the editor.

	router.get(
		'/avatar', [
			query('context')
				.custom(contextValidator)
				.customSanitizer(contextSanitizer),
			query('profileId')
				.isString(),
			query('variant')
				.isString()
				.isIn(['small'])
		],
		async (req, res, _next) => {
			try {
				validationResult(req).throw();
			} catch (e) {
				res.status(400).send({ errors: e.mapped() });
				return;
			}

			const profileId = req.query.profileId;

			const avatarUrl = await generateAvatarUrl(profileId);
			
			// Just redirect to libravatar
			res.redirect(avatarUrl);
		});

All in all, this is the code. We use this (perhaps with some bugs fixed already) in our own documentation editor. Did you already implement profiles? Please reach out if you’re not sure how to implement them, or if you have any feedback!

'use strict';

const crypto = require('crypto');
const express = require('express');
const { body, query, validationResult } = require('express-validator');
const fs = require('fs-extra');
const path = require('path');

// Internal validation and sanitation of the `context` property.
const { contextValidator, contextSanitizer } = require('./validators/contextValidator');

async function generateAvatarUrl(email) {
	const lowerEmail = email.toLowerCase();
	const emailUint8 = new TextEncoder().encode(lowerEmail);
	const hashBuffer = await crypto.subtle.digest('SHA-256', emailUint8);
	const hashArray = Array.from(new Uint8Array(hashBuffer));
	const hashHex = hashArray
		.map((b) => b.toString(16).padStart(2, '0'))
		.join('');
	const url = `https://seccdn.libravatar.org/avatar/${hashHex}`;

	return url;
}

module.exports = async _config => {
	const router = express.Router();

	router.post(
		'/get',
		[
			body('context')
				.custom(contextValidator)
				.customSanitizer(contextSanitizer),
			body('profileIds').isArray(),
			body('profileIds.*').isString()
		],
		async (req, res, _next) => {
			try {
				validationResult(req).throw();
			} catch (e) {
				res.status(400).send({ errors: e.mapped() });
				return;
			}

			const profileIds = req.body.profileIds;

			const profileUrls = await Promise.all(profileIds.map(id => generateAvatarUrl(id)));

			const avatarAvailability = await Promise.all(profileUrls.map(async url => await fetch(url).status === 200));
			res.json({
				profiles: profileIds.map((profileId, index) => {
					const username = profileId.substring(0, profileId.indexOf('@'));
					const nameParts = username.split('.').map(part => {
						switch(part) {
							case 'van':
							case 'der':
							case 'de':
								return part;
							default:
								return part.substring(0, 1).toUpperCase() + part.substring(1);
						}
					});

					const avatarIsAvailable = avatarAvailability[index];

					return {
						profileId,
						status: 200,
						body: {
							displayName: nameParts.join(' '),
							headLine: 'Writer at Fonto',
							avatarVariants: avatarIsAvailable ? [] : ['small']
						}
					};
				})
			}).status(200).end();
		});


	router.get(
		'/avatar', [
			query('context')
				.custom(contextValidator)
				.customSanitizer(contextSanitizer),
			query('profileId')
				.isString(),
			query('variant')
				.isString()
				.isIn(['small'])
		],
		async (req, res, _next) => {
			try {
				validationResult(req).throw();
			} catch (e) {
				res.status(400).send({ errors: e.mapped() });
				return;
			}

			const profileId = req.query.profileId;

			const avatarUrl = await generateAvatarUrl(profileId);
			res.redirect(avatarUrl);
		});

	return router;
};

I hope this explained how Fonto works and why it works like that. During the years we built quite the product, and we are aware some parts work in unexpected ways for those who have not been with it from the start. If you have any points of Fonto you would like some focus on, we are always ready and willing to share! Reach out on Twitter to Martin Middel or file a support issue!

Stay up-to-dateFonto Why & How posts direct in your inbox

Receive updates on new Fonto Why & How blog posts by email

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top