ct-martin/rimg

View on GitHub
server/utils.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
import sharp, { ResizeOptions, AvailableFormatInfo } from 'sharp';
import { URL } from 'url';
import { Response as FetchResponse, RequestInit } from 'node-fetch';
import {
  ALLOWED_HOSTNAMES as HOSTNAMES,
  FORWARD_HEADERS,
} from './constants';

/**
 * Converts an Express Request into its URL parts
 * @param request Express Request
 */
export function getRequestUrl(request: ExpressRequest): URL {
  return new URL(request.originalUrl, `${request.protocol}://${request.hostname}`);
}

/**
 * Looks for a ?url parameter and returns a URL, or null if ?url is missing or not a valid url
 * @param url URL to parse
 * @returns URL, or null if error
 */
export function getImgUrl(url: URL): URL | null {
  try {
    const urlParam = url.searchParams.get('url') ?? '';
    return new URL(urlParam);
  } catch (e) {
    return null;
  }
}

/**
 * Checks if an allowlist is set and if so whether the hostname passes
 * @param hostname Hostname of image being fetched
 * @returns Boolean of whether allowed
 */
export function checkAllowedHostname(hostname: string): boolean {
  // If no allowlist, let anything pass
  if (!HOSTNAMES) {
    return true;
  }

  // If allowlist, check if included
  return HOSTNAMES.includes(hostname);
}

/**
 * Takes searchparams and parses for valid size dimension
 * @param param searchParam
 * @returns Number, or undefined if error
 */
export function getDimension(param: string|null): number|undefined {
  // if param is undefined, fail
  if (typeof param !== 'string') {
    return undefined;
  }

  const num = Number.parseInt(param, 10);

  // Fail if not number
  if (Number.isNaN(num)) {
    return undefined;
  }

  // Fail if not >0
  if (num <= 0) {
    return undefined;
  }

  return num;
}

/**
 * Gets resizing options from URL
 * @param url URL to search
 */
export function getResizeOptions(url: URL): ResizeOptions {
  // Base resize settings
  const options: ResizeOptions = {
    withoutEnlargement: true, // do not resize to larger than original
  };

  // Copy parameters from query string to options.
  const widthParam = getDimension(url.searchParams.get('maxwidth') ?? url.searchParams.get('width'));
  if (widthParam) {
    options.width = widthParam;
  }
  const heightParam = getDimension(url.searchParams.get('maxheight') ?? url.searchParams.get('height'));
  if (heightParam) {
    options.height = heightParam;
  }

  return options;
}

/**
 * Checks fetch() for valid MIME type
 * @param imgFetch Response from fetch()
 * @returns boolean
 */
export function checkMime(imgFetch: FetchResponse): boolean {
  // Get MIME type
  const mimeRaw = imgFetch.headers.get('content-type');
  if (mimeRaw === null) {
    return false;
  }
  const mime = mimeRaw.split(';')[0];

  // Reject if not image
  if (mime.split('/').length !== 2) {
    return false;
  }
  const [maintype, subtype] = mime.split('/');
  if (maintype !== 'image') {
    return false;
  }

  // Reject if format is not supported by sharp or by sharp via buffer
  let supported = false;
  Object.values(sharp.format).forEach((i: AvailableFormatInfo) => {
    if (i.id === subtype && i.input.buffer) {
      supported = true;
    }
  });
  if (!supported) {
    return false;
  }

  return true;
}

/**
 * Passes referer header to respect anti-hotlinking
 * @param request Express Request
 * @returns fetch() options
 */
export function getFetchOptions(request: ExpressRequest): RequestInit {
  const fetchOptions: RequestInit = {};
  if (request.headers.referer) {
    fetchOptions.headers = {
      referer: request.headers.referer,
    };
  }
  return fetchOptions;
}

/**
 * Copies headers in `fetchHeaders` from fetch() response to Express response
 * Used to copy forward caching & security headers
 * @param imgFetch Fetch response
 * @param res Express response
 */
export function passForwardHeaders(imgFetch: FetchResponse, res: ExpressResponse) {
  imgFetch.headers.forEach((value, name) => {
    if (FORWARD_HEADERS.includes(name)) {
      res.set(name, value);
    }
  });
}