You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cryptpad/www/common/media-tag.js

594 lines
20 KiB
JavaScript

(function(name, definition) {
if (typeof module !== 'undefined') { module.exports = definition(); }
else if (typeof define === 'function' && typeof define.amd === 'object') { define(definition); }
else { this[name] = definition(); }
}('MediaTag', function() {
var cache;
var cypherChunkLength = 131088;
// Save a blob on the file system
var saveFile = function (blob, url, fileName) {
if (window.navigator && window.navigator.msSaveOrOpenBlob) {
window.navigator.msSaveOrOpenBlob(blob, fileName);
} else {
// We want to be able to download the file with a name, so we need an "a" tag with
// a download attribute
var a = document.createElement("a");
a.href = url;
a.download = fileName;
// It's not in the DOM, so we can't use a.click();
var event = new MouseEvent("click");
a.dispatchEvent(event);
}
};
var fixHTML = function (str) {
if (!str) { return ''; }
return str.replace(/[<>&"']/g, function (x) {
return ({ "<": "&lt;", ">": "&gt", "&": "&amp;", '"': "&#34;", "'": "&#39;" })[x];
});
};
var isplainTextFile = function (metadata) {
// does its type begins with "text/"
if (metadata.type.indexOf("text/") === 0) { return true; }
// no type and no file extension -> let's guess it's plain text
var parsedName = /^(\.?.+?)(\.[^.]+)?$/.exec(metadata.name) || [];
if (!metadata.type && !parsedName[2]) { return true; }
// other exceptions
if (metadata.type === 'application/x-javascript') { return true; }
if (metadata.type === 'application/xml') { return true; }
return false;
};
// Default config, can be overriden per media-tag call
var config = {
allowed: [
'text/plain',
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'audio/mp3',
'audio/ogg',
'audio/wav',
'audio/webm',
'video/mp4',
'video/ogg',
'video/webm',
'application/pdf',
//'application/dash+xml', // FIXME?
'download'
],
pdf: {},
download: {
text: "Save",
textDl: "Load attachment"
},
Plugins: {
/**
* @param {object} metadataObject {name, metadatatype, owners} containing metadata of the file
* @param {strint} url Url of the blob object
* @param {Blob} content Blob object containing the data of the file
* @param {object} cfg Object {Plugins, allowed, download, pdf} containing infos about plugins
* @param {function} cb Callback function: (err, pluginElement) => {}
*/
text: function (metadata, url, content, cfg, cb) {
var plainText = document.createElement('div');
plainText.className = "plain-text-reader";
plainText.setAttribute('style', 'white-space: pre-wrap;');
var reader = new FileReader();
reader.addEventListener('loadend', function (e) {
plainText.innerText = e.srcElement.result;
cb(void 0, plainText);
});
reader.readAsText(content);
},
image: function (metadata, url, content, cfg, cb) {
var img = document.createElement('img');
img.setAttribute('src', url);
img.blob = content;
cb(void 0, img);
},
video: function (metadata, url, content, cfg, cb) {
var video = document.createElement('video');
video.setAttribute('src', url);
video.setAttribute('controls', true);
cb(void 0, video);
},
audio: function (metadata, url, content, cfg, cb) {
var audio = document.createElement('audio');
audio.setAttribute('src', url);
audio.setAttribute('controls', true);
cb(void 0, audio);
},
pdf: function (metadata, url, content, cfg, cb) {
var iframe = document.createElement('iframe');
if (cfg.pdf.viewer) { // PDFJS
var viewerUrl = cfg.pdf.viewer + '?file=' + url;
iframe.src = viewerUrl + '#' + window.encodeURIComponent(metadata.name);
return void cb (void 0, iframe);
}
iframe.src = url + '#' + window.encodeURIComponent(metadata.name);
return void cb (void 0, iframe);
},
download: function (metadata, url, content, cfg, cb) {
var btn = document.createElement('button');
btn.setAttribute('class', 'btn btn-default');
btn.innerHTML = '<i class="fa fa-save"></i>' + cfg.download.text + '<br>' +
(metadata.name ? '<b>' + fixHTML(metadata.name) + '</b>' : '');
btn.addEventListener('click', function () {
saveFile(content, url, metadata.name);
});
cb(void 0, btn);
}
}
};
var makeProgressBar = function (cfg, mediaObject) {
// XXX CSP: we'll need to add style in cryptpad's less
var style = (function(){/*
.mediatag-progress-container {
position: relative;
border: 1px solid #0087FF;
background: white;
height: 25px;
display: inline-flex;
width: 200px;
align-items: center;
justify-content: center;
box-sizing: border-box;
vertical-align: top;
}
.mediatag-progress-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: #0087FF;
width: 0%;
}
.mediatag-progress-text {
height: 25px;
margin-left: 5px;
line-height: 25px;
vertical-align: top;
width: auto;
display: inline-block;
color: #3F4141;
font-weight: bold;
}
*/}).toString().slice(14, -3);
var container = document.createElement('div');
container.classList.add('mediatag-progress-container');
var bar = document.createElement('div');
bar.classList.add('mediatag-progress-bar');
container.appendChild(bar);
var text = document.createElement('span');
text.classList.add('mediatag-progress-text');
text.innerText = '0%';
mediaObject.on('progress', function (obj) {
var percent = obj.progress;
text.innerText = (Math.round(percent*10))/10+'%';
bar.setAttribute('style', 'width:'+percent+'%;');
});
mediaObject.tag.innerHTML = '<style>'+style+'</style>';
mediaObject.tag.appendChild(container);
mediaObject.tag.appendChild(text);
};
var makeDownloadButton = function (cfg, mediaObject, size, cb) {
var btn = document.createElement('button');
btn.setAttribute('class', 'btn btn-default');
btn.innerHTML = '<i class="fa fa-paperclip"></i>' +
cfg.download.textDl + ' <b>(' + size + 'MB)</b>';
btn.addEventListener('click', function () {
makeProgressBar(cfg, mediaObject);
cb();
});
mediaObject.tag.innerHTML = '';
mediaObject.tag.appendChild(btn);
};
var getFileSize = function (src, _cb) {
var cb = function (e, res) {
_cb(e, res);
cb = function () {};
};
// XXX Cache
var xhr = new XMLHttpRequest();
xhr.open("HEAD", src);
xhr.onerror = function () { return void cb("XHR_ERROR"); };
xhr.onreadystatechange = function() {
if (this.readyState === this.DONE) {
cb(null, Number(xhr.getResponseHeader("Content-Length")));
}
};
xhr.onload = function () {
if (/^4/.test('' + this.status)) { return void cb("XHR_ERROR " + this.status); }
};
xhr.send();
};
// Download a blob from href
var download = function (src, _cb, progressCb) {
var cb = function (e, res) {
_cb(e, res);
cb = function () {};
};
var progress = function (offset) {
progressCb(offset * 100);
};
var xhr = new XMLHttpRequest();
xhr.open('GET', src, true);
xhr.responseType = 'arraybuffer';
xhr.addEventListener("progress", function (evt) {
if (evt.lengthComputable) {
var percentComplete = evt.loaded / evt.total;
progress(percentComplete);
}
}, false);
xhr.onerror = function () { return void cb("XHR_ERROR"); };
xhr.onload = function () {
// Error?
if (/^4/.test('' + this.status)) { return void cb("XHR_ERROR " + this.status); }
var arrayBuffer = xhr.response;
if (arrayBuffer) { cb(null, new Uint8Array(arrayBuffer)); }
};
xhr.send(null);
};
// Decryption tools
var Decrypt = {
// Create a nonce
createNonce: function () {
var n = new Uint8Array(24);
for (var i = 0; i < 24; i++) { n[i] = 0; }
return n;
},
// Increment a nonce
increment: function (N) {
var l = N.length;
while (l-- > 1) {
/* .jshint probably suspects this is unsafe because we lack types
but as long as this is only used on nonces, it should be safe */
if (N[l] !== 255) { return void N[l]++; } // jshint ignore:line
// you don't need to worry about this running out.
// you'd need a REAAAALLY big file
if (l === 0) { throw new Error('E_NONCE_TOO_LARGE'); }
N[l] = 0;
}
},
decodePrefix: function (A) {
return (A[0] << 8) | A[1];
},
joinChunks: function (chunks) {
return new Blob(chunks);
},
// Convert a Uint8Array into Array.
slice: function (u8) {
return Array.prototype.slice.call(u8);
},
// Gets the key from the key string.
getKeyFromStr: function (str) {
return window.nacl.util.decodeBase64(str);
}
};
// Decrypts a Uint8Array with the given key.
var decrypt = function (u8, strKey, done, progressCb) {
var Nacl = window.nacl;
var progress = function (offset) {
progressCb((offset / u8.length) * 100);
};
var key = Decrypt.getKeyFromStr(strKey);
var nonce = Decrypt.createNonce();
var i = 0;
var prefix = u8.subarray(0, 2);
var metadataLength = Decrypt.decodePrefix(prefix);
var res = { metadata: undefined };
// Get metadata
var metaBox = new Uint8Array(u8.subarray(2, 2 + metadataLength));
var metaChunk = Nacl.secretbox.open(metaBox, nonce, key);
Decrypt.increment(nonce);
try { res.metadata = JSON.parse(Nacl.util.encodeUTF8(metaChunk)); }
catch (e) { return void done('E_METADATA_DECRYPTION'); }
if (!res.metadata) { return void done('NO_METADATA'); }
var takeChunk = function (cb) {
setTimeout(function () {
var start = i * cypherChunkLength + 2 + metadataLength;
var end = start + cypherChunkLength;
i++;
// Get the chunk
var box = new Uint8Array(u8.subarray(start, end));
// Decrypt the chunk
var plaintext = Nacl.secretbox.open(box, nonce, key);
Decrypt.increment(nonce);
if (!plaintext) { return void cb('DECRYPTION_FAILURE'); }
progress(Math.min(end, u8.length));
cb(void 0, plaintext);
});
};
var chunks = [];
// decrypt file contents
var again = function () {
takeChunk(function (e, plaintext) {
if (e) { return setTimeout(function () { done(e); }); }
if (plaintext) {
7 years ago
if ((i * cypherChunkLength + 2 + metadataLength) < u8.length) { // not done
chunks.push(plaintext);
return again();
}
chunks.push(plaintext);
res.content = Decrypt.joinChunks(chunks);
return void done(void 0, res);
}
done('UNEXPECTED_ENDING');
});
};
again();
};
// Get type
var getType = function (mediaObject, metadata, cfg) {
var mime = metadata.type;
var s = metadata.type.split('/');
var type = s[0];
var extension = s[1];
mediaObject.name = metadata.name;
if (mime && cfg.allowed.indexOf(mime) !== -1) {
mediaObject.type = type;
mediaObject.extension = extension;
mediaObject.mime = mime;
return type;
} else if (cfg.allowed.indexOf('download') !== -1) {
mediaObject.type = type;
mediaObject.extension = extension;
mediaObject.mime = mime;
return 'download';
} else {
return;
}
};
// Copy attributes
var copyAttributes = function (origin, dest) {
Object.keys(origin.attributes).forEach(function (i) {
if (!/^data-attr/.test(origin.attributes[i].name)) { return; }
var name = origin.attributes[i].name.slice(10);
var value = origin.attributes[i].value;
dest.setAttribute(name, value);
});
};
// Process
var process = function (mediaObject, decrypted, cfg, cb) {
var metadata = decrypted.metadata;
var blob = decrypted.content;
var mediaType = getType(mediaObject, metadata, cfg);
if (isplainTextFile(metadata)) {
mediaType = "text";
}
if (mediaType === 'application') {
mediaType = mediaObject.extension;
}
if (!mediaType || !cfg.Plugins[mediaType]) {
return void cb('NO_PLUGIN_FOUND');
}
// Get blob URL
var url = decrypted.url;
if (!url && window.URL) {
url = decrypted.url = window.URL.createObjectURL(new Blob([blob], {
type: metadata.type
}));
}
cfg.Plugins[mediaType](metadata, url, blob, cfg, function (err, el) {
if (err || !el) { return void cb(err || 'ERR_MEDIATAG_DISPLAY'); }
copyAttributes(mediaObject.tag, el);
mediaObject.tag.innerHTML = '';
mediaObject.tag.appendChild(el);
cb();
});
};
var addMissingConfig = function (base, target) {
Object.keys(target).forEach(function (k) {
if (!target[k]) { return; }
// Target is an object, fix it recursively
if (typeof target[k] === "object" && !Array.isArray(target[k])) {
// Sub-object
if (base[k] && (typeof base[k] !== "object" || Array.isArray(base[k]))) { return; }
else if (base[k]) { addMissingConfig(base[k], target[k]); }
else {
base[k] = {};
addMissingConfig(base[k], target[k]);
}
}
// Target is array or immutable, copy the value if it's missing
if (!base[k]) {
base[k] = Array.isArray(target[k]) ? JSON.parse(JSON.stringify(target[k]))
: target[k];
}
});
};
// Initialize a media-tag
var init = function (el, cfg) {
cfg = cfg || {};
addMissingConfig(cfg, config);
// Handle jQuery elements
if (typeof(el) === "object" && el.jQuery) { el = el[0]; }
// Abort smoothly if the element is not a media-tag
if (!el || el.nodeName !== "MEDIA-TAG") {
console.error("Not a media-tag!");
return {
on: function () { return this; }
};
}
var handlers = cfg.handlers || {
'progress': [],
'complete': [],
'error': []
};
var mediaObject = el._mediaObject = {
handlers: handlers,
tag: el
};
var emit = function (ev, data) {
// Check if the event name is valid
if (Object.keys(handlers).indexOf(ev) === -1) {
return void console.error("Invalid mediatag event");
}
// Call the handlers
handlers[ev].forEach(function (h) {
// Make sure a bad handler won't break the media-tag script
try {
h(data);
} catch (err) {
console.error(err);
}
});
};
mediaObject.on = function (ev, handler) {
// Check if the event name is valid
if (Object.keys(handlers).indexOf(ev) === -1) {
console.error("Invalid mediatag event");
return mediaObject;
}
// Check if the handler is valid
if (typeof (handler) !== "function") {
console.error("Handler is not a function!");
return mediaObject;
}
// Add the handler
handlers[ev].push(handler);
return mediaObject;
};
var src = el.getAttribute('src');
var strKey = el.getAttribute('data-crypto-key');
if (/^cryptpad:/.test(strKey)) {
strKey = strKey.slice(9);
}
var uid = [src, strKey].join('');
// End media-tag rendering: display the tag and emit the event
var end = function (decrypted) {
mediaObject.complete = true;
process(mediaObject, decrypted, cfg, function (err) {
if (err) { return void emit('error', err); }
mediaObject._blob = decrypted;
emit('complete', decrypted);
});
};
// If we have the blob in our cache, don't download & decrypt it again, just display
// XXX Store in the cache the pending mediaobject: make sure we don't download and decrypt twice the same element at the same time
if (cache[uid]) {
end(cache[uid]);
return mediaObject;
}
var dl = function () {
// Download the encrypted blob
download(src, function (err, u8Encrypted) {
if (err) {
if (err === "XHR_ERROR 404") {
mediaObject.tag.innerHTML = '<img style="width: 100px; height: 100px;" src="/images/broken.png">';
}
return void emit('error', err);
}
// Decrypt the blob
decrypt(u8Encrypted, strKey, function (errDecryption, u8Decrypted) {
if (errDecryption) {
return void emit('error', errDecryption);
}
// Cache and display the decrypted blob
cache[uid] = u8Decrypted;
end(u8Decrypted);
}, function (progress) {
emit('progress', {
progress: 50+0.5*progress
});
});
}, function (progress) {
emit('progress', {
progress: 0.5*progress
});
});
};
if (cfg.force) { dl(); return mediaObject; }
var maxSize = 5 * 1024 * 1024;
getFileSize(src, function (err, size) {
if (err) {
if (err === "XHR_ERROR 404") {
mediaObject.tag.innerHTML = '<img style="width: 100px; height: 100px;" src="/images/broken.png">';
}
return void emit('error', err);
}
if (!size || size < maxSize) { return void dl(); }
var sizeMb = Math.round(10 * size / 1024 / 1024) / 10;
makeDownloadButton(cfg, mediaObject, sizeMb, dl);
});
return mediaObject;
};
// Add the cache as a property of MediaTag
cache = init.__Cryptpad_Cache = {};
init.setDefaultConfig = function (key, value) {
config[key] = value;
};
return init;
}));