408 строки
13 KiB
TypeScript
408 строки
13 KiB
TypeScript
//
|
|
// Copyright (c) Microsoft.
|
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
//
|
|
|
|
import async = require('async');
|
|
import express = require('express');
|
|
import fs = require('fs');
|
|
import path = require('path');
|
|
|
|
import { URL } from 'url';
|
|
|
|
import { IReposError } from './transitional';
|
|
import { DateTime } from 'luxon';
|
|
|
|
const zlib = require('zlib');
|
|
|
|
const compressionOptions = {
|
|
type: 'gzip',
|
|
params: {
|
|
level: zlib.Z_BEST_SPEED,
|
|
},
|
|
};
|
|
|
|
const hardcodedCorporateTimezone = 'America/Los_Angeles';
|
|
|
|
export function getOffsetMonthRange(offsetMonths?: number) {
|
|
offsetMonths = offsetMonths || 0;
|
|
const now = new Date();
|
|
const start = DateTime.fromObject({ year: now.getFullYear(), month: 1 + offsetMonths + now.getMonth(), zone: hardcodedCorporateTimezone });
|
|
const end = start.plus({ months: 1 });
|
|
return { start: start.toJSDate(), end: end.toJSDate() };
|
|
}
|
|
|
|
export function asNumber(value: any) {
|
|
if (typeof(value) === 'number') {
|
|
return value;
|
|
} else if (typeof(value) === 'string') {
|
|
return parseInt(value, 10);
|
|
}
|
|
const typeName = typeof(value);
|
|
throw new Error(`Unsupported type ${typeName} for value ${value} (asNumber)`);
|
|
}
|
|
|
|
export function daysInMilliseconds(days: number): number {
|
|
return 1000 * 60 * 60 * 24 * days;
|
|
}
|
|
|
|
export function stringOrNumberAsString(value: any) {
|
|
if (typeof(value) === 'number') {
|
|
return (value as number).toString();
|
|
} else if (typeof(value) === 'string') {
|
|
return value;
|
|
}
|
|
const typeName = typeof(value);
|
|
throw new Error(`Unsupported type ${typeName} for value ${value} (stringOrNumberAsString)`);
|
|
}
|
|
|
|
export function stringOrNumberArrayAsStringArray(values: any[]) {
|
|
return values.map(val => stringOrNumberAsString(val));
|
|
}
|
|
|
|
export function requireJson(nameFromRoot: string): any {
|
|
// In some situations TypeScript can load from JSON, but for the transition this is better to reach outside the out directory
|
|
let file = path.resolve(__dirname, nameFromRoot);
|
|
// If within the output directory
|
|
if (fs.existsSync(file)) {
|
|
const content = fs.readFileSync(file, 'utf8');
|
|
return JSON.parse(content);
|
|
}
|
|
file = path.resolve(__dirname, '..', nameFromRoot);
|
|
if (!fs.existsSync(file)) {
|
|
throw new Error(`Cannot find JSON file ${file} to read as a module`);
|
|
}
|
|
const content = fs.readFileSync(file, 'utf8');
|
|
console.warn(`JSON as module (${file}) from project root (NOT TypeScript 'dist' folder)`);
|
|
return JSON.parse(content);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Returns an integer, random, between low and high (exclusive) - [low, high)
|
|
// ----------------------------------------------------------------------------
|
|
export function randomInteger(low, high) {
|
|
return Math.floor(Math.random() * (high - low) + low);
|
|
};
|
|
|
|
export function safeLocalRedirectUrl(path: string) {
|
|
if (!path) {
|
|
return;
|
|
}
|
|
const url = new URL(path, 'http://localhost');
|
|
if (url.host !== 'localhost') {
|
|
return;
|
|
}
|
|
return url.search ? `${url.pathname}${url.search}` : url.pathname;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Session utility: Store the referral URL, if present, and redirect to a new
|
|
// location.
|
|
// ----------------------------------------------------------------------------
|
|
interface IStoreReferrerEventDetails {
|
|
method: string;
|
|
reason: string;
|
|
referer?: string;
|
|
redirect?: string;
|
|
}
|
|
|
|
export function storeReferrer(req, res, redirect, optionalReason) {
|
|
const eventDetails : IStoreReferrerEventDetails = {
|
|
method: 'storeReferrer',
|
|
reason: optionalReason || 'unknown reason',
|
|
};
|
|
if (req.session && req.headers && req.headers.referer && req.session.referer !== undefined && !req.headers.referer.includes('/signout') && !req.session.referer) {
|
|
req.session.referer = req.headers.referer;
|
|
eventDetails.referer = req.headers.referer;
|
|
}
|
|
if (redirect) {
|
|
eventDetails.redirect = redirect;
|
|
if (req.insights) {
|
|
req.insights.trackEvent({ name: 'RedirectWithReferrer', properties: eventDetails });
|
|
}
|
|
res.redirect(redirect);
|
|
}
|
|
};
|
|
|
|
export function sortByCaseInsensitive(a: string, b: string) {
|
|
let nameA = a.toLowerCase();
|
|
let nameB = b.toLowerCase();
|
|
if (nameA < nameB) {
|
|
return -1;
|
|
}
|
|
if (nameA > nameB) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Session utility: store the original URL
|
|
// ----------------------------------------------------------------------------
|
|
export function storeOriginalUrlAsReferrer(req: express.Request, res: express.Response, redirect: string, optionalReason?: string) {
|
|
storeOriginalUrlAsVariable(req, res, 'referer', redirect, optionalReason);
|
|
};
|
|
|
|
export function redirectToReferrer(req, res, url, optionalReason) {
|
|
url = url || '/';
|
|
const alternateUrl = popSessionVariable(req, res, 'referer');
|
|
const eventDetails = {
|
|
method: 'redirectToReferrer',
|
|
reason: optionalReason || 'unknown reason',
|
|
};
|
|
if (req.insights) {
|
|
req.insights.trackEvent({ name: 'RedirectToReferrer', properties: eventDetails });
|
|
}
|
|
res.redirect(alternateUrl || url);
|
|
};
|
|
|
|
export function storeOriginalUrlAsVariable(req, res, variable, redirect, optionalReason) {
|
|
const eventDetails = {
|
|
method: 'storeOriginalUrlAsVariable',
|
|
variable: variable,
|
|
redirect: redirect,
|
|
reason: optionalReason || 'unknown reason',
|
|
};
|
|
if (req.session && req.originalUrl) {
|
|
req.session[variable] = req.originalUrl;
|
|
eventDetails['ou'] = req.originalUrl;
|
|
}
|
|
if (redirect) {
|
|
if (req.insights) {
|
|
req.insights.trackEvent({ name: 'RedirectFromOriginalUrl', properties: eventDetails });
|
|
}
|
|
res.redirect(redirect);
|
|
}
|
|
}
|
|
|
|
export function popSessionVariable(req, res, variableName) {
|
|
if (req.session && req.session[variableName] !== undefined) {
|
|
const url = req.session[variableName];
|
|
delete req.session[variableName];
|
|
return url;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Provide our own error wrapper and message for an underlying thrown error.
|
|
// Useful for the user-presentable version.
|
|
// ----------------------------------------------------------------------------
|
|
const errorPropertiesToClone = [
|
|
'stack',
|
|
'status',
|
|
];
|
|
|
|
export function wrapError(error, message, userIntendedMessage?: boolean): IReposError {
|
|
const err: IReposError = new Error(message);
|
|
err.innerError = error;
|
|
if (error) {
|
|
for (let i = 0; i < errorPropertiesToClone.length; i++) {
|
|
const key = errorPropertiesToClone[i];
|
|
const value = error[key];
|
|
if (value && typeof value === 'number') {
|
|
// Store as a string
|
|
err[key] = value.toString();
|
|
} else if (value) {
|
|
err[key] = value;
|
|
}
|
|
}
|
|
}
|
|
if (userIntendedMessage === true) {
|
|
err.skipLog = true;
|
|
}
|
|
return err;
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// A destructive removal function for an object. Removes a single key.
|
|
// ----------------------------------------------------------------------------
|
|
export function stealValue(obj, key) {
|
|
if (obj[key] !== undefined) {
|
|
var val = obj[key];
|
|
delete obj[key];
|
|
return val;
|
|
}
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Given a list of string values, check a string, using a case-insensitive
|
|
// comparison.
|
|
// ----------------------------------------------------------------------------
|
|
export function inListInsensitive(list, value) {
|
|
value = value.toLowerCase();
|
|
for (var i = 0; i < list.length; i++) {
|
|
if (list[i].toLowerCase() === value) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Given a list of lowercase values, check whether a value is present.
|
|
// ----------------------------------------------------------------------------
|
|
export function isInListAnycaseInLowercaseList(list, value) {
|
|
value = value.toLowerCase();
|
|
for (var i = 0; i < list.length; i++) {
|
|
if (list[i] === value) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Given an array of things that have an `id` property, return a hash indexed
|
|
// by that ID.
|
|
// ----------------------------------------------------------------------------
|
|
export function arrayToHashById(inputArray) {
|
|
var hash = {};
|
|
if (inputArray && inputArray.length) {
|
|
for (var i = 0; i < inputArray.length; i++) {
|
|
if (inputArray[i] && inputArray[i].id) {
|
|
hash[inputArray[i].id] = inputArray[i];
|
|
}
|
|
}
|
|
}
|
|
return hash;
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Obfuscate a string value, optionally leaving a few characters visible.
|
|
// ----------------------------------------------------------------------------
|
|
export function obfuscate(value, lastCharactersShowCount) {
|
|
if (value === undefined || value === null || value.length === undefined) {
|
|
return value;
|
|
}
|
|
var length = value.length;
|
|
lastCharactersShowCount = lastCharactersShowCount || 0;
|
|
lastCharactersShowCount = Math.min(Math.round(lastCharactersShowCount), length - 1);
|
|
var obfuscated = '';
|
|
for (var i = 0; i < length - lastCharactersShowCount; i++) {
|
|
obfuscated += '*';
|
|
}
|
|
for (var j = length - lastCharactersShowCount; j < length; j++) {
|
|
obfuscated += value[j];
|
|
}
|
|
return obfuscated;
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// A very basic breadcrumb stack that ties in to an Express request object.
|
|
// ----------------------------------------------------------------------------
|
|
export function addBreadcrumb(req, breadcrumbTitle, optionalBreadcrumbLink) {
|
|
if (req === undefined || req.baseUrl === undefined) {
|
|
throw new Error('addBreadcrumb: did you forget to provide a request object instance?');
|
|
}
|
|
if (!optionalBreadcrumbLink && optionalBreadcrumbLink !== false) {
|
|
optionalBreadcrumbLink = req.baseUrl;
|
|
}
|
|
if (!optionalBreadcrumbLink && optionalBreadcrumbLink !== false) {
|
|
optionalBreadcrumbLink = '/';
|
|
}
|
|
var breadcrumbs = req.breadcrumbs;
|
|
if (breadcrumbs === undefined) {
|
|
breadcrumbs = [];
|
|
}
|
|
breadcrumbs.push({
|
|
title: breadcrumbTitle,
|
|
url: optionalBreadcrumbLink,
|
|
});
|
|
req.breadcrumbs = breadcrumbs;
|
|
};
|
|
|
|
export function stackSafeCallback(callback, err, item, extraItem) {
|
|
// Works around RangeError: Maximum call stack size exceeded.
|
|
async.setImmediate(() => {
|
|
callback(err, item, extraItem);
|
|
});
|
|
};
|
|
|
|
export function createSafeCallbackNoParams(cb) {
|
|
return () => {
|
|
exports.stackSafeCallback(cb);
|
|
};
|
|
};
|
|
|
|
export function sleep(milliseconds: number): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
process.nextTick(resolve);
|
|
}, milliseconds);
|
|
});
|
|
}
|
|
|
|
export function ParseReleaseReviewWorkItemId(uri: string): string {
|
|
const safeUrl = new URL(uri);
|
|
const id = safeUrl.searchParams.get('id');
|
|
if (id) {
|
|
return id;
|
|
}
|
|
const pathname = safeUrl.pathname;
|
|
const editIndex = pathname.indexOf('edit/');
|
|
if (editIndex >= 0) {
|
|
return pathname.substr(editIndex + 5);
|
|
}
|
|
if (safeUrl.host === 'osstool.microsoft.com') {
|
|
return null; // Very legacy
|
|
}
|
|
throw new Error(`Unable to parse work item information from: ${uri}`);
|
|
}
|
|
|
|
export function readFileToText(filename: string): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
return fs.readFile(filename, 'utf8', (error, data) => {
|
|
return error ? reject(error) : resolve(data);
|
|
});
|
|
});
|
|
}
|
|
|
|
export function writeTextToFile(filename: string, stringContent: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
return fs.writeFile(filename, stringContent, 'utf8', error => {
|
|
if (error) {
|
|
console.warn(`Trouble writing ${filename} ${error}`);
|
|
} else {
|
|
console.log(`Wrote ${filename}`);
|
|
}
|
|
return error ? reject(error) : resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
export function quitInAMinute(successful: boolean) {
|
|
console.log(`Quitting process in one minute... exit code=${successful ? 0 : 1}`);
|
|
return setTimeout(() => {
|
|
process.exit(successful ? 0 : 1);
|
|
}, 1000 * 60 /* 1 minute later */);
|
|
}
|
|
|
|
export function gzipString(value: string): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const val = Buffer.from(value);
|
|
zlib.gzip(val, compressionOptions, (gzipError, compressed: Buffer) => {
|
|
return gzipError ? reject(gzipError) : resolve(compressed);
|
|
});
|
|
});
|
|
}
|
|
|
|
export function gunzipBuffer(buffer: Buffer): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
zlib.gunzip(buffer, (unzipError, unzipped) => {
|
|
// Fallback if there is a data error (i.e. it's not compressed)
|
|
if (unzipError && unzipError.errno === zlib.Z_DATA_ERROR) {
|
|
const originalValue = buffer.toString();
|
|
return resolve(originalValue);
|
|
} else if (unzipError) {
|
|
return reject(unzipError);
|
|
}
|
|
try {
|
|
const unzippedValue = unzipped.toString();
|
|
return resolve(unzippedValue);
|
|
} catch (otherError) {
|
|
return reject(otherError);
|
|
}
|
|
});
|
|
});
|
|
}
|