oaeproject/Hilary

View on GitHub
packages/oae-principals/lib/util.js

Summary

Maintainability
D
1 day
Test Coverage
A
97%
/*!
 * Copyright 2014 Apereo Foundation (AF) Licensed under the
 * Educational Community License, Version 2.0 (the "License"); you may
 * not use this file except in compliance with the License. You may
 * obtain a copy of the License at
 *
 *     http://opensource.org/licenses/ECL-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an "AS IS"
 * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */

import _ from 'underscore';
import shortid from 'shortid';

import { ActivityConstants } from 'oae-activity/lib/constants.js';
import { AuthzConstants } from 'oae-authz/lib/constants.js';
import { PrincipalsConstants } from 'oae-principals/lib/constants.js';

import * as ActivityModel from 'oae-activity/lib/model.js';
import * as AuthzUtil from 'oae-authz/lib/util.js';
import * as ContentUtil from 'oae-content/lib/internal/util.js';
import * as TenantsUtil from 'oae-tenants/lib/util.js';
import PrincipalsEmitter from 'oae-principals/lib/internal/emitter.js';
import { User } from './model.js';
import * as PrincipalsDAO from './internal/dao.js';

/**
 * Get a principal (user or group)
 *
 * @param  {Context}    ctx                 Current execution context
 * @param  {String}     principalId         The ID of the principal that should be retrieved.
 * @param  {Function}   callback            Standard callback function
 * @param  {Object}     callback.err        An error that occurred, if any
 * @param  {Group|User} callback.principal  The asked for principal.
 */
const getPrincipal = function (ctx, principalId, callback) {
  getPrincipals(ctx, [principalId], (error, principals) => {
    if (error) {
      return callback(error);
    }

    if (!principals[principalId]) {
      return callback({ code: 404, msg: 'Could not find principal with id ' + principalId });
    }

    return callback(null, principals[principalId]);
  });
};

/**
 * Get a set of principals (user or groups). This method *will* return an error if some of the principals
 * don't exist
 *
 * @param  {Context}        ctx                                 Current execution context
 * @param  {String[]}       principalIds                        The ID of the principal that should be retrieved
 * @param  {Object}         callback.err                        An error that occurred, if any
 * @param  {Object}         callback.err.existingPrincipals     Object representing the principals that existed in storage. The keys will be the principal ids and the values will be the user-friendly principal basic profiles
 * @param  {String[]}       callback.err.missingPrincipalIds    The ids of the principals that did not exist
 * @param  {Object}         callback.principals                 Object representing the retrieved principals. The keys will be the principal ids and the values will be the principal basic profiles
 */
const getPrincipals = function (ctx, principalIds, callback) {
  PrincipalsDAO.getPrincipals(principalIds, null, (error, principals) => {
    if (error) {
      return callback(error);
    }

    _transformPrincipals(ctx, principals);

    return callback(null, principals);
  });
};

/**
 * Touch the last modified date of the given principal
 *
 * @param  {Principal}  oldPrincipal                The principal whose last modified date to touch
 * @param  {Function}   callback                    Standard callback function
 * @param  {Object}     callback.err                An error that occurred, if any
 * @param  {Principal}  callback.updatedPrincipal   The updated version of the principal with its last modifed date updated
 */
const touchLastModified = function (oldPrincipal, callback) {
  // Const oldLastModified = oldPrincipal.lastModified;
  const newLastModified = Date.now().toString();
  const updatedProfileFields = { lastModified: newLastModified };
  PrincipalsDAO.updatePrincipal(oldPrincipal.id, updatedProfileFields, (error) => {
    if (error) {
      return callback(error);
    }

    const updatedPrincipal = _.extend({}, oldPrincipal, updatedProfileFields);
    return callback(null, updatedPrincipal);
  });
};

/**
 * Set the verified email address of the specified user, clearing any invitations that they have
 * pending
 *
 * @param  {Context}    ctx             Current execution context
 * @param  {String}     userId          The id of the user to verify the email address for
 * @param  {String}     email           The email address to place as the verified email
 * @param  {Function}   callback        Standard callback function
 * @param  {Object}     callback.err    An error that occurred, if any
 * @param  {User}       callback.user   The updated user
 */
const verifyEmailAddress = function (ctx, user, email, callback) {
  PrincipalsDAO.setEmailAddress(user, email, (error, updatedUser) => {
    if (error) {
      return callback(error);
    }

    PrincipalsEmitter.emit(PrincipalsConstants.events.VERIFIED_EMAIL, ctx, updatedUser, (errs) => {
      if (errs) {
        return callback(_.first(errs));
      }

      return callback(null, updatedUser);
    });
  });
};

