Added typescript to etherpad

* Fixed determining file extension.

* Added ts-node

* Fixed backend tests.

* Fixed frontend test runs.

* Fixed tests.

* Use script approach for starting etherpad.

* Change directory to src.

* Fixed env.

* Change directory

* Fixed build arg.

* Fixed docker build.

* Fixed.

* Fixed cypress file path.

* Fixed.

* Use latest node container.

* Fixed windows workflow.

* Use tsx and optimized docker image.

* Added workflow for type checks.

* Fixed.

* Added tsconfig.

* Converted more files to typescript.

* Removed commented keys.

* Typed caching middleware.

* Added script for checking the types.

* Moved SecretRotator to typescript.

* Fixed npm installation and moved to types folder.

* Use better scripts for watching typescript changes.

* Update windows.yml

* Fixed order of npm installation.

* Converted i18n.

* Added more types.

* Added more types.

* Fixed import.

* Fixed tests.

* Fixed tests.

* Fixed type checking test.

* Fixed stats

* Added express types.

* fixed.
This commit is contained in:
SamTV12345 2024-02-05 21:13:02 +01:00 committed by GitHub
parent c3202284bc
commit ead3c0ea38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 1259 additions and 612 deletions

View file

@ -19,6 +19,9 @@
* limitations under the License.
*/
import {ChildProcess} from "node:child_process";
import {AsyncQueueTask} from "../types/AsyncQueueTask";
const spawn = require('child_process').spawn;
const async = require('async');
const settings = require('./Settings');
@ -27,13 +30,13 @@ const os = require('os');
// on windows we have to spawn a process for each convertion,
// cause the plugin abicommand doesn't exist on this platform
if (os.type().indexOf('Windows') > -1) {
exports.convertFile = async (srcFile, destFile, type) => {
exports.convertFile = async (srcFile: string, destFile: string, type: string) => {
const abiword = spawn(settings.abiword, [`--to=${destFile}`, srcFile]);
let stdoutBuffer = '';
abiword.stdout.on('data', (data) => { stdoutBuffer += data.toString(); });
abiword.stderr.on('data', (data) => { stdoutBuffer += data.toString(); });
await new Promise((resolve, reject) => {
abiword.on('exit', (code) => {
abiword.stdout.on('data', (data: string) => { stdoutBuffer += data.toString(); });
abiword.stderr.on('data', (data: string) => { stdoutBuffer += data.toString(); });
await new Promise<void>((resolve, reject) => {
abiword.on('exit', (code: number) => {
if (code !== 0) return reject(new Error(`Abiword died with exit code ${code}`));
if (stdoutBuffer !== '') {
console.log(stdoutBuffer);
@ -46,13 +49,13 @@ if (os.type().indexOf('Windows') > -1) {
// communicate with it via stdin/stdout
// thats much faster, about factor 10
} else {
let abiword;
let stdoutCallback = null;
let abiword: ChildProcess;
let stdoutCallback: Function|null = null;
const spawnAbiword = () => {
abiword = spawn(settings.abiword, ['--plugin', 'AbiCommand']);
let stdoutBuffer = '';
let firstPrompt = true;
abiword.stderr.on('data', (data) => { stdoutBuffer += data.toString(); });
abiword.stderr!.on('data', (data) => { stdoutBuffer += data.toString(); });
abiword.on('exit', (code) => {
spawnAbiword();
if (stdoutCallback != null) {
@ -60,7 +63,7 @@ if (os.type().indexOf('Windows') > -1) {
stdoutCallback = null;
}
});
abiword.stdout.on('data', (data) => {
abiword.stdout!.on('data', (data) => {
stdoutBuffer += data.toString();
// we're searching for the prompt, cause this means everything we need is in the buffer
if (stdoutBuffer.search('AbiWord:>') !== -1) {
@ -76,15 +79,15 @@ if (os.type().indexOf('Windows') > -1) {
};
spawnAbiword();
const queue = async.queue((task, callback) => {
abiword.stdin.write(`convert ${task.srcFile} ${task.destFile} ${task.type}\n`);
stdoutCallback = (err) => {
const queue = async.queue((task: AsyncQueueTask, callback:Function) => {
abiword.stdin!.write(`convert ${task.srcFile} ${task.destFile} ${task.type}\n`);
stdoutCallback = (err: string) => {
if (err != null) console.error('Abiword File failed to convert', err);
callback(err);
};
}, 1);
exports.convertFile = async (srcFile, destFile, type) => {
exports.convertFile = async (srcFile: string, destFile: string, type: string) => {
await queue.pushAsync({srcFile, destFile, type});
};
}

View file

@ -18,7 +18,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const log4js = require('log4js');
const path = require('path');
const _ = require('underscore');
@ -29,7 +28,7 @@ const absPathLogger = log4js.getLogger('AbsolutePaths');
* findEtherpadRoot() computes its value only on first invocation.
* Subsequent invocations are served from this variable.
*/
let etherpadRoot = null;
let etherpadRoot: string|null = null;
/**
* If stringArray's last elements are exactly equal to lastDesiredElements,
@ -41,7 +40,7 @@ let etherpadRoot = null;
* @return {string[]|boolean} The shortened array, or false if there was no
* overlap.
*/
const popIfEndsWith = (stringArray, lastDesiredElements) => {
const popIfEndsWith = (stringArray: string[], lastDesiredElements: string[]): string[] | false => {
if (stringArray.length <= lastDesiredElements.length) {
absPathLogger.debug(`In order to pop "${lastDesiredElements.join(path.sep)}" ` +
`from "${stringArray.join(path.sep)}", it should contain at least ` +
@ -131,7 +130,7 @@ exports.findEtherpadRoot = () => {
* it is returned unchanged. Otherwise it is interpreted
* relative to exports.root.
*/
exports.makeAbsolute = (somePath) => {
exports.makeAbsolute = (somePath: string) => {
if (path.isAbsolute(somePath)) {
return somePath;
}
@ -150,10 +149,8 @@ exports.makeAbsolute = (somePath) => {
* a subdirectory of the base one
* @return {boolean}
*/
exports.isSubdir = (parent, arbitraryDir) => {
exports.isSubdir = (parent: string, arbitraryDir: string): boolean => {
// modified from: https://stackoverflow.com/questions/37521893/determine-if-a-path-is-subdirectory-of-another-in-node-js#45242825
const relative = path.relative(parent, arbitraryDir);
const isSubdir = !!relative && !relative.startsWith('..') && !path.isAbsolute(relative);
return isSubdir;
return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative);
};

View file

@ -21,13 +21,13 @@ const authorManager = require('../db/AuthorManager');
const hooks = require('../../static/js/pluginfw/hooks');
const padManager = require('../db/PadManager');
exports.getPadRaw = async (padId, readOnlyId) => {
exports.getPadRaw = async (padId:string, readOnlyId:string) => {
const dstPfx = `pad:${readOnlyId || padId}`;
const [pad, customPrefixes] = await Promise.all([
padManager.getPad(padId),
hooks.aCallAll('exportEtherpadAdditionalContent'),
]);
const pluginRecords = await Promise.all(customPrefixes.map(async (customPrefix) => {
const pluginRecords = await Promise.all(customPrefixes.map(async (customPrefix:string) => {
const srcPfx = `${customPrefix}:${padId}`;
const dstPfx = `${customPrefix}:${readOnlyId || padId}`;
assert(!srcPfx.includes('*'));

View file

@ -26,7 +26,7 @@ const { checkValidRev } = require('./checkValidRev');
/*
* This method seems unused in core and no plugins depend on it
*/
exports.getPadPlainText = (pad, revNum) => {
exports.getPadPlainText = (pad: { getInternalRevisionAText: (arg0: any) => any; atext: any; pool: any; }, revNum: undefined) => {
const _analyzeLine = exports._analyzeLine;
const atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(checkValidRev(revNum)) : pad.atext);
const textLines = atext.text.slice(0, -1).split('\n');
@ -47,10 +47,12 @@ exports.getPadPlainText = (pad, revNum) => {
return pieces.join('');
};
type LineModel = {
[id:string]:string|number|LineModel
}
exports._analyzeLine = (text, aline, apool) => {
const line = {};
exports._analyzeLine = (text:string, aline: LineModel, apool: Function) => {
const line: LineModel = {};
// identify list
let lineMarker = 0;
@ -86,4 +88,4 @@ exports._analyzeLine = (text, aline, apool) => {
exports._encodeWhitespace =
(s) => s.replace(/[^\x21-\x7E\s\t\n\r]/gu, (c) => `&#${c.codePointAt(0)};`);
(s:string) => s.replace(/[^\x21-\x7E\s\t\n\r]/gu, (c) => `&#${c.codePointAt(0)};`);

View file

@ -19,13 +19,16 @@
* limitations under the License.
*/
import {AText, PadType} from "../types/PadType";
import {MapType} from "../types/MapType";
const Changeset = require('../../static/js/Changeset');
const attributes = require('../../static/js/attributes');
const padManager = require('../db/PadManager');
const _analyzeLine = require('./ExportHelper')._analyzeLine;
// This is slightly different than the HTML method as it passes the output to getTXTFromAText
const getPadTXT = async (pad, revNum) => {
const getPadTXT = async (pad: PadType, revNum: string) => {
let atext = pad.atext;
if (revNum !== undefined) {
@ -39,13 +42,13 @@ const getPadTXT = async (pad, revNum) => {
// This is different than the functionality provided in ExportHtml as it provides formatting
// functionality that is designed specifically for TXT exports
const getTXTFromAtext = (pad, atext, authorColors) => {
const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => {
const apool = pad.apool();
const textLines = atext.text.slice(0, -1).split('\n');
const attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text);
const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough'];
const anumMap = {};
const anumMap: MapType = {};
const css = '';
props.forEach((propName, i) => {
@ -55,8 +58,8 @@ const getTXTFromAtext = (pad, atext, authorColors) => {
}
});
const getLineTXT = (text, attribs) => {
const propVals = [false, false, false];
const getLineTXT = (text:string, attribs:any) => {
const propVals:(number|boolean)[] = [false, false, false];
const ENTER = 1;
const STAY = 2;
const LEAVE = 0;
@ -71,7 +74,7 @@ const getTXTFromAtext = (pad, atext, authorColors) => {
let idx = 0;
const processNextChars = (numChars) => {
const processNextChars = (numChars: number) => {
if (numChars <= 0) {
return;
}
@ -84,7 +87,7 @@ const getTXTFromAtext = (pad, atext, authorColors) => {
for (const a of attributes.decodeAttribString(o.attribs)) {
if (a in anumMap) {
const i = anumMap[a]; // i = 0 => bold, etc.
const i = anumMap[a] as number; // i = 0 => bold, etc.
if (!propVals[i]) {
propVals[i] = ENTER;
@ -189,7 +192,7 @@ const getTXTFromAtext = (pad, atext, authorColors) => {
// want to deal gracefully with blank lines.
// => keeps track of the parents level of indentation
const listNumbers = {};
const listNumbers:MapType = {};
let prevListLevel;
for (let i = 0; i < textLines.length; i++) {
@ -233,6 +236,7 @@ const getTXTFromAtext = (pad, atext, authorColors) => {
delete listNumbers[prevListLevel];
}
// @ts-ignore
listNumbers[line.listLevel]++;
if (line.listLevel > 1) {
let x = 1;
@ -258,7 +262,7 @@ const getTXTFromAtext = (pad, atext, authorColors) => {
exports.getTXTFromAtext = getTXTFromAtext;
exports.getPadTXTDocument = async (padId, revNum) => {
exports.getPadTXTDocument = async (padId:string, revNum:string) => {
const pad = await padManager.getPad(padId);
return getPadTXT(pad, revNum);
};

View file

@ -5,17 +5,19 @@
* objects lack.
*/
class Stream {
private _iter
private _next: any
/**
* @returns {Stream} A Stream that yields values in the half-open range [start, end).
*/
static range(start, end) {
static range(start: number, end: number) {
return new Stream((function* () { for (let i = start; i < end; ++i) yield i; })());
}
/**
* @param {Iterable<any>} values - Any iterable of values.
*/
constructor(values) {
constructor(values: Iterable<any>) {
this._iter = values[Symbol.iterator]();
this._next = null;
}
@ -52,10 +54,11 @@ class Stream {
* @param {number} size - The number of values to read at a time.
* @returns {Stream} A new Stream that gets its values from this Stream.
*/
batch(size) {
batch(size: number) {
return new Stream((function* () {
const b = [];
try {
// @ts-ignore
for (const v of this) {
Promise.resolve(v).catch(() => {}); // Suppress unhandled rejection errors.
b.push(v);
@ -100,10 +103,11 @@ class Stream {
* @param {number} capacity - The number of values to keep buffered.
* @returns {Stream} A new Stream that gets its values from this Stream.
*/
buffer(capacity) {
buffer(capacity: number) {
return new Stream((function* () {
const b = [];
try {
// @ts-ignore
for (const v of this) {
Promise.resolve(v).catch(() => {}); // Suppress unhandled rejection errors.
// Note: V8 has good Array push+shift optimization.
@ -123,7 +127,8 @@ class Stream {
* @param {(v: any) => any} fn - Value transformation function.
* @returns {Stream} A new Stream that yields this Stream's values, transformed by `fn`.
*/
map(fn) { return new Stream((function* () { for (const v of this) yield fn(v); }).call(this)); }
map(fn:Function) { return new Stream((function* () { // @ts-ignore
for (const v of this) yield fn(v); }).call(this)); }
/**
* Implements the JavaScript iterable protocol.

View file

@ -6,9 +6,14 @@ const headers = {
'User-Agent': 'Etherpad/' + settings.getEpVersion(),
}
type Infos = {
latestVersion: string
}
const updateInterval = 60 * 60 * 1000; // 1 hour
let infos;
let lastLoadingTime = null;
let infos: Infos;
let lastLoadingTime: number | null = null;
const loadEtherpadInformations = () => {
if (lastLoadingTime !== null && Date.now() - lastLoadingTime < updateInterval) {
@ -16,7 +21,7 @@ const loadEtherpadInformations = () => {
}
return axios.get('https://static.etherpad.org/info.json', {headers: headers})
.then(async resp => {
.then(async (resp: any) => {
infos = await resp.data;
if (infos === undefined || infos === null) {
await Promise.reject("Could not retrieve current version")
@ -26,7 +31,7 @@ const loadEtherpadInformations = () => {
lastLoadingTime = Date.now();
return await Promise.resolve(infos);
})
.catch(async err => {
.catch(async (err: Error) => {
return await Promise.reject(err);
});
}
@ -37,20 +42,20 @@ exports.getLatestVersion = () => {
return infos?.latestVersion;
};
exports.needsUpdate = async (cb) => {
exports.needsUpdate = async (cb: Function) => {
await loadEtherpadInformations()
.then((info) => {
.then((info:Infos) => {
if (semver.gt(info.latestVersion, settings.getEpVersion())) {
if (cb) return cb(true);
}
}).catch((err) => {
}).catch((err: Error) => {
console.error(`Can not perform Etherpad update check: ${err}`);
if (cb) return cb(false);
});
};
exports.check = () => {
exports.needsUpdate((needsUpdate) => {
exports.needsUpdate((needsUpdate: boolean) => {
if (needsUpdate) {
console.warn(`Update available: Download the actual version ${infos.latestVersion}`);
}

View file

@ -36,32 +36,38 @@ const util = require('util');
*
*/
// MIMIC https://github.com/microsoft/TypeScript/commit/9677b0641cc5ba7d8b701b4f892ed7e54ceaee9a - START
let _crypto;
try {
_crypto = require('crypto');
} catch {
_crypto = undefined;
}
const _crypto = require('crypto');
let CACHE_DIR = path.join(settings.root, 'var/');
CACHE_DIR = existsSync(CACHE_DIR) ? CACHE_DIR : undefined;
const responseCache = {};
type Headers = {
[id: string]: string
}
const djb2Hash = (data) => {
type ResponseCache = {
[id: string]: {
statusCode: number
headers: Headers
}
}
const responseCache: ResponseCache = {};
const djb2Hash = (data: string) => {
const chars = data.split('').map((str) => str.charCodeAt(0));
return `${chars.reduce((prev, curr) => ((prev << 5) + prev) + curr, 5381)}`;
};
const generateCacheKeyWithSha256 =
(path) => _crypto.createHash('sha256').update(path).digest('hex');
(path: string) => _crypto.createHash('sha256').update(path).digest('hex');
const generateCacheKeyWithDjb2 =
(path) => Buffer.from(djb2Hash(path)).toString('hex');
(path: string) => Buffer.from(djb2Hash(path)).toString('hex');
let generateCacheKey;
let generateCacheKey: (path: string)=>string;
if (_crypto) {
generateCacheKey = generateCacheKeyWithSha256;
@ -79,17 +85,17 @@ if (_crypto) {
*/
module.exports = class CachingMiddleware {
handle(req, res, next) {
handle(req: any, res: any, next: any) {
this._handle(req, res, next).catch((err) => next(err || new Error(err)));
}
async _handle(req, res, next) {
async _handle(req: any, res: any, next: any) {
if (!(req.method === 'GET' || req.method === 'HEAD') || !CACHE_DIR) {
return next(undefined, req, res);
}
const oldReq = {};
const oldRes = {};
const oldReq:ResponseCache = {};
const oldRes:ResponseCache = {};
const supportsGzip =
(req.get('Accept-Encoding') || '').indexOf('gzip') !== -1;
@ -119,7 +125,7 @@ module.exports = class CachingMiddleware {
res.write = oldRes.write || res.write;
res.end = oldRes.end || res.end;
const headers = {};
const headers: Headers = {};
Object.assign(headers, (responseCache[cacheKey].headers || {}));
const statusCode = responseCache[cacheKey].statusCode;
@ -150,18 +156,19 @@ module.exports = class CachingMiddleware {
return respond();
}
const _headers = {};
const _headers:Headers = {};
oldRes.setHeader = res.setHeader;
res.setHeader = (key, value) => {
res.setHeader = (key: string, value: string) => {
// Don't set cookies, see issue #707
if (key.toLowerCase() === 'set-cookie') return;
_headers[key.toLowerCase()] = value;
// @ts-ignore
oldRes.setHeader.call(res, key, value);
};
oldRes.writeHead = res.writeHead;
res.writeHead = (status, headers) => {
res.writeHead = (status: number, headers: Headers) => {
res.writeHead = oldRes.writeHead;
if (status === 200) {
// Update cache
@ -174,14 +181,14 @@ module.exports = class CachingMiddleware {
oldRes.write = res.write;
oldRes.end = res.end;
res.write = (data, encoding) => {
res.write = (data: number, encoding: number) => {
buffer += data.toString(encoding);
};
res.end = async (data, encoding) => {
res.end = async (data: number, encoding: number) => {
await Promise.all([
fsp.writeFile(`${CACHE_DIR}minified_${cacheKey}`, buffer).catch(() => {}),
util.promisify(zlib.gzip)(buffer)
.then((content) => fsp.writeFile(`${CACHE_DIR}minified_${cacheKey}.gz`, content))
.then((content: string) => fsp.writeFile(`${CACHE_DIR}minified_${cacheKey}.gz`, content))
.catch(() => {}),
]);
responseCache[cacheKey] = {statusCode: status, headers};
@ -191,8 +198,8 @@ module.exports = class CachingMiddleware {
// Nothing new changed from the cached version.
oldRes.write = res.write;
oldRes.end = res.end;
res.write = (data, encoding) => {};
res.end = (data, encoding) => { respond(); };
res.write = (data: number, encoding: number) => {};
res.end = (data: number, encoding: number) => { respond(); };
} else {
res.writeHead(status, headers);
}

View file

@ -4,7 +4,7 @@ const CustomError = require('../utils/customError');
// checks if a rev is a legal number
// pre-condition is that `rev` is not undefined
const checkValidRev = (rev) => {
const checkValidRev = (rev: number|string) => {
if (typeof rev !== 'number') {
rev = parseInt(rev, 10);
}
@ -28,7 +28,7 @@ const checkValidRev = (rev) => {
};
// checks if a number is an int
const isInt = (value) => (parseFloat(value) === parseInt(value, 10)) && !isNaN(value);
const isInt = (value:number) => (parseFloat(String(value)) === parseInt(String(value), 10)) && !isNaN(value);
exports.isInt = isInt;
exports.checkValidRev = checkValidRev;

View file

@ -10,11 +10,11 @@
class CustomError extends Error {
/**
* Creates an instance of CustomError.
* @param {*} message
* @param {string} message
* @param {string} [name='Error'] a custom name for the error object
* @memberof CustomError
*/
constructor(message, name = 'Error') {
constructor(message:string, name: string = 'Error') {
super(message);
this.name = name;
Error.captureStackTrace(this, this.constructor);

View file

@ -1,8 +1,8 @@
'use strict';
const fs = require('fs');
const check = (path) => {
const existsSync = fs.statSync || fs.existsSync || path.existsSync;
const check = (path:string) => {
const existsSync = fs.statSync || fs.existsSync;
let result;
try {

View file

@ -7,14 +7,16 @@
// `predicate`. Resolves to `undefined` if none of the Promises satisfy `predicate`, or if
// `promises` is empty. If `predicate` is nullish, the truthiness of the resolved value is used as
// the predicate.
exports.firstSatisfies = (promises, predicate) => {
if (predicate == null) predicate = (x) => x;
exports.firstSatisfies = <T>(promises: Promise<T>[], predicate: null|Function) => {
if (predicate == null) {
predicate = (x: any) => x;
}
// Transform each original Promise into a Promise that never resolves if the original resolved
// value does not satisfy `predicate`. These transformed Promises will be passed to Promise.race,
// yielding the first resolved value that satisfies `predicate`.
const newPromises = promises.map(
(p) => new Promise((resolve, reject) => p.then((v) => predicate(v) && resolve(v), reject)));
const newPromises = promises.map((p) =>
new Promise((resolve, reject) => p.then((v) => predicate!(v) && resolve(v), reject)));
// If `promises` is an empty array or if none of them resolve to a value that satisfies
// `predicate`, then `Promise.race(newPromises)` will never resolve. To handle that, add another
@ -42,7 +44,7 @@ exports.firstSatisfies = (promises, predicate) => {
// `total` is greater than `concurrency`, then `concurrency` Promises will be created right away,
// and each remaining Promise will be created once one of the earlier Promises resolves.) This async
// function resolves once all `total` Promises have resolved.
exports.timesLimit = async (total, concurrency, promiseCreator) => {
exports.timesLimit = async (total: number, concurrency: number, promiseCreator: Function) => {
if (total > 0 && concurrency <= 0) throw new RangeError('concurrency must be positive');
let next = 0;
const addAnother = () => promiseCreator(next++).finally(() => {
@ -59,7 +61,7 @@ exports.timesLimit = async (total, concurrency, promiseCreator) => {
* An ordinary Promise except the `resolve` and `reject` executor functions are exposed as
* properties.
*/
class Gate extends Promise {
class Gate<T> extends Promise<T> {
// Coax `.then()` into returning an ordinary Promise, not a Gate. See
// https://stackoverflow.com/a/65669070 for the rationale.
static get [Symbol.species]() { return Promise; }
@ -68,7 +70,7 @@ class Gate extends Promise {
// `this` is assigned when `super()` returns, not when it is called, so it is not acceptable to
// do the following because it will throw a ReferenceError when it dereferences `this`:
// super((resolve, reject) => Object.assign(this, {resolve, reject}));
let props;
let props: any;
super((resolve, reject) => props = {resolve, reject});
Object.assign(this, props);
}

View file

@ -3,8 +3,8 @@
* Generates a random String with the given length. Is needed to generate the
* Author, Group, readonly, session Ids
*/
const crypto = require('crypto');
const cryptoMod = require('crypto');
const randomString = (len) => crypto.randomBytes(len).toString('hex');
const randomString = (len: number) => cryptoMod.randomBytes(len).toString('hex');
module.exports = randomString;

View file

@ -1,5 +1,10 @@
'use strict';
import {ErrorExtended, RunCMDOptions, RunCMDPromise} from "../types/RunCMDOptions";
import {ChildProcess} from "node:child_process";
import {PromiseWithStd} from "../types/PromiseWithStd";
import {Readable} from "node:stream";
const spawn = require('cross-spawn');
const log4js = require('log4js');
const path = require('path');
@ -7,12 +12,12 @@ const settings = require('./Settings');
const logger = log4js.getLogger('runCmd');
const logLines = (readable, logLineFn) => {
readable.setEncoding('utf8');
const logLines = (readable: undefined | Readable | null, logLineFn: (arg0: (string | undefined)) => void) => {
readable!.setEncoding('utf8');
// The process won't necessarily write full lines every time -- it might write a part of a line
// then write the rest of the line later.
let leftovers = '';
readable.on('data', (chunk) => {
let leftovers: string| undefined = '';
readable!.on('data', (chunk) => {
const lines = chunk.split('\n');
if (lines.length === 0) return;
lines[0] = leftovers + lines[0];
@ -21,7 +26,7 @@ const logLines = (readable, logLineFn) => {
logLineFn(line);
}
});
readable.on('end', () => {
readable!.on('end', () => {
if (leftovers !== '') logLineFn(leftovers);
leftovers = '';
});
@ -69,7 +74,7 @@ const logLines = (readable, logLineFn) => {
* - `stderr`: Similar to `stdout` but for stderr.
* - `child`: The ChildProcess object.
*/
module.exports = exports = (args, opts = {}) => {
module.exports = exports = (args: string[], opts:RunCMDOptions = {}) => {
logger.debug(`Executing command: ${args.join(' ')}`);
opts = {cwd: settings.root, ...opts};
@ -82,8 +87,8 @@ module.exports = exports = (args, opts = {}) => {
: opts.stdio === 'string' ? [null, 'string', 'string']
: Array(3).fill(opts.stdio);
const cmdLogger = log4js.getLogger(`runCmd|${args[0]}`);
if (stdio[1] == null) stdio[1] = (line) => cmdLogger.info(line);
if (stdio[2] == null) stdio[2] = (line) => cmdLogger.error(line);
if (stdio[1] == null) stdio[1] = (line: string) => cmdLogger.info(line);
if (stdio[2] == null) stdio[2] = (line: string) => cmdLogger.error(line);
const stdioLoggers = [];
const stdioSaveString = [];
for (const fd of [1, 2]) {
@ -116,13 +121,13 @@ module.exports = exports = (args, opts = {}) => {
// Create an error object to use in case the process fails. This is done here rather than in the
// process's `exit` handler so that we get a useful stack trace.
const procFailedErr = new Error();
const procFailedErr: Error & ErrorExtended = new Error();
const proc = spawn(args[0], args.slice(1), opts);
const streams = [undefined, proc.stdout, proc.stderr];
const proc: ChildProcess = spawn(args[0], args.slice(1), opts);
const streams:[undefined, Readable|null, Readable|null] = [undefined, proc.stdout, proc.stderr];
let px;
const p = new Promise((resolve, reject) => { px = {resolve, reject}; });
let px: { reject: any; resolve: any; };
const p:PromiseWithStd = new Promise<string>((resolve, reject) => { px = {resolve, reject}; });
[, p.stdout, p.stderr] = streams;
p.child = proc;
@ -132,9 +137,10 @@ module.exports = exports = (args, opts = {}) => {
if (stdioLoggers[fd] != null) {
logLines(streams[fd], stdioLoggers[fd]);
} else if (stdioSaveString[fd]) {
// @ts-ignore
p[[null, 'stdout', 'stderr'][fd]] = stdioStringPromises[fd] = (async () => {
const chunks = [];
for await (const chunk of streams[fd]) chunks.push(chunk);
for await (const chunk of streams[fd]!) chunks.push(chunk);
return Buffer.concat(chunks).toString().replace(/\n+$/g, '');
})();
}

View file

@ -4,7 +4,7 @@ const path = require('path');
// Normalizes p and ensures that it is a relative path that does not reach outside. See
// https://nvd.nist.gov/vuln/detail/CVE-2015-3297 for additional context.
module.exports = (p, pathApi = path) => {
module.exports = (p: string, pathApi = path) => {
// The documentation for path.normalize() says that it resolves '..' and '.' segments. The word
// "resolve" implies that it examines the filesystem to resolve symbolic links, so 'a/../b' might
// not be the same thing as 'b'. Most path normalization functions from other libraries (e.g.,

View file

@ -1,270 +0,0 @@
'use strict';
/**
* The Toolbar Module creates and renders the toolbars and buttons
*/
const _ = require('underscore');
const removeItem = (array, what) => {
let ax;
while ((ax = array.indexOf(what)) !== -1) {
array.splice(ax, 1);
}
return array;
};
const defaultButtonAttributes = (name, overrides) => ({
command: name,
localizationId: `pad.toolbar.${name}.title`,
class: `buttonicon buttonicon-${name}`,
});
const tag = (name, attributes, contents) => {
const aStr = tagAttributes(attributes);
if (_.isString(contents) && contents.length > 0) {
return `<${name}${aStr}>${contents}</${name}>`;
} else {
return `<${name}${aStr}></${name}>`;
}
};
const tagAttributes = (attributes) => {
attributes = _.reduce(attributes || {}, (o, val, name) => {
if (!_.isUndefined(val)) {
o[name] = val;
}
return o;
}, {});
return ` ${_.map(attributes, (val, name) => `${name}="${_.escape(val)}"`).join(' ')}`;
};
const ButtonsGroup = function () {
this.buttons = [];
};
ButtonsGroup.fromArray = function (array) {
const btnGroup = new this();
_.each(array, (btnName) => {
btnGroup.addButton(Button.load(btnName));
});
return btnGroup;
};
ButtonsGroup.prototype.addButton = function (button) {
this.buttons.push(button);
return this;
};
ButtonsGroup.prototype.render = function () {
if (this.buttons && this.buttons.length === 1) {
this.buttons[0].grouping = '';
} else if (this.buttons && this.buttons.length > 1) {
_.first(this.buttons).grouping = 'grouped-left';
_.last(this.buttons).grouping = 'grouped-right';
_.each(this.buttons.slice(1, -1), (btn) => {
btn.grouping = 'grouped-middle';
});
}
return _.map(this.buttons, (btn) => {
if (btn) return btn.render();
}).join('\n');
};
const Button = function (attributes) {
this.attributes = attributes;
};
Button.load = (btnName) => {
const button = module.exports.availableButtons[btnName];
try {
if (button.constructor === Button || button.constructor === SelectButton) {
return button;
} else {
return new Button(button);
}
} catch (e) {
console.warn('Error loading button', btnName);
return false;
}
};
_.extend(Button.prototype, {
grouping: '',
render() {
const liAttributes = {
'data-type': 'button',
'data-key': this.attributes.command,
};
return tag('li', liAttributes,
tag('a', {'class': this.grouping, 'data-l10n-id': this.attributes.localizationId},
tag('button', {
'class': ` ${this.attributes.class}`,
'data-l10n-id': this.attributes.localizationId,
})));
},
});
const SelectButton = function (attributes) {
this.attributes = attributes;
this.options = [];
};
_.extend(SelectButton.prototype, Button.prototype, {
addOption(value, text, attributes) {
this.options.push({
value,
text,
attributes,
});
return this;
},
select(attributes) {
const options = [];
_.each(this.options, (opt) => {
const a = _.extend({
value: opt.value,
}, opt.attributes);
options.push(tag('option', a, opt.text));
});
return tag('select', attributes, options.join(''));
},
render() {
const attributes = {
'id': this.attributes.id,
'data-key': this.attributes.command,
'data-type': 'select',
};
return tag('li', attributes, this.select({id: this.attributes.selectId}));
},
});
const Separator = function () {};
Separator.prototype.render = function () {
return tag('li', {class: 'separator'});
};
module.exports = {
availableButtons: {
bold: defaultButtonAttributes('bold'),
italic: defaultButtonAttributes('italic'),
underline: defaultButtonAttributes('underline'),
strikethrough: defaultButtonAttributes('strikethrough'),
orderedlist: {
command: 'insertorderedlist',
localizationId: 'pad.toolbar.ol.title',
class: 'buttonicon buttonicon-insertorderedlist',
},
unorderedlist: {
command: 'insertunorderedlist',
localizationId: 'pad.toolbar.ul.title',
class: 'buttonicon buttonicon-insertunorderedlist',
},
indent: defaultButtonAttributes('indent'),
outdent: {
command: 'outdent',
localizationId: 'pad.toolbar.unindent.title',
class: 'buttonicon buttonicon-outdent',
},
undo: defaultButtonAttributes('undo'),
redo: defaultButtonAttributes('redo'),
clearauthorship: {
command: 'clearauthorship',
localizationId: 'pad.toolbar.clearAuthorship.title',
class: 'buttonicon buttonicon-clearauthorship',
},
importexport: {
command: 'import_export',
localizationId: 'pad.toolbar.import_export.title',
class: 'buttonicon buttonicon-import_export',
},
timeslider: {
command: 'showTimeSlider',
localizationId: 'pad.toolbar.timeslider.title',
class: 'buttonicon buttonicon-history',
},
savedrevision: defaultButtonAttributes('savedRevision'),
settings: defaultButtonAttributes('settings'),
embed: defaultButtonAttributes('embed'),
showusers: defaultButtonAttributes('showusers'),
timeslider_export: {
command: 'import_export',
localizationId: 'timeslider.toolbar.exportlink.title',
class: 'buttonicon buttonicon-import_export',
},
timeslider_settings: {
command: 'settings',
localizationId: 'pad.toolbar.settings.title',
class: 'buttonicon buttonicon-settings',
},
timeslider_returnToPad: {
command: 'timeslider_returnToPad',
localizationId: 'timeslider.toolbar.returnbutton',
class: 'buttontext',
},
},
registerButton(buttonName, buttonInfo) {
this.availableButtons[buttonName] = buttonInfo;
},
button: (attributes) => new Button(attributes),
separator: () => (new Separator()).render(),
selectButton: (attributes) => new SelectButton(attributes),
/*
* Valid values for whichMenu: 'left' | 'right' | 'timeslider-right'
* Valid values for page: 'pad' | 'timeslider'
*/
menu(buttons, isReadOnly, whichMenu, page) {
if (isReadOnly) {
// The best way to detect if it's the left editbar is to check for a bold button
if (buttons[0].indexOf('bold') !== -1) {
// Clear all formatting buttons
buttons = [];
} else {
// Remove Save Revision from the right menu
removeItem(buttons[0], 'savedrevision');
}
} else {
/*
* This pad is not read only
*
* Add back the savedrevision button (the "star") if is not already there,
* but only on the right toolbar, and only if we are showing a pad (dont't
* do it in the timeslider).
*
* This is a quick fix for #3702 (and subsequent issue #3767): it was
* sufficient to visit a single read only pad to cause the disappearence
* of the star button from all the pads.
*/
if ((buttons[0].indexOf('savedrevision') === -1) &&
(whichMenu === 'right') && (page === 'pad')) {
buttons[0].push('savedrevision');
}
}
const groups = _.map(buttons, (group) => ButtonsGroup.fromArray(group).render());
return groups.join(this.separator());
},
};

305
src/node/utils/toolbar.ts Normal file
View file

@ -0,0 +1,305 @@
'use strict';
/**
* The Toolbar Module creates and renders the toolbars and buttons
*/
const _ = require('underscore');
const removeItem = (array: string[], what: string) => {
let ax;
while ((ax = array.indexOf(what)) !== -1) {
array.splice(ax, 1);
}
return array;
};
const defaultButtonAttributes = (name: string, overrides?: boolean) => ({
command: name,
localizationId: `pad.toolbar.${name}.title`,
class: `buttonicon buttonicon-${name}`,
});
const tag = (name: string, attributes: AttributeObj, contents?: string) => {
const aStr = tagAttributes(attributes);
if (_.isString(contents) && contents!.length > 0) {
return `<${name}${aStr}>${contents}</${name}>`;
} else {
return `<${name}${aStr}></${name}>`;
}
};
type AttributeObj = {
[id: string]: string
}
const tagAttributes = (attributes: AttributeObj) => {
attributes = _.reduce(attributes || {}, (o: AttributeObj, val: string, name: string) => {
if (!_.isUndefined(val)) {
o[name] = val;
}
return o;
}, {});
return ` ${_.map(attributes, (val: string, name: string) => `${name}="${_.escape(val)}"`).join(' ')}`;
};
type ButtonGroupType = {
grouping: string,
render: Function
}
class ButtonGroup {
private buttons: Button[]
constructor() {
this.buttons = []
}
public static fromArray = function (array: string[]) {
const btnGroup = new ButtonGroup();
_.each(array, (btnName: string) => {
const button = Button.load(btnName) as Button
btnGroup.addButton(button);
});
return btnGroup;
}
private addButton(button: Button) {
this.buttons.push(button);
return this;
}
render() {
if (this.buttons && this.buttons.length === 1) {
this.buttons[0].grouping = '';
} else if (this.buttons && this.buttons.length > 1) {
_.first(this.buttons).grouping = 'grouped-left';
_.last(this.buttons).grouping = 'grouped-right';
_.each(this.buttons.slice(1, -1), (btn: Button) => {
btn.grouping = 'grouped-middle';
});
}
return _.map(this.buttons, (btn: ButtonGroup) => {
if (btn) return btn.render();
}).join('\n');
}
}
class Button {
protected attributes: AttributeObj
grouping: string
constructor(attributes: AttributeObj) {
this.attributes = attributes
this.grouping = ""
}
public static load(btnName: string) {
const button = module.exports.availableButtons[btnName];
try {
if (button.constructor === Button || button.constructor === SelectButton) {
return button;
} else {
return new Button(button);
}
} catch (e) {
console.warn('Error loading button', btnName);
return false;
}
}
render() {
const liAttributes = {
'data-type': 'button',
'data-key': this.attributes.command,
};
return tag('li', liAttributes,
tag('a', {'class': this.grouping, 'data-l10n-id': this.attributes.localizationId},
tag('button', {
'class': ` ${this.attributes.class}`,
'data-l10n-id': this.attributes.localizationId,
})));
}
}
type SelectButtonOptions = {
value: string,
text: string,
attributes: AttributeObj
}
class SelectButton extends Button {
private readonly options: SelectButtonOptions[];
constructor(attrs: AttributeObj) {
super(attrs);
this.options = []
}
addOption(value: string, text: string, attributes: AttributeObj) {
this.options.push({
value,
text,
attributes,
})
return this;
}
select(attributes: AttributeObj) {
const options: string[] = [];
_.each(this.options, (opt: AttributeSelect) => {
const a = _.extend({
value: opt.value,
}, opt.attributes);
options.push(tag('option', a, opt.text));
});
return tag('select', attributes, options.join(''));
}
render() {
const attributes = {
'id': this.attributes.id,
'data-key': this.attributes.command,
'data-type': 'select',
};
return tag('li', attributes, this.select({id: this.attributes.selectId}));
}
}
type AttributeSelect = {
value: string,
attributes: AttributeObj,
text: string
}
class Separator {
constructor() {
}
public render() {
return tag('li', {class: 'separator'});
}
}
module.exports = {
availableButtons: {
bold: defaultButtonAttributes('bold'),
italic: defaultButtonAttributes('italic'),
underline: defaultButtonAttributes('underline'),
strikethrough: defaultButtonAttributes('strikethrough'),
orderedlist: {
command: 'insertorderedlist',
localizationId: 'pad.toolbar.ol.title',
class: 'buttonicon buttonicon-insertorderedlist',
},
unorderedlist: {
command: 'insertunorderedlist',
localizationId: 'pad.toolbar.ul.title',
class: 'buttonicon buttonicon-insertunorderedlist',
},
indent: defaultButtonAttributes('indent'),
outdent: {
command: 'outdent',
localizationId: 'pad.toolbar.unindent.title',
class: 'buttonicon buttonicon-outdent',
},
undo: defaultButtonAttributes('undo'),
redo: defaultButtonAttributes('redo'),
clearauthorship: {
command: 'clearauthorship',
localizationId: 'pad.toolbar.clearAuthorship.title',
class: 'buttonicon buttonicon-clearauthorship',
},
importexport: {
command: 'import_export',
localizationId: 'pad.toolbar.import_export.title',
class: 'buttonicon buttonicon-import_export',
},
timeslider: {
command: 'showTimeSlider',
localizationId: 'pad.toolbar.timeslider.title',
class: 'buttonicon buttonicon-history',
},
savedrevision: defaultButtonAttributes('savedRevision'),
settings: defaultButtonAttributes('settings'),
embed: defaultButtonAttributes('embed'),
showusers: defaultButtonAttributes('showusers'),
timeslider_export: {
command: 'import_export',
localizationId: 'timeslider.toolbar.exportlink.title',
class: 'buttonicon buttonicon-import_export',
},
timeslider_settings: {
command: 'settings',
localizationId: 'pad.toolbar.settings.title',
class: 'buttonicon buttonicon-settings',
},
timeslider_returnToPad: {
command: 'timeslider_returnToPad',
localizationId: 'timeslider.toolbar.returnbutton',
class: 'buttontext',
},
},
registerButton(buttonName: string, buttonInfo: any) {
this.availableButtons[buttonName] = buttonInfo;
},
button: (attributes: AttributeObj) => new Button(attributes),
separator: () => (new Separator()).render(),
selectButton: (attributes: AttributeObj) => new SelectButton(attributes),
/*
* Valid values for whichMenu: 'left' | 'right' | 'timeslider-right'
* Valid values for page: 'pad' | 'timeslider'
*/
menu(buttons: string[][], isReadOnly: boolean, whichMenu: string, page: string) {
if (isReadOnly) {
// The best way to detect if it's the left editbar is to check for a bold button
if (buttons[0].indexOf('bold') !== -1) {
// Clear all formatting buttons
buttons = [];
} else {
// Remove Save Revision from the right menu
removeItem(buttons[0], 'savedrevision');
}
} else if ((buttons[0].indexOf('savedrevision') === -1) &&
(whichMenu === 'right') && (page === 'pad')) {
/*
* This pad is not read only
*
* Add back the savedrevision button (the "star") if is not already there,
* but only on the right toolbar, and only if we are showing a pad (dont't
* do it in the timeslider).
*
* This is a quick fix for #3702 (and subsequent issue #3767): it was
* sufficient to visit a single read only pad to cause the disappearence
* of the star button from all the pads.
*/
buttons[0].push('savedrevision');
}
const groups = _.map(buttons, (group: string[]) => ButtonGroup.fromArray(group).render());
return groups.join(this.separator());
},
};