Home Reference Source

packages/skygear-core/lib/cloud/asset.js

import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import mime from 'mime-types';
import {SkygearResponse} from './transport/common';
import registry from './registry';
import {settings} from './settings';
import S3URLSigner from 'amazon-s3-url-signer';
import request from 'superagent';
import URL from 'url';

/**
 * staticAssetHandler — default handler for serving static assets with during
 * development.
 */

export function staticAssetHandler(req) {
  if (req.path.indexOf('/static') !== 0) {
    throw new Error('The base path is not static asset');
  }
  let matchedPrefix = null;
  Object.keys(registry.staticAsset).forEach((prefix) => {
    if (req.path.indexOf('/static' + prefix) === 0) {
      matchedPrefix = prefix;
    }
  });
  if (!matchedPrefix) {
    return new SkygearResponse({
      statusCode: 404
    });
  }
  const matchedFunc = registry.staticAsset[matchedPrefix];
  const absPrefix = matchedFunc();
  const finalPath = req.path.replace('/static' + matchedPrefix, absPrefix);
  if (!fs.existsSync(finalPath)) {
    return new SkygearResponse({
      statusCode: 404
    });
  }
  const data = fs.readFileSync(finalPath, {
    flag: 'r'
  });
  const contentType = mime.contentType(path.extname(finalPath));
  return new SkygearResponse({
    headers: {
      'Content-Type': [contentType]
    },
    body: data
  });
}

class Signer {
  /**
   * @name Signer#sign
   * @return {Promise<string>} A Promise of the url
   */
  async sign() {
    throw new Error('Not implemented, subclass should override this method');
  }
}

/* eslint-disable camelcase */
export class FSSigner extends Signer {
  constructor(_settings) {
    super();
    this.assetStoreURLPrefix = _settings.assetStoreURLPrefix;
    this.assetStoreURLExpireDuration =
      _settings.assetStoreURLExpireDuration;
    this.assetStoreSecret = _settings.assetStoreSecret;
  }

  async sign(name) {
    const prefix = this.assetStoreURLPrefix;
    const duration = this.assetStoreURLExpireDuration;
    const expire = Math.floor(Date.now() / 1000) + duration;
    const secret = this.assetStoreSecret;
    const hash = crypto.createHmac('sha256', secret)
      .update(name)
      .update(expire.toString())
      .digest('base64');
    const fullURL =
      `${prefix}/${name}?expiredAt=${expire.toString()}&signature=${hash}`;
    return fullURL;
  }
}

export class CloudSigner extends Signer {
  constructor(_settings) {
    super();
    this.request = request;
    this.appName = _settings.appName;
    this.assetStoreURLExpireDuration =
      _settings.assetStoreURLExpireDuration;
    this.cloudAssetToken = _settings.cloudAssetToken;
    this.cloudAssetHost = _settings.cloudAssetHost;
    const isPublic = !!_settings.cloudAssetStorePublic;
    this.prefix = isPublic ?
      _settings.cloudAssetPublicPrefix :
      _settings.cloudAssetPrivatePrefix;
    this.signerSecret = null;
    this.expiredAt = null;
    this.extra = null;
  }

  async refreshSignerToken() {
    const appName = this.appName;
    const duration = parseInt(this.assetStoreURLExpireDuration);
    const expire = Math.floor(Date.now() / 1000) + duration;
    const token = this.cloudAssetToken;
    const host = this.cloudAssetHost;
    const url = `${host}/token/${appName}`;

    const response = await this.request.get(url)
      .accept('application/json')
      .set('Authorization', `Bearer ${token}`)
      .query({expired_at: expire.toString()});

    const body = response.body;
    this.signerSecret = body.value;
    this.expiredAt = new Date(body.expired_at);
    this.extra = body.extra;
  }

  needRefreshSignerToken() {
    if (this.signerSecret === null) {
      return true;
    }
    if (this.expiredAt < new Date()) {
      return true;
    }
    return false;
  }

  async sign(name) {
    if (this.needRefreshSignerToken()) {
      await this.refreshSignerToken();
      return this.sign(name);
    }
    const appName = this.appName;
    const duration = parseInt(this.assetStoreURLExpireDuration);
    const expired = Math.floor(Date.now() / 1000) + duration;

    const hash = crypto.createHmac('sha256', this.signerSecret)
      .update(appName)
      .update(name)
      .update(expired.toString())
      .update(this.extra)
      .digest('base64');
    const signatureAndExtra =
      encodeURIComponent(`${hash}.${this.extra}`);

    return `${this.prefix}/${appName}/${name}` +
      `?expired_at=${expired}&signature=${signatureAndExtra}`;
  }
}

export class S3Signer extends Signer {
  constructor(_settings) {
    super();

    this.assetStoreS3AccessKey = _settings.assetStoreS3AccessKey;
    this.assetStoreS3SecretKey = _settings.assetStoreS3SecretKey;
    this.assetStoreS3URLPrefix = _settings.assetStoreS3URLPrefix;
    this.assetStoreS3Region = _settings.assetStoreS3Region;
    this.assetStoreS3Bucket = _settings.assetStoreS3Bucket;
    this.assetStoreURLExpireDuration = _settings.assetStoreURLExpireDuration;

    const key = this.assetStoreS3AccessKey;
    const secret = this.assetStoreS3SecretKey;
    const region = this.assetStoreS3Region;
    const host = `s3-${region}.amazonaws.com`;
    const bucket = this.assetStoreS3Bucket;

    this.signer = S3URLSigner.urlSigner(key, secret, {
      host: host
    });
    this.bucket = bucket;
  }

  async sign(name) {
    // assume name is not percent encoded
    // because it should be _asset.id in database
    const encodedName = encodeURIComponent(name);
    const duration = this.assetStoreURLExpireDuration;
    let url = this.signer.getUrl('GET', encodedName, this.bucket, duration);
    let prefix = this.assetStoreS3URLPrefix;
    if (!prefix) {
      return url;
    } else if (prefix.length > 1 && prefix.slice(-1) !== '/') {
      // URL.resolve behave differently if trailing slash is not present
      // always append trailing slash such that it works in both cases.
      prefix += '/';
    }

    const plainS3URLObject = URL.parse(url);
    const urlWithoutQuery = URL.resolve(
      prefix,
      encodedName
    );
    const urlObject = URL.parse(urlWithoutQuery);
    urlObject.search = plainS3URLObject.search;
    url = URL.format(urlObject);
    return url;
  }
}

let sharedSigner = null;
/**
 * Return a shared signer for the current configuration.
 *
 * @return {Signer}
 */
export function getSigner() {
  if (sharedSigner === null) {
    switch (settings.assetStore) {
    case 'fs':
      sharedSigner = new FSSigner(settings);
      break;
    case 's3':
      sharedSigner = new S3Signer(settings);
      break;
    case 'cloud':
      sharedSigner = new CloudSigner(settings);
      break;
    default:
      throw new Error(`Unknown asset store type: ${settings.assetStore}`);
    }
  }
  return sharedSigner;
}