/**
 * Create a unique id for a group
 *
 * @param  {String}     tenantAlias     The alias of the tenant for which to generate the group id
 * @return {String}                     The id for the group
 */
const createGroupId = function (tenantAlias) {
  return AuthzUtil.toId(AuthzConstants.principalTypes.GROUP, tenantAlias, shortid.generate());
};

/**
 * Determine whether or not the given string represents a group id.
 *
 * @param  {String}  groupId    A string that may or may not be a group id
 * @return {Boolean}            Whether or not the provided identifier is a group identifier.
 */
const isGroup = function (groupId) {
  return PrincipalsDAO.isGroup(groupId);
};

/**
 * Determine whether or not the given string represents a user id.
 *
 * @param  {String}  userId     A string that may or may not be a user id
 * @return {Boolean}            Whether or not the provided identifier is a user identifier.
 */
const isUser = function (userId) {
  return PrincipalsDAO.isUser(userId);
};

/**
 * Hide sensitive user information that the (possibly anonymous) user in context does not have access to see.
 *
 * If the current user has no access to the user, then the following will be scrubbed:
 *
 *  1. The displayName will be replaced by the publicAlias
 *  2. The following fields will be deleted
 *      ** publicAlias
 *      ** locale
 *      ** smallPicture
 *      ** smallPictureUri
 *      ** mediumPicture
 *      ** mediumPictureUri
 *      ** largePicture
 *      ** largePictureUri
 *
 * If the user has access but is not the user themself, the publicAlias is scrubbed from the user.
 *
 * @param  {Context}     ctx     Current execution context
 * @param  {User}        user    The user object to hide as necessary
 * @api private
 */
const hideUserData = (ctx, user) => {
  const isAnon = !ctx.user();
  const isLoggedIn = TenantsUtil.isLoggedIn(ctx, user.tenant.alias);
  const isTargetUser = !isAnon && ctx.user().id === user.id;
  const isAdmin = !isAnon && ctx.user().isAdmin && ctx.user().isAdmin(user.tenant.alias);
  const needsLoggedIn = user.visibility === AuthzConstants.visibility.LOGGEDIN;
  const isPrivate = user.visibility === AuthzConstants.visibility.PRIVATE;

  if ((!user.deleted && isAdmin) || isTargetUser) {
    return user;
  }

  // Hide the sensitive profile information if the user has limited access
  if (user.deleted || (needsLoggedIn && !isLoggedIn) || isPrivate) {
    /**
     * Show user's publicAlias instead of displayName if it doesn't resemble a Shibboleth identifier
     */
    const invalid = /https?:\/\/|shibboleth!|@/i;
    if (!invalid.test(user.publicAlias)) {
      user.displayName = user.publicAlias;
    }

    user.picture = {};

    /**
     * The profile path should be removed from the user object as well.
     * This will tell the UI when to offer a link to the profile page and when not to
     */
    delete user.profilePath;
  }

  // Always delete these guys if it is not the target user or admin
  delete user.acceptedTC;
  delete user.email;
  delete user.emailPreference;
  delete user.locale;
  delete user.notificationsUnread;
  delete user.notificationsLastRead;
  delete user.publicAlias;
};

/**
 * Given a user object, apply the given set of basic profile updates and return the updated user.
 *
 * @param  {User}      user         The user object on which to apply the updates
 * @param  {Object}    fieldUpdates An object of fieldKey -> value of the field updates to apply to the user object
 * @return {User}                   The updated user with all field updates applied
 */
const createUpdatedUser = function (user, fieldUpdates) {
  const newDisplayName = fieldUpdates.displayName || user.displayName;
  const newEmail = fieldUpdates.email || user.email;
  const newUser = new User(user.tenant.alias, user.id, newDisplayName, newEmail, {
    visibility: fieldUpdates.visibility || user.visibility,
    emailPreference: fieldUpdates.emailPreference || user.emailPreference,
    locale: fieldUpdates.locale || user.locale,
    publicAlias: fieldUpdates.publicAlias || user.publicAlias,
    notificationsUnread: fieldUpdates.notificationsUnread || user.notificationsUnread,
    notificationsLastRead: fieldUpdates.notificationsLastRead || user.notificationsLastRead,
    acceptedTC: fieldUpdates.acceptedTC || user.acceptedTC,
    isGlobalAdmin: user.isGlobalAdmin(),
    isTenantAdmin: user.isTenantAdmin(user.tenant.alias)
  });

  return newUser;
};

