blackhighlighter
Version:
Client and server for widget implementing secure and committed web redaction
1,411 lines (1,134 loc) • 117 kB
JavaScript
//
// jquery-blackhighlighter.js
// Copyright (C) 2009-2014 HostileFork.com
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// See http://blackhighlighter.hostilefork.com for documentation.
//
////////////////////////////////////////////////////////////////////////////////
//
// REQUIREJS AND MODULE PATTERN FOR WIDGET
//
// Basic structure borrowed from:
//
// https://github.com/bgrins/ExpandingTextareas
(function(factory) {
// Add jQuery via AMD registration or browser globals
if (typeof define === 'function' && define.amd) {
define([ 'jquery'], factory);
}
else {
factory(jQuery);
}
}(function ($) {
// http://stackoverflow.com/questions/1335851/
// http://stackoverflow.com/questions/4462478/
"use strict";
// Mask global underscore library in browser in order to avoid any
// underscore-dependent code creeping in.
var _ = null;
////////////////////////////////////////////////////////////////////////////////
//
// BASE64 ENCODE AND DECODE
//
// To reduce dependencies, we include this safe and suggested Base64
// implementation directly into the file. There are functions btoa() and
// atob() in most major browsers, and a "polyfill" available:
//
// https://github.com/davidchambers/Base64.js
//
// "it's best to use the native functions and polyfill rather than include
// a library that introduces a new API."
//
// http://stackoverflow.com/questions/246801/#comment34178292_247261
//
// However the mozilla docs emphasize there are some bugs ("issues") in
// the standard anyway. So given that it's very little code, having a
// copy embedded gives browser compatibility and takes care of those bugs.
//
// Uint8Array shim for IE
// http://stackoverflow.com/a/12047504/211160
(function() {
try {
var a = new Uint8Array(1);
return; //no need
} catch(e) { }
function subarray(start, end) {
return this.slice(start, end);
}
function set_(array, offset) {
if (arguments.length < 2) offset = 0;
for (var i = 0, n = array.length; i < n; ++i, ++offset)
this[offset] = array[i] & 0xFF;
}
// we need typed arrays
function TypedArray(arg1) {
var result;
if (typeof arg1 === "number") {
result = new Array(arg1);
for (var i = 0; i < arg1; ++i)
result[i] = 0;
} else
result = arg1.slice(0);
result.subarray = subarray;
result.buffer = result;
result.byteLength = result.length;
result.set = set_;
if (typeof arg1 === "object" && arg1.buffer)
result.buffer = arg1.buffer;
return result;
}
window.Uint8Array = TypedArray;
window.Uint32Array = TypedArray;
window.Int32Array = TypedArray;
})();
/*\
|*|
|*| Base64 / binary data / UTF-8 strings utilities
|*|
|*| https://developer.mozilla.org/en-US/docs/Web/JavaScript/Base64_encoding_and_decoding
|*|
\*/
/* Array of bytes to base64 string decoding */
function b64ToUint6 (nChr) {
return nChr > 64 && nChr < 91 ?
nChr - 65
: nChr > 96 && nChr < 123 ?
nChr - 71
: nChr > 47 && nChr < 58 ?
nChr + 4
: nChr === 43 ?
62
: nChr === 47 ?
63
:
0;
}
function base64DecToArr (sBase64, nBlocksSize) {
var
sB64Enc = sBase64.replace(/[^A-Za-z0-9\+\/]/g, ""), nInLen = sB64Enc.length,
nOutLen = nBlocksSize ? Math.ceil((nInLen * 3 + 1 >> 2) / nBlocksSize) * nBlocksSize : nInLen * 3 + 1 >> 2, taBytes = new Uint8Array(nOutLen);
for (var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0; nInIdx < nInLen; nInIdx++) {
nMod4 = nInIdx & 3;
nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4;
if (nMod4 === 3 || nInLen - nInIdx === 1) {
for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) {
taBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3 & 24) & 255;
}
nUint24 = 0;
}
}
return taBytes;
}
/* Base64 string to array encoding */
function uint6ToB64 (nUint6) {
return nUint6 < 26 ?
nUint6 + 65
: nUint6 < 52 ?
nUint6 + 71
: nUint6 < 62 ?
nUint6 - 4
: nUint6 === 62 ?
43
: nUint6 === 63 ?
47
:
65;
}
function base64EncArr (aBytes) {
var nMod3 = 2, sB64Enc = "";
for (var nLen = aBytes.length, nUint24 = 0, nIdx = 0; nIdx < nLen; nIdx++) {
nMod3 = nIdx % 3;
if (nIdx > 0 && (nIdx * 4 / 3) % 76 === 0) { sB64Enc += "\r\n"; }
nUint24 |= aBytes[nIdx] << (16 >>> nMod3 & 24);
if (nMod3 === 2 || aBytes.length - nIdx === 1) {
sB64Enc += String.fromCharCode(uint6ToB64(nUint24 >>> 18 & 63), uint6ToB64(nUint24 >>> 12 & 63), uint6ToB64(nUint24 >>> 6 & 63), uint6ToB64(nUint24 & 63));
nUint24 = 0;
}
}
return sB64Enc.substr(0, sB64Enc.length - 2 + nMod3) + (nMod3 === 2 ? '' : nMod3 === 1 ? '=' : '==');
}
/* UTF-8 array to DOMString and vice versa */
function UTF8ArrToStr (aBytes) {
var sView = "";
for (var nPart, nLen = aBytes.length, nIdx = 0; nIdx < nLen; nIdx++) {
nPart = aBytes[nIdx];
sView += String.fromCharCode(
nPart > 251 && nPart < 254 && nIdx + 5 < nLen ? /* six bytes */
/* (nPart - 252 << 32) is not possible in ECMAScript! So...: */
(nPart - 252) * 1073741824 + (aBytes[++nIdx] - 128 << 24) + (aBytes[++nIdx] - 128 << 18) + (aBytes[++nIdx] - 128 << 12) + (aBytes[++nIdx] - 128 << 6) + aBytes[++nIdx] - 128
: nPart > 247 && nPart < 252 && nIdx + 4 < nLen ? /* five bytes */
(nPart - 248 << 24) + (aBytes[++nIdx] - 128 << 18) + (aBytes[++nIdx] - 128 << 12) + (aBytes[++nIdx] - 128 << 6) + aBytes[++nIdx] - 128
: nPart > 239 && nPart < 248 && nIdx + 3 < nLen ? /* four bytes */
(nPart - 240 << 18) + (aBytes[++nIdx] - 128 << 12) + (aBytes[++nIdx] - 128 << 6) + aBytes[++nIdx] - 128
: nPart > 223 && nPart < 240 && nIdx + 2 < nLen ? /* three bytes */
(nPart - 224 << 12) + (aBytes[++nIdx] - 128 << 6) + aBytes[++nIdx] - 128
: nPart > 191 && nPart < 224 && nIdx + 1 < nLen ? /* two bytes */
(nPart - 192 << 6) + aBytes[++nIdx] - 128
: /* nPart < 127 ? */ /* one byte */
nPart
);
}
return sView;
}
function strToUTF8Arr (sDOMStr) {
var aBytes, nChr, nStrLen = sDOMStr.length, nArrLen = 0;
/* mapping... */
for (var nMapIdx = 0; nMapIdx < nStrLen; nMapIdx++) {
nChr = sDOMStr.charCodeAt(nMapIdx);
nArrLen += nChr < 0x80 ? 1 : nChr < 0x800 ? 2 : nChr < 0x10000 ? 3 : nChr < 0x200000 ? 4 : nChr < 0x4000000 ? 5 : 6;
}
aBytes = new Uint8Array(nArrLen);
/* transcription... */
for (var nIdx = 0, nChrIdx = 0; nIdx < nArrLen; nChrIdx++) {
nChr = sDOMStr.charCodeAt(nChrIdx);
if (nChr < 128) {
/* one byte */
aBytes[nIdx++] = nChr;
} else if (nChr < 0x800) {
/* two bytes */
aBytes[nIdx++] = 192 + (nChr >>> 6);
aBytes[nIdx++] = 128 + (nChr & 63);
} else if (nChr < 0x10000) {
/* three bytes */
aBytes[nIdx++] = 224 + (nChr >>> 12);
aBytes[nIdx++] = 128 + (nChr >>> 6 & 63);
aBytes[nIdx++] = 128 + (nChr & 63);
} else if (nChr < 0x200000) {
/* four bytes */
aBytes[nIdx++] = 240 + (nChr >>> 18);
aBytes[nIdx++] = 128 + (nChr >>> 12 & 63);
aBytes[nIdx++] = 128 + (nChr >>> 6 & 63);
aBytes[nIdx++] = 128 + (nChr & 63);
} else if (nChr < 0x4000000) {
/* five bytes */
aBytes[nIdx++] = 248 + (nChr >>> 24);
aBytes[nIdx++] = 128 + (nChr >>> 18 & 63);
aBytes[nIdx++] = 128 + (nChr >>> 12 & 63);
aBytes[nIdx++] = 128 + (nChr >>> 6 & 63);
aBytes[nIdx++] = 128 + (nChr & 63);
} else /* if (nChr <= 0x7fffffff) */ {
/* six bytes */
aBytes[nIdx++] = 252 + /* (nChr >>> 32) is not possible in ECMAScript! So...: */ (nChr / 1073741824);
aBytes[nIdx++] = 128 + (nChr >>> 24 & 63);
aBytes[nIdx++] = 128 + (nChr >>> 18 & 63);
aBytes[nIdx++] = 128 + (nChr >>> 12 & 63);
aBytes[nIdx++] = 128 + (nChr >>> 6 & 63);
aBytes[nIdx++] = 128 + (nChr & 63);
}
}
return aBytes;
}
////////////////////////////////////////////////////////////////////////////////
//
// SHA256 DIGEST CALCULATION
//
// To reduce dependencies, we include this SHA256 implementation which
// is... um... I guess it's okay. It seems to agree with the Node.JS
// crypto calculations anyway; and it's small.
//
/* A JavaScript implementation of the Secure Hash Algorithm, SHA-256
* Version 0.3 Copyright Angel Marin 2003-2004 - http://anmar.eu.org/
* Distributed under the BSD License
* Some bits taken from Paul Johnston's SHA-1 implementation
*/
var chrsz = 8; /* bits per input character. 8 - ASCII; 16 - Unicode */
function safe_add (x, y) {
var lsw = (x & 0xFFFF) + (y & 0xFFFF);
var msw = (x >> 16) + (y >> 16) + (lsw >> 16);
return (msw << 16) | (lsw & 0xFFFF);
}
function S (X, n) {return ( X >>> n ) | (X << (32 - n));}
function R (X, n) {return ( X >>> n );}
function Ch(x, y, z) {return ((x & y) ^ ((~x) & z));}
function Maj(x, y, z) {return ((x & y) ^ (x & z) ^ (y & z));}
function Sigma0256(x) {return (S(x, 2) ^ S(x, 13) ^ S(x, 22));}
function Sigma1256(x) {return (S(x, 6) ^ S(x, 11) ^ S(x, 25));}
function Gamma0256(x) {return (S(x, 7) ^ S(x, 18) ^ R(x, 3));}
function Gamma1256(x) {return (S(x, 17) ^ S(x, 19) ^ R(x, 10));}
function core_sha256 (m, l) {
var K = new Array(0x428A2F98,0x71374491,0xB5C0FBCF,0xE9B5DBA5,0x3956C25B,0x59F111F1,0x923F82A4,0xAB1C5ED5,0xD807AA98,0x12835B01,0x243185BE,0x550C7DC3,0x72BE5D74,0x80DEB1FE,0x9BDC06A7,0xC19BF174,0xE49B69C1,0xEFBE4786,0xFC19DC6,0x240CA1CC,0x2DE92C6F,0x4A7484AA,0x5CB0A9DC,0x76F988DA,0x983E5152,0xA831C66D,0xB00327C8,0xBF597FC7,0xC6E00BF3,0xD5A79147,0x6CA6351,0x14292967,0x27B70A85,0x2E1B2138,0x4D2C6DFC,0x53380D13,0x650A7354,0x766A0ABB,0x81C2C92E,0x92722C85,0xA2BFE8A1,0xA81A664B,0xC24B8B70,0xC76C51A3,0xD192E819,0xD6990624,0xF40E3585,0x106AA070,0x19A4C116,0x1E376C08,0x2748774C,0x34B0BCB5,0x391C0CB3,0x4ED8AA4A,0x5B9CCA4F,0x682E6FF3,0x748F82EE,0x78A5636F,0x84C87814,0x8CC70208,0x90BEFFFA,0xA4506CEB,0xBEF9A3F7,0xC67178F2);
var HASH = new Array(0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19);
var W = new Array(64);
var a, b, c, d, e, f, g, h, i, j;
var T1, T2;
/* append padding */
m[l >> 5] |= 0x80 << (24 - l % 32);
m[((l + 64 >> 9) << 4) + 15] = l;
for ( var i = 0; i<m.length; i+=16 ) {
a = HASH[0]; b = HASH[1]; c = HASH[2]; d = HASH[3]; e = HASH[4]; f = HASH[5]; g = HASH[6]; h = HASH[7];
for ( var j = 0; j<64; j++) {
if (j < 16) W[j] = m[j + i];
else W[j] = safe_add(safe_add(safe_add(Gamma1256(W[j - 2]), W[j - 7]), Gamma0256(W[j - 15])), W[j - 16]);
T1 = safe_add(safe_add(safe_add(safe_add(h, Sigma1256(e)), Ch(e, f, g)), K[j]), W[j]);
T2 = safe_add(Sigma0256(a), Maj(a, b, c));
h = g; g = f; f = e; e = safe_add(d, T1); d = c; c = b; b = a; a = safe_add(T1, T2);
}
HASH[0] = safe_add(a, HASH[0]); HASH[1] = safe_add(b, HASH[1]); HASH[2] = safe_add(c, HASH[2]); HASH[3] = safe_add(d, HASH[3]); HASH[4] = safe_add(e, HASH[4]); HASH[5] = safe_add(f, HASH[5]); HASH[6] = safe_add(g, HASH[6]); HASH[7] = safe_add(h, HASH[7]);
}
return HASH;
}
function str2binb (str) {
var bin = Array();
var mask = (1 << chrsz) - 1;
for(var i = 0; i < str.length * chrsz; i += chrsz)
bin[i>>5] |= (str.charCodeAt(i / chrsz) & mask) << (24 - i%32);
return bin;
}
function binb2hex (binarray) {
var hexcase = 0; /* hex output format. 0 - lowercase; 1 - uppercase */
var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef";
var str = "";
for (var i = 0; i < binarray.length * 4; i++) {
str += hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8+4)) & 0xF) + hex_tab.charAt((binarray[i>>2] >> ((3 - i%4)*8 )) & 0xF);
}
return str;
}
function hex_sha256(s){return binb2hex(core_sha256(str2binb(s),s.length * chrsz));}
////////////////////////////////////////////////////////////////////////////////
//
// BASE64 + SHA256
//
// The strange internal format used by the SHA256 calculation represents
// the hash as an array of 8 integers. We want to turn that into an
// array of bytes so we can Base64 encode it.
//
// Also, the default encoding for Base64 is not safe to use in URLs.
// This does the suggested substitutions:
//
// '+' => '-'
// '/' => '_'
// '=' => '~'
//
// http://stackoverflow.com/a/5835352/211160
//
function intToByteArray(/*int*/num) {
if ((num & 0xFFFFFFFF) != num) {
throw Error("Integer out of range for intToByteArray");
}
var data = [];
for (var i = 0; i < 4; i++) {
data[i] = (num >> (i * 8)) & 0xff;
}
return data;
}
function urlencode_base64_sha256(s) {
var binb = core_sha256(str2binb(s),s.length * chrsz);
var bytes = [];
for (var i = 0; i < binb.length; i++) {
// http://stackoverflow.com/a/1374131/211160
bytes.push.apply(bytes, intToByteArray(binb[i]));
}
var str = base64EncArr(bytes);
str = str.replace(/\+/g, '-');
str = str.replace(/\//g, '_');
str = str.replace(/=/g, '~');
return str;
}
////////////////////////////////////////////////////////////////////////////////
//
// HTML ESCAPING
//
// _.escape() is in underscore.js with no equivalent in jQuery; this native
// implementation can be used in shared code between widget and server.
//
// http://stackoverflow.com/a/12034334/211160
//
var entityMap = {
"&": "&",
"<": "<",
">": ">",
'"': '"',
"'": ''',
"/": '/'
};
function escapeHtmlNativeJS (string) {
return String(string).replace(/[&<>"'\/]/g, function (s) {
return entityMap[s];
});
}
////////////////////////////////////////////////////////////////////////////////
// Better to use these constants than test against "magic numbers"
//
// http://safalra.com/web-design/javascript/dom-node-type-constants/
//
// http://en.wikipedia.org/wiki/Document_Object_Model
var Node = {
ELEMENT_NODE: 1,
ATTRIBUTE_NODE: 2,
TEXT_NODE: 3,
CDATA_SECTION_NODE: 4,
ENTITY_REFERENCE_NODE: 5,
ENTITY_NODE: 6,
PROCESSING_INSTRUCTION_NODE: 7,
COMMENT_NODE: 8,
DOCUMENT_NODE: 9,
DOCUMENT_TYPE_NODE: 10,
DOCUMENT_FRAGMENT_NODE: 11,
NOTATION_NODE: 12
};
////////////////////////////////////////////////////////////////////////////////
//
// CUSTOM ERRORS
//
// http://blog.hostilefork.com/error-handling-internal-badrequest-node/
//
// Note that `captureStackTrace` is V8 specific (Chrome, Node)
// http://www.devthought.com/2011/12/22/a-string-is-not-an-error/
//
function ClientError (msg) {
if (!(this instanceof ClientError)) {
return new ClientError(msg);
}
Error.call(this);
this.message = msg;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ClientError);
}
};
ClientError.prototype.__proto__ = Error.prototype;
ClientError.prototype.name = 'ClientError';
function TimestampError(serverDate, clientDate) {
if (!(this instanceof TimestampError)) {
return new TimestampError(serverDate, clientDate);
}
Error.call(this);
this.serverDate = serverDate;
this.clientDate = clientDate;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, TimestampError);
}
};
TimestampError.prototype.__proto__ = Error.prototype;
TimestampError.prototype.name = 'TimestampError';
TimestampError.prototype.toString = function () {
return 'TimestampError: Server signed data with timestamp '
+ this.serverDate.toUTCString()
+ " which is off by more than a minute from "
+ this.clientDate.toUTCString();
}
// http://stackoverflow.com/a/24876472/211160
function MultipleError(errs) {
if (!(this instanceof MultipleError)) {
return new MultipleError(errs);
}
Error.call(this);
this.errs = errs;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, MultipleError);
}
};
MultipleError.prototype.__proto__ = Error.prototype;
MultipleError.prototype.name = 'MultipleError';
MultipleError.prototype.toString = function () {
return 'MultipleError: [\n\t' + this.errs.join(',\n\t') + '\n]';
}
////////////////////////////////////////////////////////////////////////////////
//
// EXPORTED API
//
// What we return from this RequireJS "module function" is an object
// whose contents represent that which we wish to export to the browser
// or to NodeJS. Though blackhighlighter can be loaded in the browser
// with a jQuery dependency, it can also be loaded in Node.JS passing in
// jQuery of null just to get the common non-UI functions.
//
// It should be noted that for the blackhighlighter widget itself, the
// functionality is exported by adding onto the jQuery ($) entity
// a .blackhighlighter() function. That is not an option for exporting
// common routines to Node.JS as there is no jQuery, and the
// require("jquery.blackhighlighter") has to return something AND not
// trigger any calls to jQuery because it will be null.
//
// Note that because the server-side doesn't use jQuery, and the client
// side doesn't require underscore:
//
// **** THIS SECTION MUST BE NATIVE JAVASCRIPT! ****
// **** THAT MEANS NO CALLS TO $() OR _.xxxxx() ****
//
// Although there is a forEach in ECMAscript 5, there's no real reason
// to use it until warranted... just a simple old FOR will do.
//
// http://stackoverflow.com/a/9329476/211160
//
var exports = {
// Custom error classes
'ClientError': ClientError,
'TimestampError': TimestampError,
'MultipleError': MultipleError,
// API points, base_url should be passed in the constructor and not
// repeated, see:
//
// https://github.com/hostilefork/blackhighlighter/issues/57
// https://github.com/hostilefork/blackhighlighter/issues/55
makeCommitUrl: function(base_url) {
return base_url + 'commit/';
},
makeRevealUrl: function(base_url) {
return base_url + 'reveal/';
},
//
// UUID
// http://en.wikipedia.org/wiki/UUID
//
generateRandomUUID: function() {
// 128 bits of random data is the size of a Uuid
// http://bytes.com/groups/javascript/523253-how-create-Uuid-javascript
function fourHex(count) {
if (count === 0) {
return '';
}
// if count is null or undefined, assume 1
var ret = '';
for (var index = 0; index < (count ? count : 1); index++) {
ret += (((1 + Math.random()) * 0x10000) | 0)
.toString(16)
.substring(1);
}
return ret;
}
return (
fourHex(2) + '-'
+ fourHex() + '-'
+ fourHex() + '-'
+ fourHex() + '-'
+ fourHex(3)
);
},
stripHyphensFromUUID: function(uuid) {
// standard Uuid format contains hyphens to improve readability
// freebase and other systems that use Uuids in URLs don't have
// the hyphens
return uuid.replace(/-/g, '');
},
//
// TYPE DETECTION
//
// REVIEW: Use better approaches? Something like this?
// http://mattsnider.com/javascript/type-detection/
// http://mattsnider.com/core/type-detection-revisited/
//
isWhitespace: function(charToCheck) {
// http://www.somacon.com/p355.php
// added non-breaking space
var whitespaceChars = ' \t\n\r\f\u00A0';
return (whitespaceChars.indexOf(charToCheck) != -1);
},
//
// JAVASCRIPT HELPERS
//
escapeNonBreakingSpacesInString: function(str) {
// UNICODE \u00A0 is not escaped by JSON.stringify
var nbspSplit = str.split('\u00A0');
if (nbspSplit.length == 1) {
return str;
}
var ret = nbspSplit[0];
for (
var nbspSplitIndex = 1;
nbspSplitIndex < nbspSplit.length;
nbspSplitIndex++
) {
ret += '\\' + 'u00A0';
ret += nbspSplit[nbspSplitIndex];
}
return ret;
},
// http://www.somacon.com/p355.php
trimLeadingWhitespace: function(str) {
var k = 0;
while ((k < str.length) && this.isWhitespace(str.charAt(k))) {
k++;
}
return str.substring(k, str.length);
},
trimTrailingWhitespace: function(str) {
var j = str.length-1;
while ((j >= 0) && this.isWhitespace(str.charAt(j))) {
j--;
}
return str.substring(0, j + 1);
},
trimAllWhitespace: function(str) {
return this.trimLeadingWhitespace(this.trimTrailingWhitespace(str));
},
// See ../test/all.js for notes on JSON format and tests
canonicalJsonFromCommit: function(commit) {
// There are some things to consider here regarding Unicode
// Normalization and "Canonical JSON":
//
// https://github.com/hostilefork/blackhighlighter/issues/56
var result = '{"commit_date":';
result += JSON.stringify(commit.commit_date);
result += ',';
var isFirstSpan = true;
result += '"spans":[';
for (var index = 0; index < commit.spans.length; index++) {
var span = commit.spans[index];
if (isFirstSpan) {
isFirstSpan = false;
}
else {
result += ',';
}
// Native test for string, no libraries
// http://stackoverflow.com/a/9436948/211160
if (typeof span == 'string' || span instanceof String) {
// We want to turn single quotes into \", etc.
// for our canonical representation
result += JSON.stringify(span);
}
else {
result += '["display_length":';
result += span.display_length.toString(10);
result += ',';
result += '"sha256":';
result += JSON.stringify(span.sha256);
result += ']';
}
}
result += ']}';
return result;
},
commitIdFromCommit: function(commit) {
return urlencode_base64_sha256(this.canonicalJsonFromCommit(commit));
},
hashOfReveal: function(reveal) {
return urlencode_base64_sha256(reveal.salt + reveal.contents);
},
generateHtmlFromCommitAndReveals: function (commit, reveal_array) {
// https://github.com/hostilefork/blackhighlighter/issues/53
// u'\u00A0' is the non breaking space
// ...it should be preserved in db strings via UTF8
// REVIEW: for each one that has been unredacted make
// a hovery bit so that you can get a tip on when it was made public?
// how will auditing be done?
// http://documentcloud.github.com/underscore/#groupBy
var revealsByHash = {};
for (var index = 0; index < reveal_array.length; index++) {
var reveal = reveal_array[index];
if (revealsByHash[reveal.sha256] != null)
throw Error("More than one reveal for the same hash.");
revealsByHash[reveal.sha256] = reveal_array[index];
}
var result = '';
// The commits and reveals contain just ordinary text as
// JavaScript strings, so "a < b" is legal. But what we're
// making here needs to be raw HTML in the template, to get
// the spans and divs and such for the redaction in the
// blacked-out bits. We have to escape the span using
// native JavaScript in this set of common exports.
for (var index = 0; index < commit.spans.length; index++) {
var commitSpan = commit.spans[index];
if (
typeof commitSpan == 'string'
|| commitSpan instanceof String
) {
commitSpan = escapeHtmlNativeJS(commitSpan);
// Also, line breaks must be converted to br nodes
result += commitSpan.split('\n').join('<br />');
}
else {
if (revealsByHash[commitSpan.sha256]) {
var reveal = revealsByHash[commitSpan.sha256];
result += '<span class="placeholder revealed">'
+ '<span class="placeholder-sha256">'
+ commitSpan.sha256
+ '</span>'
+ reveal.value
+ '</span>';
}
else {
var display_length = parseInt(
commitSpan.display_length, 10
);
// http://stackoverflow.com/a/1877479/211160
var placeholderString = Array(
display_length + 1
).join('?');
// REVIEW: use hex digest as title for query, or do
// something more clever? we could add a method onto
// the element or keep a sidestructure
var placeholder =
'<span class="placeholder protected">'
+ '<span class="placeholder-sha256">'
+ commitSpan.sha256
+ '</span>'
+ placeholderString
+ '</span>';
result += placeholder;
}
}
}
return result;
}
};
// Stop here if we don't have jQuery - all we want is the exports
if ($.isFakeJquery) {
return exports;
}
////////////////////////////////////////////////////////////////////////////////
//
// INSTANCE INITIALIZATION
//
// While there is a DOM element in the tree representing the
// contenteditable div, there is also a separate object representing the
// properties of a blackhighlighter instance attached to that div. I'm
// not entirely sure about the advantages or disadvantages of this vs.
// using jQuery .data() attached to the element (is that always cleared
// when you unplug an element from the DOM?) but it works.
//
// One of these objects is instantiated whenever you call something like
// $el.blackhighlighter({option: value}); and the instance lasts until
// you call $el.blackhighlighter("destroy");
//
var Blackhighlighter = function($div, opts) {
var instance = this;
Blackhighlighter._registry.push(instance);
instance.$div = $div;
// keep track of if we added it to take it off?
$div.addClass("blackhighlighter");
if (opts.mode === 'show') {
if (opts.commit) {
instance.commit = opts.commit;
}
else {
throw ClientError("Starting a blackhighlighter in show mode requires a commit in the options");
}
}
else {
if (opts.commit) {
throw ClientError("Can't start a compose/protect blackhighlighter with a commit");
}
if (opts.reveals) {
throw ClientError("Can't start a compose/protect blackhighlighter with reveals");
}
}
// Protections are local reveals in the show/reveal modes
instance.protections = {};
// REVIEW: should we force a check of the hashes here to give a client
// error if they pass in bad reveals, or is it okay to throw the
// error later?
instance.reveals = {};
if (opts.reveals) {
$.each(opts.reveals, function(idx, reveal) {
if (reveal.sha256 in instance.reveals) {
throw ClientError("Duplicate reveal hash passed into blackhighlighter");
}
instance.reveals[reveal.sha256] = reveal;
});
}
// Set the mode (needs the commit/protections to update titles)
instance.setMode(opts.mode, true);
// When the content of the text area is modified, we want to give
// an update notification to clients of the widget.
//
// REVIEW: Should we use the .trigger mechanism to offer all
// of our events, or is it better to pass the functions in as
// parameters to the config?
//
// http://stackoverflow.com/a/6263537/211160
instance.$div.on('focus', function() {
var $this = $(this);
$this.data('before', $this.html());
return $this;
});
instance.$div.on('blur keyup paste input', function() {
var $this = $(this);
if ($this.data('before') !== $this.html()) {
$this.data('before', $this.html());
$this.trigger('change');
}
return $this;
});
// We need some kind of updating/event model so that clients can
// know at least if someone has redacted or unredacted...or typed
// into the widget.
instance.$div.on('change', function() {
instance.update();
return true;
});
if (opts.update) $div.bind("update.blackhighlighter", opts.update);
};
// Stores (active) `Blackhighlighter` instances
// Destroyed instances are removed
Blackhighlighter._registry = [];
// Returns the `Blackhighlighter` instance given a DOM node
Blackhighlighter.getInstance = function(div) {
var $divs = $.map(Blackhighlighter._registry, function(instance) {
return instance.$div[0];
}),
index = $.inArray(div, $divs);
return index > -1 ? Blackhighlighter._registry[index] : null;
};
////////////////////////////////////////////////////////////////////////////////
Blackhighlighter.prototype = {
// Attaches input events
// Only attaches `keyup` events if `input` is not fully suported
/* attach: function() {
var events = 'input.blackhighlighter change.blackhighlighter',
_this = this;
if(!inputSupported) events += ' keyup.blackhighlighter';
this.$textarea.bind(events, function() { _this.update(); });
},*/
// In "expanding" (the plugin I modeled after), this would update the
// clone and trigger an event. I'm using it just to say when things
// get protected or unprotected. Enhance event model later when I
// understand it better
update: function() {
// Use `triggerHandler` to prevent conflicts with `update`
// in Prototype.js
this.$div.triggerHandler("update.blackhighlighter");
},
// Tears down the plugin on the object
destroy: function() {
var index = $.inArray(this, Blackhighlighter._registry);
if (index > -1) Blackhighlighter._registry.splice(index, 1);
// REVIEW: clean up any contenteditable or events?
// version of setMode for targeting an undefined mode to help?
// can pass more in string, space-delimited
this.$div.unbind('update.blackhighlighter');
},
////////////////////////////////////////////////////////////////////////////////
//
// MODE TRANSITIONS
//
//
// Suggestions should be done with an API.
// https://github.com/hostilefork/blackhighlighter/issues/60
//
_addSuggestionsRecursive: function(node) {
var lastPushWasText = false;
// re-interleave the splits and matches...which goes first depends
// on whether the match was at the first position.
function pushSuggestSpan(str) {
var $span = $('<span class="placeholder suggested"></span>');
$span.append($(document.createTextNode(str)));
$(node).before($span);
// Event delegation would be nice, but poor compositionality
// https://github.com/hostilefork/blackhighlighter/issues/59
$span.on('click', $.proxy(this._takeSuggestionListener, this));
lastPushWasText = false;
}
function pushTextNode(str) {
if (lastPushWasText) {
throw Error("Pushed two text nodes in a row, need normalization for that.");
}
if (str !== '') {
$(node).before(document.createTextNode(str));
lastPushWasText = true;
}
}
var nodeType = $.type(node.nodeType) === undefined
? Node.ATTRIBUTE_NODE
: node.nodeType;
// search all textnodes that aren't under protected spans
switch (nodeType) {
case Node.TEXT_NODE:
var strData = node.data;
var regexEmail = /[0-9a-zA-Z]+@[0-9a-zA-Z]+[\.][0-9a-zA-Z]+[\.]?[0-9a-zA-Z]+/g;
var firstMatchPos = strData.search(regexEmail);
if (firstMatchPos == -1) {
// no matches, leave node alone
break;
}
var splitArray = strData.split(regexEmail);
// reset lastIndex so we find first match again
regexEmail.lastIndex = 0;
var matchArray = strData.match(regexEmail);
var matchIndex = 0;
var splitIndex = 0;
// internet explorer does not return empty spans at start
// and end of match array, so we can prune them off for
// firefox...
if (splitArray[0] === '') {
splitIndex++;
}
while (
(matchIndex < matchArray.length)
&& (splitIndex < splitArray.length)
) {
if (firstMatchPos == 0) {
pushSuggestSpan.call(this, matchArray[matchIndex++]);
pushTextNode.call(this, splitArray[splitIndex++]);
}
else {
pushTextNode.call(this, splitArray[splitIndex++]);
pushSuggestSpan.call(this, matchArray[matchIndex++]);
}
}
if (firstMatchPos === 0) {
if (matchIndex < matchArray.length) {
pushSuggestSpan(matchArray[matchIndex++]);
}
}
else {
if (splitIndex < splitArray.length) {
pushTextNode(splitArray[splitIndex++]);
}
}
if (
(splitIndex != splitArray.length)
|| (matchIndex != matchArray.length)
) {
throw Error("Unreachable condition in regular expression matcher for addProtectSuggestions.");
}
$(node).remove();
break;
case Node.ELEMENT_NODE:
if (
(node.tagName.toLowerCase() != 'span')
|| (!$(node).hasClass('protected'))
) {
var child = node.firstChild;
while (child) {
var next = child.nextSibling;
this._addSuggestionsRecursive(child);
child = next;
}
}
break;
default:
break;
}
},
// IE does strange behaviors for the DOM element's normalize() function
// such as wrapping text nodes in divs sometimes (?) This
// re-implementation is more explicit and avoids bugs with that.
//
// http://stackoverflow.com/a/22339405/211160
_safeNormalize: function (element) {
function collectTextNodes(textNode) {
// while there are text siblings, concatenate into the first
while (textNode.nextSibling) {
var next = textNode.nextSibling;
if (
next.nodeType == Node.TEXT_NODE
|| next.nodeType == Node.CDATA_SECTION_NODE
) {
textNode.nodeValue += next.nodeValue;
textNode.parentNode.removeChild(next);
}
else {
// Stop if not a text node
return;
}
}
}
var node = element.firstChild;
// Traverse siblings, call normalise for elements and
// collectTextNodes for text nodes
while (node && node.nextSibling) {
if (node.nodeType == 1) {
this._safeNormalize(node);
}
else if (node.nodeType == 3) {
collectTextNodes(node);
}
node = node.nextSibling;
}
},
_removeSuggestions: function() {
var instance = this;
this.$div.find('span.suggested').each(function(idx, span) {
var $span = $(span);
var $parent = $span.parent();
$span.replaceWith($span.contents().remove());
instance._safeNormalize.call(instance, $parent.get(0));
});
},
_canonizeContent: function() {
var instance = this;
// First canonize all the <p> tags for browsers that make them into
// <div> instead. As you might expect, an easy thing to change is
// hard; tags on elements can't change without disrupting content.
//
// Note we lose any attributes that may have been attached to the
// paragraph. As we're going for canon, that's not a bad thing in
// this case...in fact we should probably strip *more* information!
//
// http://stackoverflow.com/a/1695200/211160
this.$div.find("p").each(function(idx, el) {
var oldP = $(el);
var newDiv = $('<div></div>');
oldP.before(newDiv);
newDiv.append(oldP.contents());
oldP.remove();
});
// If there are no divs or br at all, then wrap the whole thing up
// into one single div.
if (!this.$div.find("div, br").length) {
var $newDiv = $("<div></div>").append(
this.$div.contents().remove()
);
this.$div.append($newDiv);
}
// Due to wacky behavior of the ::selection pseudoclass, a custom
// selection color will not apply to any *empty space* that crosses
// line breaks. This looks ugly. There are other reasons for
// canonizing the input so there are only <div></div> sections
// with no <br> (simplifies later processing), so it's worth
// doing regardless of this quirk.
// Hard to canonize any arbitrary input here, because you can't
// (for instance) blindly transform all <div>foo</div> into foo<br>
// So this is an attempt to "make it work". Common pattern in the
// contenteditable implementions looks to begin the container
// with something not in a div, with things after that put into
// divs...which causes a break similar to as if it was in a div.
// So account for that one, at least.
if (
(this.$div.contents().length >= 2)
&& (!this.$div.contents().eq(0).is("div"))
&& (this.$div.contents().eq(1).is("div"))
) {
this.$div.contents().eq(0).wrapAll("<div></div>");
}
// After that let's flatten, and hope for the best.
this.$div.find("div").each(function(idx, el) {
var $el = $(el);
// Flatten by putting content before, break after, and remove
if (
($el.contents().length == 1)
&& ($el.contents().first().is("br"))
) {
$el.after($('<br>'));
$el.remove();
}
else {
$el.after($('<br>'));
$el.before($el.contents());
$el.remove();
}
});
// Now we recover the structure adapting code from StackOverflow
//
// http://stackoverflow.com/q/18494385/211160
var $contents = this.$div.contents();
var $cur, $set, i;
$set = $();
for (i = 0; i < $contents.length; i++) {
$cur = $contents.eq(i);
if ($cur.is("br")) {
if ($set.length > 0) {
$set.wrapAll("<div></div>");
$cur.remove();
}
else {
// An actual line break. Wrap in a span so that we
// don't have content as a direct child of the
// contenteditble (causes ugly selection UI)
$cur.replaceWith(
$('<div class="nbsp-spacing-hack"> </div>')
);
}
$set = $();
}
else {
$set = $set.add($cur);
}
}
$set.wrapAll("<div></div>");
// We want only text nodes or to preserve any protection spans.
// There may be things other than these that made it into
// the contenteditable, even with our pasting filter. Example:
// IE has a "feature" where it will always turn things that
// look like hyperlinks or email addresses into anchors. Seems
// you can't turn it off.
//
// http://drupal.org/node/191644
this.$div.contents().each(function () {
var $subDiv = $(this);
if (!$subDiv.is('div')) {
throw Error("Canonization produced non-div toplevel elem");
}
// Only look at the non-text nodes...
$subDiv.children().each(function () {
var $child = $(this);
if (
!$child.is('span')
&& !$child.hasClass('placeholder')
) {
$child.replaceWith(
$(document.createTextNode($child.text()))
);
}
});
instance._safeNormalize.call(instance, $subDiv.get(0));
});
// Do text nodes need any processing in the canonization?
// https://github.com/hostilefork/blackhighlighter/issues/61
},
_decanonizeContent: function() {
var instance = this;
// One simple way to decanonize is just to leave the first element
// outside of a div, with all the successive elements keeping their
// divs and wrapping actual breaks in divs. This is what webkit
// seems to do, and if it weren't for the selection stuff I'd have
// left it be.
//
// If only custom selection color wasn't so fickle :-/
this.$div.children().each(function(idx, el) {
var $el = $(el);
if ($el.is("div") && $el.hasClass("nbsp-spacing-hack")) {
$el.html($('<br>'));
$el.removeClass("nbsp-spacing-hack");
}
if ($el.is("div") && (idx == 0)) {
$el.before($el.contents());
$el.remove();
}
instance._safeNormalize.call(instance, $el.get(0));
// Just leave it otherwise.
});
},
setMode: function(newMode, initializing) {
var oldMode = undefined;
if (!initializing) {
oldMode = this.mode;
if (oldMode == newMode) {
return;
}
switch (oldMode) {
case 'compose': {
this.$div
.attr("contenteditable", false)
.removeClass("blackhighlighter-compose");
break;
}
case 'protect': {
this.$div
.removeClass("blackhighlighter-protect")
.off("mousedown", this._inkOnListener, this)
.off("mouseup mouseleave",
this._inkOffListener, this
);
// Just in case it got stuck somehow :-/
this.$div.removeClass("blackhighlighter-ink");
// Event delegation would be nice, bad compositionality
// https://github.com/hostilefork/blackhighlighter/issues/59
this.$div.find("span.protected").off(
'click', this._unprotectSpanListener
);
this._removeSuggestions();
this._decanonizeContent();
break;
}
case 'show': {
this.$div
.removeClass("blackhighlighter-show");
break;
}
case 'reveal': {
this.$di