oaeproject/Hilary

View on GitHub
packages/oae-content/lib/internal/util.js

Summary

Maintainability
C
7 hrs
Test Coverage
A
96%
/*!
 * 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 querystring from 'node:querystring';
import { format } from 'node:util';
import { setUpConfig } from 'oae-config';
import _ from 'underscore';
import { ContentConstants } from 'oae-content/lib/constants.js';

import { logger } from 'oae-logger';

import { ActivityConstants } from 'oae-activity/lib/constants.js';
import * as ActivityModel from 'oae-activity/lib/model.js';
import PreviewConstants from 'oae-preview-processor/lib/constants.js';
import * as Signature from 'oae-util/lib/signature.js';
import * as TenantsUtil from 'oae-tenants/lib/util.js';

import * as amazonS3 from '../backends/amazons3.js';
import * as localDirStorage from '../backends/local.js';
import * as remoteDirStorage from '../backends/remote.js';
import * as testStorage from '../backends/test.js';

const availableBackends = { local: localDirStorage, amazon: amazonS3, remote: remoteDirStorage, test: testStorage };

const ContentConfig = setUpConfig('oae-content');

const log = logger('oae-content-util');

const TIME_1_WEEK_IN_SECONDS = 7 * 24 * 60 * 60;

/**
 * Get the storage backend for a uri, if the uri is unspecified it will return the default backend
 * for a tenant. If the backend could not be found, this will throw an error! The uri will be checked
 * before defaulting to the configured tenant backend. This allows for a tenant to switch storage
 * systems yet still serve the old files
 *
 * @param  {Context}    ctx     Current execution context
 * @param  {String}     [uri]   An storage URI that references a file in storage. If not specified, the tenant default backend will be used
 * @return {Backend}            The appropriate backend
 * @throws {Error}              Thrown if there is no backend available that matches the `uri`
 */
const getStorageBackend = function (ctx, uri) {
  let backendName = null;
  if (uri) {
    backendName = uri.split(':')[0];
  } else {
    // Use the tenant's default
    backendName = ContentConfig.getValue(ctx.tenant().alias, 'storage', 'backend');
    if (!backendName) {
      log(ctx).error('There was no storage backend configured, this should not happen');
      throw new Error('There was no storage backend configured for name: ' + backendName);
    }
  }

  try {
    // return require('oae-content/lib/backends/' + backendName);
    return availableBackends[backendName];
  } catch (error) {
    log(ctx).error({ err: error }, "Couldn't load the backend %s", backendName);
    throw new Error('Could not find storage back-end ' + backendName);
  }
};

/**
 * Convert the content object into one that can be returned by the APIs to the consumer
 *
 * @param  {Context}    ctx         Current execution context
 * @param  {Content}    content     The content object that needs to be augmented with signatures and download urls
 * @param  {Number}     [duration]  The approximate time in seconds for which the generated picture URLs will be valid. The larger this value is, the more effective browser caching is on the download which is good for thumbnail images. Default: 1 week
 * @param  {Number}     [offset]    The minimum time in seconds for which the generated picture URLs will be valid. Default: 1 week
 */
const augmentContent = function (ctx, content, duration, offset) {
  // Generate a signature for this content item. In combination with the previews object, the UI should be able to construct
  // download URLs for the preview items
  content.signature = Signature.createExpiringResourceSignature(ctx, content.id);

  // Replace all the different sizes of back-end image URIs to signed URLs the consumer can use
  if (content.previews) {
    if (content.previews.thumbnailUri) {
      content.previews.thumbnailUrl = getSignedDownloadUrl(ctx, content.previews.thumbnailUri, duration, offset);
      delete content.previews.thumbnailUri;
    }

    if (content.previews.smallUri) {
      content.previews.smallUrl = getSignedDownloadUrl(ctx, content.previews.smallUri, duration, offset);
      delete content.previews.smallUri;
    }

    if (content.previews.mediumUri) {
      content.previews.mediumUrl = getSignedDownloadUrl(ctx, content.previews.mediumUri, duration, offset);
      delete content.previews.mediumUri;
    }

    if (content.previews.largeUri) {
      content.previews.largeUrl = getSignedDownloadUrl(ctx, content.previews.largeUri, duration, offset);
      delete content.previews.largeUri;
    }

    if (content.previews.wideUri) {
      content.previews.wideUrl = getSignedDownloadUrl(ctx, content.previews.wideUri, duration, offset);
      delete content.previews.wideUri;
    }
  }
};

/**
 * Using a download strategy, derive the download reference (path and querystring) that any user (even anonymous) can
 * use to download the target file. If the uri represents a target that has a "direct" download strategy, the target
 * will be provided directly rather than a signed request that comes back through the /api/download/signed endpoint.
 * Therefore the expiry parameters `duration` and `offset` are invalid concepts and have no effect in that case
 *
 * @param  {Context}    ctx         Current execution context
 * @param  {String}     uri         The storage URI of the item being downloaded
 * @param  {Number}     [duration]  The approximate time in seconds for which the generated picture URLs will be valid. The larger this value is, the more effective browser caching is on the download which is good for thumbnail images. If `-1`, the download URL will be valid forever. Default: 1 week
 * @param  {Number}     [offset]    The minimum time in seconds for which the generated picture URLs will be valid. If the `duration` is `-1`, then this value has no impact. Default: 1 week
 * @return {String}                 The url that can be used in a browser to download the file
 */