/// ///////////////////////////
// ACTIVITY UTILITY METHODS //
/// ///////////////////////////

/**
 * Create the persistent user entity that can be transformed into an activity entity for the UI.
 *
 * @param  {String}    userId      The ID of the user
 * @param  {User}      [user]      The user that supplies the data for the entity
 * @return {Object}                An object containing the entity data that can be transformed into a UI user activity entity
 */
const createPersistentUserActivityEntity = function (userId, user) {
  return new ActivityModel.ActivityEntity('user', userId, user.visibility, { user });
};

/**
 * Transform a persisted user activity entity that can be used in an activity stream The returned activity entity will be
 * output in the `activitystrea.ms`-compliant data model
 *
 * For more details on the transformed entity model, @see ActivityAPI#registerActivityEntityType
 *
 * @param  {Context}        ctx                 Current execution context
 * @param  {String}         userId              The id of the user
 * @param  {User}           [user]              The user object. If not specified, the generated entity with be abbreviated with just the information available
 * @return {ActivityEntity}                     The activity entity that represents the given user data
 */
const transformPersistentUserActivityEntity = function (ctx, userId, user) {
  const tenant = ctx.tenant();
  const baseUrl = TenantsUtil.getBaseUrl(tenant);
  const globalId = baseUrl + '/api/user/' + userId;

  const options = { ext: {} };
  options.ext[ActivityConstants.properties.OAE_ID] = userId;

  if (user) {
    hideUserData(ctx, user);

    // Signed user profile picture URLs will last forever
    _generatePictureURLs(ctx, user, -1);

    options.displayName = user.displayName;
    if (user.profilePath) {
      options.url = baseUrl + user.profilePath;

      if (user.picture.small) {
        options.ext[ActivityConstants.properties.OAE_THUMBNAIL] = new ActivityModel.ActivityMediaLink(
          user.picture.small,
          PrincipalsConstants.picture.size.SMALL,
          PrincipalsConstants.picture.size.SMALL
        );
      }

      if (user.picture.medium) {
        options.image = new ActivityModel.ActivityMediaLink(
          user.picture.medium,
          PrincipalsConstants.picture.size.MEDIUM,
          PrincipalsConstants.picture.size.MEDIUM
        );
      }
    }

    options.ext[ActivityConstants.properties.OAE_VISIBILITY] = user.visibility;
    options.ext[ActivityConstants.properties.OAE_PROFILEPATH] = user.profilePath;
  }

  return new ActivityModel.ActivityEntity('user', globalId, user.visibility, options);
};

/**
 * Transform a persisted user activity entity that can be used in an activity stream. The user property will be taken from
 * the persisted entity, scrubbed and returned as the "transformed entity"
 *
 * @param  {Context}    ctx     Current execution context
 * @param  {String}     userId  The id of the user
 * @param  {User}       [user]  The user object. If not specified, the generated entity with be abbreviated with just the information available
 * @return {User}               The scrubbed user object
 */
const transformPersistentUserActivityEntityToInternal = function (ctx, userId, user) {
  if (user) {
    // Signed user profile picture URLs will last forever
    hideUserData(ctx, user);
    _generatePictureURLs(ctx, user, -1);
    return user;
  }

  return { id: userId };
};

/**
 * Create the persistent group entity that can be transformed into an activity entity for the UI
 *
 * @param  {String}    groupId     The ID of the group
 * @param  {Group}     [group]     The group that supplies the data for the entity. If not specified, only the minimal data will be returned for transformation.
 * @return {Object}                An object containing the entity data that can be transformed into a UI group activity entity
 */
const createPersistentGroupActivityEntity = function (groupId, group) {
  return new ActivityModel.ActivityEntity('group', groupId, group.visibility, { group });
};

/**
 * Transform a persisted group activity entity that can be used in an activity stream
 *
 * For more details on the transformed entity model, @see ActivityAPI#registerActivityEntityType
 *
 * @param  {Context}        ctx                 Current execution context
 * @param  {String}         groupId             The id of the group
 * @param  {Group}          [group]             The group object. If not specified, the generated entity with be abbreviated with just the information available
 * @return {ActivityEntity}                     The activity entity that represents the given group data
 */
