MediaWiki:Gadget-calculator.js
Appearance
This page is loaded as a part of the calculator gadget, used by 10 users. |
/* On-Wiki calculator script. See [[Template:Calculator]]. Created by [[User:Bawolff]]. Forked from https://mdwiki.org/wiki/MediaWiki:Gadget-calculator.js (released under Creative Commons Attribution-ShareAlike 3.0 and 4.0 International Public License). See original source for attribution history
*
* This script is designed with security in mind. Possible security risks:
* * Having a formula that executes JS
* ** To prevent this we do not use eval(). Instead we parse the formula with our own parser into an abstract tree that we evaluate by walking through it
* ** Form submission & DOM clobbering - we prefix the name (and id) attribute of all fields to prevent conflicts
* ** Style injection - we take the style attribute from an existing element that was sanitized by MW. We do not take style from a data attribute.
* ** Client-side DoS - Formulas aren't evaluated without user interaction. Formulas have a max length. Max number of widgets per page. Ultimately, easy to revert slow formulas just like any other vandalism.
*
* Essentially the code works by replacing certain <span> tags with <input>, parsing a custom formula language, setting up a dependency graph based on identifiers, and re-evaluating formulas on change.
*/
(function () {
var itemList = {};
var backlinks = {};
var inProgressRefresh;
var mathFuncs = [ 'abs', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'ceil', 'cos', 'cosh', 'exp', 'floor', 'hypot',
'log', 'log10', 'log2', 'max', 'min', 'pow', 'random', 'round', 'sign', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'trunc' ];
var otherFuncs = [ 'ifzero', 'coalesce', 'iffinite', 'ifnan', 'ifpositive' ];
var allFuncs = mathFuncs.concat(otherFuncs);
// Start parser code.
var Numb = function(n) {
if ( typeof n === 'number' ) {
this.value = n;
}
this.value = parseFloat(n);
}
Numb.prototype.toString = function () { return 'Number<' + this.value + '>'; }
var Ident = function(n) {
this.value = n;
}
Ident.prototype.toString = function () { return 'IDENT<' + this.value + '>'; }
var Operator = function(val, args) {
this.op = val;
this.args = args;
}
var Null = function() { }
var Parser = function( text ) {
this.text = text;
};
var terminals = {
'IDENT': /^[a-zA-Z_][a-zA-Z0-9_]*/,
'NUMBER': /^-?[0-9]+(?:\.[0-9]+)?(?:[eE][0-9]+)*/,
'WS': /^\s*/,
'PLUSMINUS': /^[+-]/,
'pi': /^(?:pi|π)(?![A-z_0-9-])/i,
'Infinity': /^Infinity(?![A-z_0-9-])/i,
'-Infinity': /^Infinity(?![A-z_0-9-])/i,
'NaN': /^NaN(?![A-z_0-9-])/i,
'MULTIPLYDIV': /^[*\/%×÷]/i,
};
Parser.prototype = {
check: function(id) {
if ( terminals[id] ) {
return !!(this.text.match(terminals[id]))
}
return this.text.startsWith( id )
},
consume: function(id) {
if ( terminals[id] ) {
var res = this.text.match(terminals[id]);
this.text = this.text.substring( res[0].length );
return res[0];
}
if ( this.text.startsWith( id ) ) {
this.text = this.text.substring(id.length);
return id;
}
throw new Error( "Expected " + id);
},
parse: function () {
if ( this.text === undefined || this.text === '' ) return new Null();
this.consume( 'WS' );
res = this.expression();
if( this.text.length !== 0 ) {
throw new Error( "Unexpected end of text" );
}
return res;
},
expression: function () {
var text2, res, res2;
res = this.term();
this.consume( 'WS' );
while ( this.check( "PLUSMINUS" )) {
var op = this.consume( "PLUSMINUS" );
res2 = this.term();
res = new Operator( op, [ res, res2 ] );
}
return res;
},
argList: function () {
var args = [];
this.consume( 'WS' );
if ( this.check( ')' ) ) {
this.consume( ')' );
return args;
}
args[args.length] = this.expression();
this.consume( 'WS' );
while ( this.check( ',' ) ) {
this.consume( ',' );
this.consume( 'WS' );
args[args.length] = this.expression();
}
this.consume( 'WS' );
this.consume( ')' );
return args;
},
term: function () {
var text2, res, res2;
res = this.factor();
this.consume( 'WS' );
while ( this.check( "MULTIPLYDIV" )) {
var op = this.consume( "MULTIPLYDIV" );
if ( op === '×' ) op = '*';
if ( op === '÷' ) op = '/';
res2 = this.term();
res = new Operator( op, [ res, res2 ] );
}
return res;
},
factor: function () {
if ( this.check( 'pi' ) ) {
this.consume( 'pi' );
return new Numb( Math.PI )
}
if ( this.check( 'Infinity' ) ) {
this.consume( 'Infinity' );
return new Numb( Infinity );
}
if ( this.check( '-Infinity' ) ) {
this.consume( '-Infinity' );
return new Numb( -Infinity );
}
if ( this.check( 'NaN' ) ) {
this.consume( 'NaN' );
return new Numb( NaN );
}
for ( var i in allFuncs ) {
if ( this.check( allFuncs[i] + '(' ) ) {
this.consume(allFuncs[i] + '(');
var argList = this.argList()
return new Operator( allFuncs[i], argList );
}
}
if ( this.check( 'IDENT' ) ) {
return new Ident( this.consume( 'IDENT' ) );
}
if ( this.check( 'NUMBER' ) ) {
return new Numb( this.consume( 'NUMBER' ) );
}
if ( this.check( '(' ) ) {
this.consume( '(' );
res = this.expression();
this.consume( ')' );
return res;
}
throw new Error( "Expected term");
},
};
// End parser code.
// Evaluate the value of an AST at runtime.
var evaluate = function( ast ) {
if ( ast instanceof Numb ) {
return ast.value;
}
if ( ast instanceof Ident ) {
var elm = document.getElementById( 'calculator-field-' + ast.value );
if ( elm.tagName === 'INPUT' ) {
if ( elm.type === 'radio' || elm.type === 'checkbox' ) {
return elm.checked ? 1 : 0;
}
return parseFloat( elm.value );
} else {
return parseFloat( elm.textContent );
}
}
if ( ast instanceof Operator ) {
evaledArgs = ast.args.map( evaluate );
if ( mathFuncs.indexOf(ast.op) !== -1 ) {
return Math[ast.op].apply( Math, evaledArgs );
}
if ( 'coalesce' === ast.op ) {
for ( var k = 0; k < evaledArgs.length; k++ ) {
if ( !isNaN( evaledArgs[k] ) ) {
return evaledArgs[k];
}
}
return NaN;
}
if ( 'ifzero' === ast.op ) {
if ( evaledArgs.length !== 3 ) {
return NaN;
}
return Math.abs( evaledArgs[0] ) < 1e-15 ? evaledArgs[1] : evaledArgs[2];
}
if ( 'iffinite' === ast.op ) {
if ( evaledArgs.length !== 3 ) {
return NaN;
}
return isFinite( evaledArgs[0] ) ? evaledArgs[1] : evaledArgs[2];
}
if ( 'ifnan' === ast.op ) {
if ( evaledArgs.length !== 3 ) {
return NaN;
}
return isNaN( evaledArgs[0] ) ? evaledArgs[1] : evaledArgs[2];
}
if ( 'ifpositive' === ast.op ) {
if ( evaledArgs.length !== 3 ) {
return NaN;
}
return evaledArgs[0] >= 0 ? evaledArgs[1] : evaledArgs[2]
}
if ( evaledArgs.length !== 2 ) {
throw new Error( "Unexpected number of args for " + ast.op );
}
if ( ast.op === '*' ) return evaledArgs[0]*evaledArgs[1];
if ( ast.op === '/' ) return evaledArgs[0]/evaledArgs[1];
if ( ast.op === '+' ) return evaledArgs[0]+evaledArgs[1];
if ( ast.op === '-' ) return evaledArgs[0]-evaledArgs[1];
if ( ast.op === '%' ) return evaledArgs[0]%evaledArgs[1];
throw new Error( "Unrecognized operator " + ast.op );
}
return NaN;
}
// Start dependency graph code
var getIdentifiers = function( tree ) {
if ( tree instanceof Ident ) {
return new Set([tree.value]);
}
if ( tree instanceof Operator ) {
var res = new Set([]);
for ( var i = 0; i < tree.args.length; i++ ) {
getIdentifiers( tree.args[i] ).forEach( function (x) { res.add(x) } );
}
return res;
}
return new Set();
}
var buildBacklinks = function (items) {
var backlinks = {};
for ( var item in items ) {
var idents = getIdentifiers( items[item] );
// Set does not do for..in loops, and this version MW forbids for..of
idents.forEach( function (ident) {
if ( !backlinks[ident] ) {
backlinks[ident] = [];
}
backlinks[ident].push( item );
} );
}
return backlinks;
};
// End dependency graph code
// Start code that does setup and HTML interaction.
var setup = function() {
var elms = Array.from( document.getElementsByClassName( 'calculator-field' ) );
if (elms.length > 200) {
console.log( "Too many calculator widgets on page" );
return;
}
for ( var i in elms ) {
elm = elms[i];
var formula = elm.dataset.calculatorFormula;
if ( formula && formula.length > 2000 ) {
console.log( "Skipping element with too long formula" );
continue;
}
var type = elm.dataset.calculatorType;
var readonly = !!elm.dataset.calculatorReadonly;
var id = elm.id;
var defaultVal = elm.textContent;
if ( type === undefined || !id.match( /^calculator-field-[a-zA-Z_][a-zA-Z0-9_]*$/ ) ) {
console.log( "Skipping " + id );
continue;
}
try {
var formulaAST = (new Parser( formula )).parse();
} catch( e ) {
console.log( "Error parsing formula of " + id + ": " + e.message );
continue;
}
if ( type !== 'plain' ) {
var input = document.createElement( 'input' );
input.className = 'calculator-field';
input.readOnly = readonly
input.value = parseFloat(defaultVal);
input.style.cssText = elm.style.cssText; // This should be safe because elm's css was sanitized by MW
if ( elm.dataset.calculatorSize ) {
var size = parseInt( elm.dataset.calculatorSize );
input.size = size
// Browsers are pretty inconsistent so also set as css
input.style.width = type === 'number' ? ( size + 2 ) + 'ch' : size + 'ch';
}
if ( elm.dataset.calculatorSize ) input.size = elm.dataset.calculatorSize
if ( elm.dataset.calculatorMax ) input.max = elm.dataset.calculatorMax
if ( elm.dataset.calculatorMin ) input.min = elm.dataset.calculatorMin
if ( elm.dataset.calculatorPlaceholder ) input.placeholder = elm.dataset.calculatorPlaceholder
if ( elm.dataset.calculatorStep ) input.step = elm.dataset.calculatorStep
// Name is primarily for radio groups. Prefix to prevent dom clobbering or in case it ends up in a form somehow.
if ( elm.dataset.calculatorName ) input.name = 'calcgadget-' + elm.dataset.calculatorName
if ( type === 'number' || type === 'text' || type === 'radio' || type === 'checkbox' ) {
input.type = type;
}
if ( type === 'radio' || type === 'checkbox' ) {
input.onchange = changeHandler; // some browsers dont fire oninput for checkboxrs/radio
if ( !isNaN( defaultVal ) && Math.abs( defaultVal ) > 1e-15 ) {
input.checked = true;
}
}
input.id = elm.id;
elm.replaceWith( input );
elm = input;
input.oninput = changeHandler;
}
var itemId = id.replace( /^calculator-field-/, '' );
itemList[itemId] = formulaAST;
}
backlinks = buildBacklinks( itemList );
}
var changeHandler = function(e) {
console.log( "Change happened!" );
inProgressRefresh = {};
var itemId = e.target.id.replace( /^calculator-field-/, '' );
inProgressRefresh[itemId] = true;
for ( var i in backlinks[itemId] ) {
refresh( backlinks[itemId][i] );
}
if ( e.target.type === 'radio' ) {
// when a radio element gets checked, others get unchecked.
var otherElms = document.getElementsByName( e.target.name );
for ( var l = 0; l < otherElms.length; l++ ) {
if ( otherElms[l].id === e.target.id ) {
continue;
}
var oElmId = otherElms[l].id.replace( /^calculator-field-/, '' );
if ( backlinks[oElmId] ) {
for ( var k in backlinks[oElmId] ) {
refresh( backlinks[oElmId][k] );
}
}
}
}
inProgressRefresh = undefined;
}
var refresh = function (itemId) {
if ( !itemList[itemId] || itemList[itemId] instanceof Null ) {
// This should not happen.
console.log( "Tried to refresh field with no formula" );
return;
}
if ( inProgressRefresh[itemId] ) {
console.log( "Loop Detected! Skipping " + itemId );
return;
}
inProgressRefresh[itemId] = true;
var res = evaluate( itemList[itemId] );
var elm = document.getElementById( "calculator-field-" + itemId );
if ( elm.tagName === 'INPUT' ) {
elm.value = res;
if ( elm.type === 'radio' || elm.type === 'checkbox' ) {
elm.checked = !isNaN( res ) && Math.abs( res ) > 1e-15
}
} else {
elm.textContent = res;
}
for ( var i in backlinks[itemId] ) {
refresh( backlinks[itemId][i] );
}
}
$( setup );
} )();