const getSignedDownloadUrl = function (ctx, uri, duration, offset) {
  duration = duration || TIME_1_WEEK_IN_SECONDS;
  offset = offset || TIME_1_WEEK_IN_SECONDS;

  const downloadStrategy = getStorageBackend(ctx, uri).getDownloadStrategy(ctx.tenant().alias, uri);
  if (downloadStrategy.strategy === ContentConstants.backend.DOWNLOAD_STRATEGY_DIRECT) {
    // When using the direct strategy, the user is linked directly to the item, therefore does not have to be given a
    // secure link through /api/download/signed for redirection or download
    return downloadStrategy.target;
  }

  // All we sign for the download url is the URI
  const data = { uri };
  const signatureData =
    duration === -1 ? { signature: Signature.sign(data) } : Signature.createExpiringSignature(data, duration, offset);

  // Attach the signature and expiry time to the final data object
  _.extend(data, signatureData);

  return format('/api/download/signed?%s', querystring.stringify(data));
};

/**
 * Verify the download parameters for a signed download. This takes in a query string as it is the inverse of
 * `getSignedDownloadUrl` which produces a download url with a query string
 *
 * @param  {Object}     qs              The query string object that was received in the download request
 * @param  {String}     qs.uri          The requested file uri
 * @param  {Number}     [qs.expires]    The expiry time (millis since the epoch) of the signature. If not specified, this download URL does not expire
 * @param  {String}     qs.signature    The signature string of the request
 * @return {String}                     If the request is authentic and not expired, the result is the `uri` that the user is attempting to download. Otherwise, this will return `null`
 */
const verifySignedDownloadQueryString = function (qs) {
  if (qs.expires) {
    return Signature.verifyExpiringSignature({ uri: qs.uri }, qs.expires, qs.signature) ? qs.uri : null;
  }

  return Signature.verify({ uri: qs.uri }, qs.signature) ? qs.uri : null;
};

/**
 * Create the persistent content entity that can be transformed into an activity entity for the UI.
 *
 * @param  {Content}   content      The content item that provides the data for the entity.
 * @return {Object}                 An object containing the entity data that can be transformed into a UI content activity entity
 */
const createPersistentContentActivityEntity = function (content) {
  // Ensure the content item does not contain the revision HTML, as it can be
  // massive and is not needed in the activity
  content = _.omit(content, 'latestRevision');

  // Build the cleaned activity entity
  return new ActivityModel.ActivityEntity('content', content.id, content.visibility, {
    content
  });
};

/**
 * Transform a content object into an activity entity suitable to be displayed in an activity stream.
 *
 * For more details on the transformed entity model, @see ActivityAPI#registerActivityEntityTransformer
 *
 * @param  {Context}            ctx                 Current execution context
 * @param  {Object}             entity              The persisted activity entity to transform
 * @param  {Object}             previews            An object that holds the thumbnailUri and wideUri if they are present on the revision
 * @return {ActivityEntity}                         The activity entity that represents the given content item
 */
const transformPersistentContentActivityEntity = function (ctx, entity, previews) {
  const { content } = entity;
  const tenant = ctx.tenant();

  const baseUrl = TenantsUtil.getBaseUrl(tenant);
  const globalId = baseUrl + '/api/content/' + content.id;
  const profileUrl = baseUrl + content.profilePath;

  const options = {};

  // The `content.displayName` is the displayName of the piece of content *at the time when the activity was generated*.
  // Some content items get their displayName updated via the preview processor (ex: youtube links).
  // We use the updated displayName (if it's available) as it looks nicer to the user.
  options.displayName = content.displayName;
  options.url = profileUrl;

  options.ext = {};
  options.ext[ActivityConstants.properties.OAE_ID] = content.id;
  options.ext[ActivityConstants.properties.OAE_VISIBILITY] = content.visibility;
  options.ext[ActivityConstants.properties.OAE_PROFILEPATH] = content.profilePath;
  options.ext[ContentConstants.activity.PROP_OAE_CONTENT_TYPE] = content.resourceSubType;
  options.ext[ContentConstants.activity.PROP_OAE_CONTENT_MIMETYPE] = content.mime;
  options.ext[ContentConstants.activity.PROP_OAE_REVISION_ID] = content.latestRevisionId;

  // Create image URLs to the resources that will be valid forever
  if (previews.thumbnailUri) {
    const width = PreviewConstants.SIZES.IMAGE.THUMBNAIL;
    const thumbnailUrl = getSignedDownloadUrl(ctx, previews.thumbnailUri, -1);
    options.image = new ActivityModel.ActivityMediaLink(thumbnailUrl, width, width);
  }

  if (previews.wideUri) {
    const wideUrl = getSignedDownloadUrl(ctx, previews.wideUri, -1);
    options.ext[ContentConstants.activity.PROP_OAE_WIDE_IMAGE] = new ActivityModel.ActivityMediaLink(
      wideUrl,
      PreviewConstants.SIZES.IMAGE.WIDE_WIDTH,
      PreviewConstants.SIZES.IMAGE.WIDE_HEIGHT
    );
  }

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

/**
 * Transform a content object into an activity entity suitable to be displayed in an activity stream.
 *
 * For more details on the transformed entity model, @see ActivityAPI#registerActivityEntityTransformer
 *
 * @param  {Context}           ctx         Current execution context
 * @param  {Object}            entity      The persisted activity entity to transform.
 * @param  {Object}            previews    An object that holds the thumbnailUri and wideUri if they are present on the revision.
 * @return {Content}                       The content object suitable for an internal stream
 */
const transformPersistentContentActivityEntityToInternal = function (ctx, entity, previews) {
  const { content } = entity;
  content.previews = _.extend(content.previews, previews);
  augmentContent(ctx, content, -1);
  return content;
};

export {
  getStorageBackend,
  augmentContent,
  getSignedDownloadUrl,
  verifySignedDownloadQueryString,
  createPersistentContentActivityEntity,
  transformPersistentContentActivityEntity,
  transformPersistentContentActivityEntityToInternal
};