const transformPersistentGroupActivityEntity = function (ctx, groupId, group) {
  const tenant = ctx.tenant();
  const baseUrl = TenantsUtil.getBaseUrl(tenant);

  // Note that the globalId is used as a canonical reference and should not depend on whether or not
  // the tenant is using http or https
  const globalId = 'http://' + tenant.host + '/api/group/' + groupId;

  const options = { ext: {} };
  options.ext[ActivityConstants.properties.OAE_ID] = groupId;

  if (group) {
    // Signed group picture URLs will last forever
    _generatePictureURLs(ctx, group, -1);

    options.displayName = group.displayName;
    options.url = baseUrl + group.profilePath;

    if (group.picture.small) {
      options.ext[ActivityConstants.properties.OAE_THUMBNAIL] = new ActivityModel.ActivityMediaLink(
        group.picture.small,
        PrincipalsConstants.picture.size.SMALL,
        PrincipalsConstants.picture.size.SMALL
      );
    }

    if (group.picture.medium) {
      options.image = new ActivityModel.ActivityMediaLink(
        group.picture.medium,
        PrincipalsConstants.picture.size.MEDIUM,
        PrincipalsConstants.picture.size.MEDIUM
      );
    }

    // Extension properties
    options.ext[ActivityConstants.properties.OAE_VISIBILITY] = group.visibility;

    if (!group.deleted) {
      options.ext[ActivityConstants.properties.OAE_PROFILEPATH] = group.profilePath;
    }

    options.ext[ActivityConstants.properties.OAE_JOINABLE] = group.joinable;
  }

  return new ActivityModel.ActivityEntity('group', globalId, group.visibility, options);
};

/**
 * Transform a persisted group activity entity that can be used in an activity stream. The group property will be
 * taken from the persisted entity, scrubbed and returned as the "transformed entity"
 *
 * For more details on the transformed entity model, @see ActivityAPI#registerActivityEntityType
 *
 * @param  {Context}    ctx                 Current execution context
 * @param  {String}     groupId             The id of the group
 * @param  {Group}      [group]             The group object. If not specified, the generated entity with be abbreviated with just the information available
 * @return {Group}                          The group object
 */
const transformPersistentGroupActivityEntityToInternal = function (ctx, groupId, group) {
  if (group) {
    // Signed group picture URLs will last forever
    _generatePictureURLs(ctx, group, -1);
    return group;
  }

  return { id: groupId };
};

/**
 * Given a set of principals, transform their model so the required UI-level information is
 * available
 *
 * @param  {Context}    ctx         Current execution context
 * @param  {Object[]}   principals  The array of users and groups to transform
 * @api private
 */
const _transformPrincipals = function (ctx, principals) {
  _.each(principals, (principal) => {
    _generatePictureURLs(ctx, principal);
    if (isUser(principal.id)) {
      hideUserData(ctx, principal);
    }
  });
};

/**
 * Replace the URI properties with signed URL paths to actually download the files
 *
 * @param  {Context}        ctx         Current execution context
 * @param  {Group|User}     principal   The principal for which to generate the picture URL paths
 * @param  {Number}         [duration]  The approximate time in seconds for which the generated picture URLs will be valid. Default: 1 week
 * @param  {Number}         [offset]    The minimum time in seconds for which the generated picture URLs will be valid. Default: 1 week
 * @api private
 */
const _generatePictureURLs = function (ctx, principal, duration, offset) {
  if (principal.picture.smallUri) {
    principal.picture.small = ContentUtil.getSignedDownloadUrl(ctx, principal.picture.smallUri, duration, offset);
    delete principal.picture.smallUri;
  }

  if (principal.picture.mediumUri) {
    principal.picture.medium = ContentUtil.getSignedDownloadUrl(ctx, principal.picture.mediumUri, duration, offset);
    delete principal.picture.mediumUri;
  }

  if (principal.picture.largeUri) {
    principal.picture.large = ContentUtil.getSignedDownloadUrl(ctx, principal.picture.largeUri, duration, offset);
    delete principal.picture.largeUri;
  }
};

export {
  getPrincipal,
  getPrincipals,
  touchLastModified,
  verifyEmailAddress,
  createGroupId,
  isGroup,
  isUser,
  hideUserData,
  createUpdatedUser,
  createPersistentUserActivityEntity,
  transformPersistentUserActivityEntity,
  transformPersistentUserActivityEntityToInternal,
  createPersistentGroupActivityEntity,
  transformPersistentGroupActivityEntity,
  transformPersistentGroupActivityEntityToInternal
};