mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +09:00
The controller logs the url and query of every HTTP request. Plugins doing OAuth get the authorization code as a query parameter on their callback route, so it ended up in the log. redactUrl now accepts a path, code joins the redacted parameter names, and redactQuery covers the parsed query in the log metadata. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
632 lines
18 KiB
TypeScript
632 lines
18 KiB
TypeScript
/**
|
|
* Collection of small utilites that are useful in multiple places.
|
|
* @module lib/helpers
|
|
*/
|
|
|
|
import type { Logger } from "./logging.js";
|
|
import setBlocking from "set-blocking";
|
|
|
|
/**
|
|
* Return a string describing the type of the value passed
|
|
*
|
|
* Works the same as typeof, excpet that null and array types get their
|
|
* own string.
|
|
*
|
|
* @param value - value to return the type of.
|
|
* @returns basic type of the value passed.
|
|
*/
|
|
export function basicType(value: unknown) {
|
|
if (value === null) { return "null"; }
|
|
if (value instanceof Array) { return "array"; }
|
|
return typeof value;
|
|
}
|
|
|
|
|
|
/**
|
|
* Asynchronously wait for the given duration
|
|
*
|
|
* @param durationMs - Time to wait for in milliseconds.
|
|
*/
|
|
export async function wait(durationMs: number) {
|
|
await new Promise(resolve => { setTimeout(resolve, durationMs); });
|
|
}
|
|
|
|
|
|
/**
|
|
* Resolve a promise with a timeout.
|
|
*
|
|
* @param {Promise} promise - Promise to wait for.
|
|
* @param {number} limitMs - Maximum time im milliseconds to wait for.
|
|
* @param {*=} timeoutResult - Value to return if the operation timed out.
|
|
*/
|
|
export async function timeout<T>(promise: Promise<T>, limitMs: number, timeoutResult: T) {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise<T>(resolve => {
|
|
timer = setTimeout(() => resolve(timeoutResult), limitMs);
|
|
}),
|
|
]);
|
|
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Catch process signals, force exit on second signal
|
|
*/
|
|
export function createShutdownGuard(logger: Logger, signalName: string, callback: () => Promise<unknown>): () => void {
|
|
let called = false;
|
|
|
|
return () => {
|
|
if (called) {
|
|
setBlocking(true);
|
|
logger.fatal(`Caught second ${signalName}, terminating immediately`);
|
|
process.exit(1);
|
|
}
|
|
|
|
called = true;
|
|
logger.info(`Caught ${signalName}, shutting down`);
|
|
callback().catch(err => {
|
|
setBlocking(true);
|
|
logger.fatal("Error during shutdown, terminating immediately");
|
|
logger.fatal(err.stack);
|
|
process.exit(1);
|
|
});
|
|
};
|
|
}
|
|
|
|
|
|
/**
|
|
* Helper to serialise and merge waiting calls to an async callback.
|
|
*
|
|
* Provides a convenient interface to serialise the calls made to an async
|
|
* callback such that no overlapping invocations of the callback is made.
|
|
* If the callback is already running when another invocation is requested
|
|
* the request will be queued until the callback returns and then an the
|
|
* callback called again.
|
|
*
|
|
* If multiple calls are made while the callback is running then these will
|
|
* be merged into one call of the callback.
|
|
*/
|
|
export class AsyncSerialMergingCallback {
|
|
private _currentlyRunning = false;
|
|
private _currentlyWaiting: (() => void)[] = [];
|
|
|
|
/**
|
|
* @param callback - Async function to serialise access to
|
|
*/
|
|
constructor(
|
|
public callback: () => Promise<void>,
|
|
) { }
|
|
|
|
/**
|
|
* Invoke the assosiated callback.
|
|
*
|
|
* If the callback is currently running then this will wait until the
|
|
* existing invocation finishes and then invoke it again. If called
|
|
* multiple times while an invocation is running the calls will be
|
|
* merged into one call.
|
|
*/
|
|
async invoke() {
|
|
if (this._currentlyRunning) {
|
|
const waitForCompletion = () => new Promise<void>(resolve => {
|
|
this._currentlyWaiting.push(resolve);
|
|
});
|
|
await waitForCompletion();
|
|
if (this._currentlyRunning) {
|
|
await waitForCompletion();
|
|
return;
|
|
}
|
|
}
|
|
|
|
this._currentlyRunning = true;
|
|
try {
|
|
await this.callback();
|
|
} finally {
|
|
this._currentlyRunning = false;
|
|
for (const waiter of this._currentlyWaiting) {
|
|
waiter();
|
|
}
|
|
this._currentlyWaiting.length = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to serialise calls to an async callback.
|
|
*
|
|
* Provides a convenient interface to serialise the calls made to an async
|
|
* callback such that no overlapping invocations of the callback is made,
|
|
* and successive calls are served on a first in first out basis. If the
|
|
* callback is already running when another invocation is requested the
|
|
* request will be queued until the callback returns and then the callback
|
|
* called again.
|
|
*/
|
|
export class AsyncSerialCallback<
|
|
Callback extends (...args: Parameters<Callback>) => Promise<Awaited<ReturnType<Callback>>>,
|
|
> {
|
|
private _currentlyRunning = false;
|
|
private _currentlyWaiting: (() => void)[] = [];
|
|
|
|
/**
|
|
* @param callback - Async function to serialise access to
|
|
*/
|
|
constructor(
|
|
public callback: Callback,
|
|
) { }
|
|
|
|
/**
|
|
* Invoke the assosiated callback.
|
|
*
|
|
* If the callback is currently running then this will wait until the
|
|
* existing invocation finishes and then invoke it again. If called
|
|
* multiple times while an invocation is running the calls will be
|
|
* merged into one call.
|
|
*/
|
|
async invoke(...args: Parameters<Callback>) {
|
|
if (this._currentlyRunning) {
|
|
await new Promise<void>(resolve => {
|
|
this._currentlyWaiting.push(resolve);
|
|
});
|
|
}
|
|
|
|
this._currentlyRunning = true;
|
|
try {
|
|
return await this.callback(...args);
|
|
} finally {
|
|
this._currentlyRunning = false;
|
|
this._currentlyWaiting.shift()?.();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Read stream to the end and return its content
|
|
*
|
|
* Reads the stream given asynchronously until the end is reached and
|
|
* returns all the data which was read from the stream.
|
|
*
|
|
* @param stream - byte stream to read to the end.
|
|
* @returns content of the stream.
|
|
*/
|
|
export async function readStream(stream: NodeJS.ReadableStream & { isTTY?: boolean }) {
|
|
let chunks: Buffer[] = [];
|
|
for await (let chunk of stream) {
|
|
// Support using ^Z to end input on Windows
|
|
if (process.platform === "win32" && stream.isTTY && chunk.toString() === "\x1a\r\n") {
|
|
break;
|
|
}
|
|
chunks.push(chunk as Buffer);
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
/**
|
|
* Split the given string on the first instance of separator
|
|
*
|
|
* Splits `string` on the first instance of `separator` and returns an
|
|
* array consisting of the string up to the separator and the string
|
|
* after the separator. Returns an array with the string and an empty
|
|
* string if the separator is not present.
|
|
*
|
|
* @param separator - Separator to split string by.
|
|
* @param string - String to split
|
|
* @returns string split on separator.
|
|
*/
|
|
export function splitOn(separator: string, string: string) {
|
|
let index = string.indexOf(separator);
|
|
if (index === -1) {
|
|
return [string, ""];
|
|
}
|
|
return [string.slice(0, index), string.slice(index + separator.length)];
|
|
}
|
|
|
|
/**
|
|
* Escapes text for inclusion in a RegExp
|
|
*
|
|
* Adds \ character in front of special meta characters in the passsed in
|
|
* text so that it can be embedded into a RegExp and only match the text.
|
|
*
|
|
* See https://stackoverflow.com/a/9310752
|
|
*
|
|
* @param text - Text to escape RegExp meta chars in.
|
|
* @returns escaped text.
|
|
*/
|
|
export function escapeRegExp(text: string) {
|
|
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
|
}
|
|
|
|
/**
|
|
* Format byte count for human readable display
|
|
*
|
|
* Shortens a large number of bytes using the kB/MB/GB/TB prefixes.
|
|
* @param bytes - Count of bytes to format.
|
|
* @param prefixes - Whethere to use SI powers (1000) or the binary powers (1024).
|
|
* @returns formatted text.
|
|
*/
|
|
export function formatBytes(bytes: number, prefixes: "si" | "binary" = "si") {
|
|
if (bytes === 0) {
|
|
return "0\u{A0}Bytes"; // No-break space
|
|
}
|
|
|
|
let base, units;
|
|
if (prefixes === "si") {
|
|
base = 1000;
|
|
units = ["\u{A0}Bytes", "\u{A0}kB", "\u{A0}MB", "\u{A0}GB", "\u{A0}TB"];
|
|
} else {
|
|
base = 1024;
|
|
units = ["\u{A0}Bytes", "\u{A0}kiB", "\u{A0}MiB", "\u{A0}GiB", "\u{A0}TiB"];
|
|
}
|
|
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(base)), units.length);
|
|
const significant = bytes / base ** exponent;
|
|
const fractionDigits = Number(significant < 99.95) + Number(significant < 9.995);
|
|
return significant.toFixed(fractionDigits) + units[exponent];
|
|
}
|
|
|
|
/**
|
|
* Query parameters whose values are credentials and must never be logged.
|
|
*
|
|
* Matched case insensitively against the full parameter name.
|
|
*/
|
|
const sensitiveSearchParams = new Set([
|
|
"access_token",
|
|
"api_key",
|
|
"apikey",
|
|
// OAuth2 authorization code, plugins doing OAuth receive it on their callback route
|
|
"code",
|
|
"password",
|
|
"secret",
|
|
"token",
|
|
]);
|
|
|
|
/**
|
|
* Replace credentials in a URL with a placeholder
|
|
*
|
|
* Returns the URL as a string with the values of any query parameter
|
|
* carrying a credential replaced by `REDACTED`, so that the URL can
|
|
* safely be included in log output and error messages. The parameter
|
|
* names are kept intact to preserve the diagnostic value of the URL.
|
|
*
|
|
* Accepts a path with a query string, like the url of an incoming HTTP
|
|
* request, and returns a path in that case. Returns the input unchanged
|
|
* if it is a string which does not parse as a URL, as there are then no
|
|
* query parameters to redact.
|
|
*
|
|
* @param url - URL or path to redact credentials from.
|
|
* @returns url with credentials replaced by `REDACTED`.
|
|
*/
|
|
export function redactUrl(url: URL | string) {
|
|
const isPath = typeof url === "string" && url.startsWith("/") && !url.startsWith("//");
|
|
let parsed;
|
|
try {
|
|
parsed = isPath ? new URL(url, "http://localhost") : new URL(url);
|
|
} catch {
|
|
return String(url);
|
|
}
|
|
if (![...parsed.searchParams.keys()].some(name => sensitiveSearchParams.has(name.toLowerCase()))) {
|
|
return String(url);
|
|
}
|
|
|
|
const searchParams = new URLSearchParams();
|
|
for (const [name, value] of parsed.searchParams) {
|
|
searchParams.append(name, sensitiveSearchParams.has(name.toLowerCase()) ? "REDACTED" : value);
|
|
}
|
|
|
|
parsed.search = searchParams.toString();
|
|
return isPath ? parsed.pathname + parsed.search + parsed.hash : parsed.toString();
|
|
}
|
|
|
|
/**
|
|
* Copy of parsed query parameters with credentials replaced by `REDACTED`
|
|
*
|
|
* Uses the same parameter names as {@link redactUrl}.
|
|
*
|
|
* @param query - query parameters, for example `req.query` from express.
|
|
* @returns query safe to write to the log.
|
|
*/
|
|
export function redactQuery(query: Record<string, unknown>) {
|
|
const result = { ...query };
|
|
for (const name of Object.keys(result)) {
|
|
if (sensitiveSearchParams.has(name.toLowerCase())) {
|
|
result[name] = "REDACTED";
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function skipWhitespace(pos: number, input: string) {
|
|
// whitespace = 1*" "
|
|
while (pos < input.length && input.charAt(pos) === " ") {
|
|
pos += 1;
|
|
}
|
|
return pos;
|
|
}
|
|
|
|
function parseSearchIdentifier(pos: number, input: string): [number, string] {
|
|
// character = ? all characters excluding space : " ?
|
|
// identifier = 1*character
|
|
let startPos = pos;
|
|
while (pos < input.length && ![" ", '"', ":"].includes(input.charAt(pos))) {
|
|
pos += 1;
|
|
}
|
|
return [pos, input.slice(startPos, pos)];
|
|
}
|
|
|
|
function parseSearchWord(pos: number, input: string): [number, ParsedWord] {
|
|
// non quote character = ? all characters excluding " ?
|
|
// word op = "-"
|
|
// word = [ word op ], ( identifier | '"', *non quote character, '"' )
|
|
let exclude = false;
|
|
let value: string;
|
|
if (input.charAt(pos) === "-") {
|
|
exclude = true;
|
|
pos += 1;
|
|
}
|
|
if (input.charAt(pos) === '"') {
|
|
pos += 1;
|
|
let startPos = pos;
|
|
let endPos = pos;
|
|
while (pos < input.length && input.charAt(pos) !== '"') {
|
|
pos += 1;
|
|
endPos += 1;
|
|
}
|
|
if (input.charAt(pos) === '"') {
|
|
pos += 1;
|
|
}
|
|
value = input.slice(startPos, endPos);
|
|
} else {
|
|
([pos, value] = parseSearchIdentifier(pos, input));
|
|
}
|
|
let word: ParsedWord = {
|
|
type: "word",
|
|
value,
|
|
};
|
|
if (exclude) {
|
|
word.exclude = true;
|
|
}
|
|
return [pos, word];
|
|
}
|
|
|
|
function parseSearchTerm(
|
|
pos: number,
|
|
input: string,
|
|
attributes: Record<string, string>,
|
|
issues: Array<string>
|
|
): [number, ParsedTerm | undefined] {
|
|
// attribute = identifier, ':', word
|
|
// term = word | attribute
|
|
let term: ParsedTerm | undefined;
|
|
if (["-", '"'].includes(input.charAt(pos))) {
|
|
([pos, term] = parseSearchWord(pos, input));
|
|
} else {
|
|
let identifier: string;
|
|
([pos, identifier] = parseSearchIdentifier(pos, input));
|
|
if (input.charAt(pos) === ":") {
|
|
pos += 1;
|
|
identifier = identifier.toLowerCase();
|
|
if (!Object.prototype.hasOwnProperty.call(attributes, identifier)) {
|
|
issues.push(`Unregonized attribute "${identifier}", use quotes to escape colons`);
|
|
} else if (attributes[identifier] === "word") {
|
|
let value: ParsedWord;
|
|
([pos, value] = parseSearchWord(pos, input));
|
|
term = { type: "attribute", name: identifier, value };
|
|
} else {
|
|
throw new Error(`Bad attribute format ${attributes[identifier]} for ${identifier}`);
|
|
}
|
|
} else {
|
|
term = { type: "word", value: identifier };
|
|
}
|
|
}
|
|
return [pos, term];
|
|
}
|
|
|
|
/**
|
|
* Match a parsed word term with the given texts.
|
|
*
|
|
* Returns true if the passed word term in the search matches at least one
|
|
* of the passed text snippets. If the word mode is exclude then returns
|
|
* true if none of the passed text snippets match.
|
|
*
|
|
* @param word - Word to match.
|
|
* @param texts - Text to match word in.
|
|
* @returns true if the word matches.
|
|
*/
|
|
export function wordMatches(word: ParsedTerm, ...texts: string[]) {
|
|
if (word.type !== "word") {
|
|
throw Error("wordMatches: parameter is not a word");
|
|
}
|
|
let matches = false;
|
|
for (let text of texts) {
|
|
if (text.includes(word.value as string)) {
|
|
matches = true;
|
|
break;
|
|
}
|
|
}
|
|
if (word.exclude) {
|
|
matches = !matches;
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
export interface ParsedAttribute {
|
|
/** Type of term, either attribute or word. */
|
|
type: "attribute"
|
|
/** Name of attribute. */
|
|
name: string;
|
|
/** Parsed value of this attribute. */
|
|
value: ParsedWord;
|
|
}
|
|
|
|
export interface ParsedWord {
|
|
/** Type of term, either attribute or word. */
|
|
type: "word";
|
|
/** Exclude results with this word. */
|
|
exclude?: boolean;
|
|
/** Parsed text of this word. */
|
|
value: string;
|
|
}
|
|
|
|
export type ParsedTerm = ParsedAttribute | ParsedWord;
|
|
|
|
/**
|
|
* Result from {@link parseSearchString}.
|
|
*/
|
|
export interface ParsedSearch {
|
|
/** Parsed result of search terms. */
|
|
terms: Array<ParsedTerm>;
|
|
/** Issues detected while parsing the seach string. */
|
|
issues: Array<string>;
|
|
};
|
|
|
|
/**
|
|
* Parse a search string with optional attributes
|
|
*
|
|
* Parses the given input as a search expression consisting of space
|
|
* delimited words and attributes:word pairs. For aattributes to be
|
|
* recognized they need to be passed as name: "word" in the attributes
|
|
* parameter.
|
|
*
|
|
* Words in the search expression can optionally be prefixed by a - to
|
|
* search for results that does not contain that word. E.g. -author contains
|
|
* all results not matching author.
|
|
*
|
|
* If parsing fails an string describing the problem will be added to the
|
|
* issues array returned and parsing will resume at some arbitrary point.
|
|
* The issues should be shown to the end user so that they can correct their
|
|
* search.
|
|
*
|
|
* @param input - Search expression to parse.
|
|
* @param attributes -
|
|
* Recognized attributes and their format. Currently only word is
|
|
* supported.
|
|
* @returns parsed terms of the search.
|
|
*/
|
|
export function parseSearchString(
|
|
input: string,
|
|
attributes: Record<string, string> = {}
|
|
): ParsedSearch {
|
|
// search = [ term, *( [ whitespace ], term ) ]
|
|
input = input.trim();
|
|
let parsed = {
|
|
terms: [] as ParsedTerm[],
|
|
issues: [] as string[],
|
|
};
|
|
let pos = 0;
|
|
while (pos < input.length) {
|
|
let term: ParsedTerm | undefined;
|
|
([pos, term] = parseSearchTerm(pos, input, attributes, parsed.issues));
|
|
if (term) {
|
|
parsed.terms.push(term);
|
|
}
|
|
pos = skipWhitespace(pos, input);
|
|
}
|
|
// istanbul ignore if (should not be possible)
|
|
if (pos !== input.length) {
|
|
throw new Error(`parse search ended at ${pos} which is not the end of the input (${input.length})`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function isDigit(pos: number, input: string) {
|
|
const code = input.charCodeAt(pos);
|
|
return "0".charCodeAt(0) <= code && code <= "9".charCodeAt(0);
|
|
}
|
|
|
|
function parseNumber(pos: number, input: string): [number, number] {
|
|
// number = 1*('0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9')
|
|
let startPos = pos;
|
|
let endPos = pos;
|
|
while (pos < input.length && isDigit(pos, input)) {
|
|
pos += 1;
|
|
endPos += 1;
|
|
}
|
|
return [pos, Number.parseInt(input.slice(startPos, endPos), 10)];
|
|
}
|
|
|
|
function parseRange(pos: number, input: string, min: number, max: number): [number, Set<number>] {
|
|
// range = number, [ whitespace ], [ '-', [ whitespace ], number ]
|
|
const range = new Set<number>();
|
|
if (!isDigit(pos, input)) {
|
|
throw new Error(`Expected digit but got '${input[pos]}' at pos ${pos} while parsing "${input}"`);
|
|
}
|
|
let start: number;
|
|
([pos, start] = parseNumber(pos, input));
|
|
pos = skipWhitespace(pos, input);
|
|
if (pos < input.length && input[pos] === "-") {
|
|
pos += 1; // Skip dash
|
|
pos = skipWhitespace(pos, input);
|
|
if (pos === input.length) {
|
|
throw new Error(`Expected digit but got end of input while parsing "${input}"`);
|
|
}
|
|
if (!isDigit(pos, input)) {
|
|
throw new Error(`Expected digit but got '${input[pos]}' at pos ${pos} while parsing "${input}"`);
|
|
}
|
|
let end: number;
|
|
([pos, end] = parseNumber(pos, input));
|
|
|
|
if (start > end) {
|
|
([start, end] = [end, start]);
|
|
}
|
|
if (start < min) {
|
|
throw new Error(`start of range ${start}-${end} is below the minimum value ${min}`);
|
|
}
|
|
if (end > max) {
|
|
throw new Error(`end of range ${start}-${end} is above the maximum value ${max}`);
|
|
}
|
|
for (let i = start; i <= end; i++) {
|
|
range.add(i);
|
|
}
|
|
} else { // Single value range
|
|
if (start < min) {
|
|
throw new Error(`value ${start} is below the minimum value ${min}`);
|
|
}
|
|
if (start > max) {
|
|
throw new Error(`value ${start} is above the maximum value ${max}`);
|
|
}
|
|
range.add(start);
|
|
}
|
|
return [pos, range];
|
|
}
|
|
|
|
/**
|
|
* Parse a comma separated range expression
|
|
*
|
|
* Parses the given input as a series of comma sepparated number ranges
|
|
* where each range can consist of either a whole number or a whole number
|
|
* of where the range starts, a dash and then a whole number of where the
|
|
* range ends inclusively.
|
|
*
|
|
* @param input - Range expression to parse
|
|
* @param min - Minimum accepted input value.
|
|
* @param max - Maximum accepted input value.
|
|
* @returns Set of all numbers in the parsed range.
|
|
*/
|
|
export function parseRanges(
|
|
input: string,
|
|
min: number,
|
|
max: number,
|
|
) {
|
|
// ranges = [ range, *( [ whitespace ], [ ',', [ whitespace ] ], range ) ]
|
|
input = input.trim();
|
|
const parsed = new Set<number>();
|
|
let pos = 0;
|
|
while (pos < input.length) {
|
|
let range: Set<number>;
|
|
([pos, range] = parseRange(pos, input, min, max));
|
|
for (const i of range) {
|
|
parsed.add(i);
|
|
}
|
|
pos = skipWhitespace(pos, input);
|
|
if (pos < input.length && input[pos] === ",") {
|
|
pos += 1;
|
|
pos = skipWhitespace(pos, input);
|
|
}
|
|
}
|
|
return parsed;
|
|
}
|