UNPKG

blackhighlighter

Version:

Client and server for widget implementing secure and committed web redaction

1,411 lines (1,134 loc) 117 kB
// // 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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': '&quot;', "'": '&#39;', "/": '&#x2F;' }; 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">&nbsp;</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