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

595 lines
20 KiB
JavaScript

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

(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) {
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 = typeof(config.maxDownloadSize) === "number" ? config.maxDownloadSize
: (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;
}));