pages/api/account/index.ts
import { NextApiRequest as Req, NextApiResponse as Res } from 'next';
import { dequal } from 'dequal/lite';
import { serialize } from 'cookie';
import to from 'await-to-js';
import { DecodedIdToken, auth } from 'lib/api/firebase';
import { User, UserInterface, UserJSON, isUserJSON } from 'lib/model/user';
import { createUser, getUser, updateUser } from 'lib/api/db/user';
import { APIError } from 'lib/model/error';
import { Availability } from 'lib/model/availability';
import { Meeting } from 'lib/model/meeting';
import { SocialInterface } from 'lib/model/account';
import { Timeslot } from 'lib/model/timeslot';
import clone from 'lib/utils/clone';
import { handle } from 'lib/api/error';
import logger from 'lib/api/logger';
import segment from 'lib/api/segment';
import updateAuthUser from 'lib/api/update/auth-user';
import updatePhoto from 'lib/api/update/photo';
import updateUserOrgs from 'lib/api/update/user-orgs';
import updateUserTags from 'lib/api/update/user-tags';
import verifyAuth from 'lib/api/verify/auth';
import verifyBody from 'lib/api/verify/body';
function mergeSocials(
overrides: SocialInterface[],
baseline: SocialInterface[]
): SocialInterface[] {
const socials = clone(overrides);
baseline.forEach((s) => {
if (!socials.some((sc) => sc.type === s.type)) socials.push(clone(s));
});
return socials;
}
interface ModelInterface<T> {
new (model: T): T;
}
function mergeArrays<T>(
overrides: T[],
baseline: T[],
Model?: ModelInterface<T>
): T[] {
const merged = clone(overrides).map((m) => (Model ? new Model(m) : m));
baseline.forEach((i) => {
if (!merged.some((im) => dequal(im, i)))
merged.push(Model ? new Model(clone(i)) : clone(i));
});
return merged;
}
// TODO: Implement a more sophisticated merging of e.g. overlapping timeslots.
function mergeAvailability(
overrides: Availability,
baseline: Availability
): Availability {
return new Availability(...mergeArrays(overrides, baseline, Timeslot));
}
/**
* Merges the two users giving priority to `overrides` without any loss of data.
* @param overrides - The 1st priority data that will override `baseline`.
* @param baseline - The 2nd priority data that will be overriden.
* @return A user object that fills in as many fields as possible; first takes
* from `overrides` and fallbacks to taking from `baseline`.
*/
function mergeUsers(overrides: User, baseline: User): User {
const merged: UserInterface = {
id: overrides.id || baseline.id,
name: overrides.name || baseline.name,
// Don't override an existing profile picture when the user is logging in
// with Google. Typically, the existing picture will be better (e.g. higher
// quality and an actual face instead of a letter) than the Google avatar.
photo: baseline.photo || overrides.photo,
background: overrides.background || baseline.background,
email: overrides.email || baseline.email,
phone: overrides.phone || baseline.phone,
bio: overrides.bio || baseline.bio,
venue: overrides.venue || baseline.venue,
socials: mergeSocials(overrides.socials, baseline.socials),
orgs: mergeArrays(overrides.orgs, baseline.orgs),
availability: mergeAvailability(
overrides.availability,
baseline.availability
),
subjects: mergeArrays(overrides.subjects, baseline.subjects),
langs: mergeArrays(overrides.langs, baseline.langs),
parents: mergeArrays(overrides.parents, baseline.parents),
meetings: mergeArrays(overrides.meetings, baseline.meetings, Meeting),
visible: overrides.visible || baseline.visible,
roles: mergeArrays(overrides.roles, baseline.roles),
tags: mergeArrays(overrides.tags, baseline.tags),
reference: overrides.reference || baseline.reference,
timezone: overrides.timezone || baseline.timezone,
// Don't override the existing creation timestamp. These will be updated by
// Firestore data conversion methods anyways, so it doesn't really matter.
created: baseline.created || overrides.created,
updated: overrides.updated || baseline.updated,
};
return new User(merged);
}
async function updateAccount(req: Req, res: Res): Promise<void> {
const body = verifyBody<User, UserJSON>(req.body, isUserJSON, User);
// Revert to old behavior if user doesn't already exist; just create it.
const original = (await to(getUser(body.id)))[1];
// Merge the two users giving priority to the request body (but preventing any
// loss of data; `mergeUsers` won't allow falsy values or empty arrays).
const merged = mergeUsers(body, original || new User());
// Either:
// 1. Verify the user's authentication cookie (that this API endpoint sets).
// 2. Verify the user's ID token (sent when the user first logs in).
const [err] = await to(verifyAuth(req.headers, { userId: merged.id }));
if (err) {
// TODO: Guard against CSRF attacks (using a CSRF cookie token).
const jwt = body.token;
if (!jwt) throw new APIError('Could not find an auth cookie or JWT', 401);
// Only process if the user just signed in in the last 5 minutes.
const [error, token] = await to<DecodedIdToken>(
auth.verifyIdToken(jwt, true)
);
if (error) throw new APIError(`Your JWT is invalid: ${error.message}`, 401);
if (!token) throw new APIError('Could not decode your ID token', 401);
if (new Date().getTime() / 1000 - token.auth_time > 5 * 60)
throw new APIError('A more recent login is required. Try again', 401);
// Create and set a new session cookie that expires after 5 days.
const expiresIn = 5 * 24 * 60 * 60 * 1000;
const [e, cookie] = await to(auth.createSessionCookie(jwt, { expiresIn }));
if (e) throw new APIError('Could not create session cookie', 401);
res.setHeader(
'Set-Cookie',
serialize('session', cookie as string, {
maxAge: expiresIn,
httpOnly: true,
secure: true,
})
);
}
const withOrgsUpdate = updateUserOrgs(merged);
const withTagsUpdate = updateUserTags(withOrgsUpdate);
const withPhotoUpdate = await updatePhoto(withTagsUpdate, User);
const withAuthUpdate = await updateAuthUser(withPhotoUpdate);
const [error] = await to(updateUser(withAuthUpdate));
if (error) {
logger.warn(`Error updating user (${error.message}), creating instead...`);
await createUser(withAuthUpdate);
}
res.status(200).json(withAuthUpdate.toJSON());
segment.track({
userId: withAuthUpdate.id,
event: 'Account Updated',
properties: withAuthUpdate.toSegment(),
});
}
async function getAccount(req: Req, res: Res): Promise<void> {
const { uid } = await verifyAuth(req.headers);
res.statusCode = 302;
res.setHeader('Location', `/api/users/${uid}`);
res.end();
segment.track({ userId: uid, event: 'Account Fetched' });
}
/**
* GET - Fetches the profile data of the user who own's the given JWT.
* PUT - Updates (or creates) the user's profile data; this differs from the
* default PUT 'api/users' endpoint as it merges the given data with any
* existing data to prevent data loss (e.g. so users can claim their
* existing profiles).
*
* Requires a JWT; will return the profile data of that user.
*/
export default async function account(req: Req, res: Res): Promise<void> {
try {
switch (req.method) {
case 'GET':
await getAccount(req, res);
break;
case 'PUT':
await updateAccount(req, res);
break;
default:
res.setHeader('Allow', ['GET', 'PUT']);
res.status(405).end(`Method ${req.method as string} Not Allowed`);
}
} catch (e) {
handle(e, res);
}
}