diff --git a/www/code2/inner.js b/www/code2/inner.js index aa2a50624..34df7a526 100644 --- a/www/code2/inner.js +++ b/www/code2/inner.js @@ -487,10 +487,9 @@ define([ } //UserList.getLastName(toolbar.$userNameButton, isNew); - /* TODO RPC var fmConfig = { - dropArea: $iframe.find('.CodeMirror'), - body: $iframe.find('body'), + dropArea: $('.CodeMirror'), + body: $('body'), onUploaded: function (ev, data) { //var cursor = editor.getCursor(); //var cleanName = data.name.replace(/[\[\]]/g, ''); @@ -502,8 +501,7 @@ define([ editor.replaceSelection(mt); } }; - APP.FM = Cryptpad.createFileManager(fmConfig); - */ + APP.FM = common.createFileManager(fmConfig); }; config.onRemote = function () { @@ -619,15 +617,12 @@ define([ nThen(function (waitFor) { cmEditorAvailable(waitFor(function (cm) { CM = cm; - console.log('cm'); })); $(waitFor(function () { Cryptpad.addLoadingScreen(); })); - SFCommon.create(waitFor(function (c) { console.log('common'); APP.common = common = c; })); - console.log('nThen1'); + SFCommon.create(waitFor(function (c) { APP.common = common = c; })); }).nThen(function (/*waitFor*/) { - console.log('nThen2'); CodeMirror = Cryptpad.createCodemirror(window, Cryptpad, null, CM); $('.CodeMirror').addClass('fullPage'); editor = CodeMirror.editor; diff --git a/www/common/common-file.js b/www/common/common-file.js index 8dcff2081..f22ebdf34 100644 --- a/www/common/common-file.js +++ b/www/common/common-file.js @@ -23,6 +23,85 @@ define([ } }; + module.upload = function (file, noStore, common, updateProgress, onComplete, onError, onPending) { + var u8 = file.blob; // This is not a blob but a uint8array + var metadata = file.metadata; + + var key = Nacl.randomBytes(32); + var next = FileCrypto.encrypt(u8, metadata, key); + + var estimate = FileCrypto.computeEncryptedSize(u8.length, metadata); + + var sendChunk = function (box, cb) { + var enc = Nacl.util.encodeBase64(box); + common.rpc.send.unauthenticated('UPLOAD', enc, function (e, msg) { + cb(e, msg); + }); + }; + + var actual = 0; + var again = function (err, box) { + if (err) { throw new Error(err); } + if (box) { + actual += box.length; + var progressValue = (actual / estimate * 100); + updateProgress(progressValue); + + return void sendChunk(box, function (e) { + if (e) { return console.error(e); } + next(again); + }); + } + + if (actual !== estimate) { + console.error('Estimated size does not match actual size'); + } + + // if not box then done + common.uploadComplete(function (e, id) { + if (e) { return void console.error(e); } + var uri = ['', 'blob', id.slice(0,2), id].join('/'); + console.log("encrypted blob is now available as %s", uri); + + var b64Key = Nacl.util.encodeBase64(key); + + var hash = common.getFileHashFromKeys(id, b64Key); + var href = '/file/#' + hash; + + var title = metadata.name; + + if (noStore) { return void onComplete(href); } + + common.renamePad(title || "", href, function (err) { + if (err) { return void console.error(err); } + onComplete(href); + }); + }); + }; + + common.uploadStatus(estimate, function (e, pending) { + if (e) { + console.error(e); + onError(e); + return; + } + + if (pending) { + return void onPending(function () { + // if the user wants to cancel the pending upload to execute that one + common.uploadCancel(function (e, res) { + if (e) { + return void console.error(e); + } + console.log(res); + next(again); + }); + }); + } + next(again); + }); + }; + module.create = function (common, config) { var File = {}; @@ -62,7 +141,8 @@ define([ }; var upload = function (file) { - var blob = file.blob; + var blob = file.blob; // This is not a blob but an array buffer + var u8 = new Uint8Array(blob); var metadata = file.metadata; var id = file.id; if (queue.inProgress) { return; } @@ -83,112 +163,49 @@ define([ }); }; - var u8 = new Uint8Array(blob); - - var key = Nacl.randomBytes(32); - var next = FileCrypto.encrypt(u8, metadata, key); + var onComplete = function (href) { + $link.attr('href', href) + .click(function (e) { + e.preventDefault(); + window.open($link.attr('href'), '_blank'); + }); + var title = metadata.name; + common.log(Messages._getKey('upload_success', [title])); + common.prepareFeedback('upload')(); - var estimate = FileCrypto.computeEncryptedSize(blob.byteLength, metadata); + if (config.onUploaded) { + var data = getData(file, href); + config.onUploaded(file.dropEvent, data); + } - var sendChunk = function (box, cb) { - var enc = Nacl.util.encodeBase64(box); - common.rpc.send.unauthenticated('UPLOAD', enc, function (e, msg) { - console.log(box); - cb(e, msg); - }); + queue.inProgress = false; + queue.next(); }; - var actual = 0; - var again = function (err, box) { - if (err) { throw new Error(err); } - if (box) { - actual += box.length; - var progressValue = (actual / estimate * 100); - updateProgress(progressValue); - - return void sendChunk(box, function (e) { - if (e) { return console.error(e); } - next(again); - }); + var onError = function (e) { + queue.inProgress = false; + queue.next(); + if (e === 'TOO_LARGE') { + // TODO update table to say too big? + return void common.alert(Messages.upload_tooLarge); } - - if (actual !== estimate) { - console.error('Estimated size does not match actual size'); + if (e === 'NOT_ENOUGH_SPACE') { + // TODO update table to say not enough space? + return void common.alert(Messages.upload_notEnoughSpace); } + console.error(e); + return void common.alert(Messages.upload_serverError); + }; - // if not box then done - common.uploadComplete(function (e, id) { - if (e) { return void console.error(e); } - var uri = ['', 'blob', id.slice(0,2), id].join('/'); - console.log("encrypted blob is now available as %s", uri); - - var b64Key = Nacl.util.encodeBase64(key); - - var hash = common.getFileHashFromKeys(id, b64Key); - var href = '/file/#' + hash; - $link.attr('href', href) - .click(function (e) { - e.preventDefault(); - window.open($link.attr('href'), '_blank'); - }); - - var title = metadata.name; - - var onComplete = function () { - common.log(Messages._getKey('upload_success', [title])); - common.prepareFeedback('upload')(); - - if (config.onUploaded) { - var data = getData(file, href); - config.onUploaded(file.dropEvent, data); - } - - queue.inProgress = false; - queue.next(); - }; - - if (config.noStore) { return void onComplete(); } - - common.renamePad(title || "", href, function (err) { - if (err) { return void console.error(err); } // TODO - onComplete(); - }); - //Title.updateTitle(title || "", href); - //APP.toolbar.title.show(); + var onPending = function (cb) { + common.confirm(Messages.upload_uploadPending, function (yes) { + if (!yes) { return; } + cb(); }); }; - common.uploadStatus(estimate, function (e, pending) { - if (e) { - queue.inProgress = false; - queue.next(); - if (e === 'TOO_LARGE') { - // TODO update table to say too big? - return void common.alert(Messages.upload_tooLarge); - } - if (e === 'NOT_ENOUGH_SPACE') { - // TODO update table to say not enough space? - return void common.alert(Messages.upload_notEnoughSpace); - } - console.error(e); - return void common.alert(Messages.upload_serverError); - } - - if (pending) { - // TODO keep this message in case of pending files in another window? - return void common.confirm(Messages.upload_uploadPending, function (yes) { - if (!yes) { return; } - common.uploadCancel(function (e, res) { - if (e) { - return void console.error(e); - } - console.log(res); - next(again); - }); - }); - } - next(again); - }); + file.blob = u8; + module.upload(file, config.noStore, common, updateProgress, onComplete, onError, onPending); }; var prettySize = function (bytes) { diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index c71fdd715..6f1ccaf27 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -723,7 +723,7 @@ define([ var proxy = store.getProxy(); var fo = proxy.fo; var hashes = []; - var list = fo.getFiles().filter(function (id) { + var list = fo.getFiles([fo.ROOT]).filter(function (id) { var href = fo.getFileData(id).href; var parsed = parsePadUrl(href); if ((parsed.type === 'file' || parsed.type === 'media') @@ -734,6 +734,26 @@ define([ }); return list; }; + // Needed for the secure filepicker app + common.getSecureFilesList = function (cb) { + var store = common.getStore(); + if (!store) { return void cb("Store is not ready"); } + var proxy = store.getProxy(); + var fo = proxy.fo; + var list = {}; + var hashes = []; + fo.getFiles([fo.ROOT]).forEach(function (id) { + var data = fo.getFileData(id); + var parsed = parsePadUrl(data.href); + if ((parsed.type === 'file' || parsed.type === 'media') + && hashes.indexOf(parsed.hash) === -1) { + hashes.push(parsed.hash); + list[id] = data; + } + }); + console.log(list); + cb (null, list); + }; var getUserChannelList = common.getUserChannelList = function () { var store = common.getStore(); @@ -953,6 +973,9 @@ define([ rpc.uploadCancel(cb); }; + + common.uploadFileSecure = Files.upload; + /* Create a usage bar which keeps track of how much storage space is used by your CryptDrive. The getPinnedUsage RPC is one of the heavier calls, so we throttle its usage. Clients will not update more than once per @@ -1414,7 +1437,7 @@ define([ }; // This is duplicated in drive/main.js, it should be unified - var getFileIcon = function (data) { + var getFileIcon = common.getFileIcon = function (data) { var $icon = common.getIcon(); if (!data) { return $icon; } diff --git a/www/common/sframe-common-file.js b/www/common/sframe-common-file.js new file mode 100644 index 000000000..11d262d24 --- /dev/null +++ b/www/common/sframe-common-file.js @@ -0,0 +1,295 @@ +define([ + 'jquery', + '/file/file-crypto.js', + '/bower_components/tweetnacl/nacl-fast.min.js', +], function ($, FileCrypto) { + var Nacl = window.nacl; + var module = {}; + + var blobToArrayBuffer = function (blob, cb) { + var reader = new FileReader(); + reader.onloadend = function () { + cb(void 0, this.result); + }; + reader.readAsArrayBuffer(blob); + }; + + var arrayBufferToString = function (AB) { + try { + return Nacl.util.encodeBase64(new Uint8Array(AB)); + } catch (e) { + console.error(e); + return null; + } + }; + + module.create = function (common, config) { + var File = {}; + var Cryptpad = common.getCryptpadCommon(); + + var Messages = Cryptpad.Messages; + + var queue = File.queue = { + queue: [], + inProgress: false + }; + + var uid = function () { + return 'file-' + String(Math.random()).substring(2); + }; + + var $table = File.$table = $('', { id: 'uploadStatus' }); + var $thead = $('').appendTo($table); + $('', {id: id}).appendTo($table); + + var $cancel = $('', {'class': 'cancel fa fa-times'}).click(function () { + queue.queue = queue.queue.filter(function (el) { return el.id !== id; }); + $cancel.remove(); + $tr.find('.upCancel').text('-'); + $tr.find('.progressValue').text(Messages.upload_cancelled); + }); + + var $link = $('', { + 'class': 'upLink', + 'rel': 'noopener noreferrer' + }).text(obj.metadata.name); + + $('
').text(Messages.upload_name).appendTo($thead); + $('').text(Messages.upload_size).appendTo($thead); + $('').text(Messages.upload_progress).appendTo($thead); + $('').text(Messages.cancel).appendTo($thead); + + var createTableContainer = function ($body) { + File.$container = $('
', { id: 'uploadStatusContainer' }).append($table).appendTo($body); + return File.$container; + }; + + var getData = function (file, href) { + var data = {}; + + data.name = file.metadata.name; + data.url = href; + if (file.metadata.type.slice(0,6) === 'image/') { + data.mediatag = true; + } + + return data; + }; + + var upload = function (file) { + var blob = file.blob; // This is not a blob but an array buffer + var u8 = new Uint8Array(blob); + var metadata = file.metadata; + var id = file.id; + var dropEvent = file.dropEvent; + delete file.dropEvent; + if (queue.inProgress) { return; } + queue.inProgress = true; + + var $row = $table.find('tr[id="'+id+'"]'); + + $row.find('.upCancel').html('-'); + var $pv = $row.find('.progressValue'); + var $pb = $row.find('.progressContainer'); + var $pc = $row.find('.upProgress'); + var $link = $row.find('.upLink'); + + var sframeChan = common.getSframeChannel(); + + var updateProgress = function (progressValue) { + $pv.text(Math.round(progressValue*100)/100 + '%'); + $pb.css({ + width: (progressValue/100)*$pc.width()+'px' + }); + }; + + var onComplete = function (href) { + $link.attr('href', href) + .click(function (e) { + e.preventDefault(); + window.open($link.attr('href'), '_blank'); + }); + var title = metadata.name; + Cryptpad.log(Messages._getKey('upload_success', [title])); + common.prepareFeedback('upload')(); + + if (config.onUploaded) { + var data = getData(file, href); + config.onUploaded(dropEvent, data); + } + + queue.inProgress = false; + queue.next(); + }; + + var onError = function (e) { + queue.inProgress = false; + queue.next(); + if (e === 'TOO_LARGE') { + // TODO update table to say too big? + return void Cryptpad.alert(Messages.upload_tooLarge); + } + if (e === 'NOT_ENOUGH_SPACE') { + // TODO update table to say not enough space? + return void Cryptpad.alert(Messages.upload_notEnoughSpace); + } + console.error(e); + return void Cryptpad.alert(Messages.upload_serverError); + }; + + var onPending = function (cb) { + Cryptpad.confirm(Messages.upload_uploadPending, cb); + }; + + sframeChan.on('EV_FILE_UPLOAD_STATE', function (data) { + if (data.error) { + return void onError(data.error); + } + if (data.complete && data.href) { + return void onComplete(data.href); + } + if (typeof data.progress !== "undefined") { + return void updateProgress(data.progress); + } + }); + sframeChan.on('Q_CANCEL_PENDING_FILE_UPLOAD', function (data, cb) { + onPending(cb); + }); + file.noStore = config.noStore; + try { + file.blob = Nacl.util.encodeBase64(u8); + common.uploadFile(file, function () { + console.log('Upload started...'); + }); + } catch (e) { + Cryptpad.alert(Messages.upload_serverError); + } + }; + + var prettySize = function (bytes) { + var kB = Cryptpad.bytesToKilobytes(bytes); + if (kB < 1024) { return kB + Messages.KB; } + var mB = Cryptpad.bytesToMegabytes(bytes); + return mB + Messages.MB; + }; + + queue.next = function () { + if (queue.queue.length === 0) { + queue.to = window.setTimeout(function () { + if (config.keepTable) { return; } + File.$container.fadeOut(); + }, 3000); + return; + } + if (queue.inProgress) { return; } + File.$container.show(); + var file = queue.queue.shift(); + upload(file); + }; + queue.push = function (obj) { + var id = uid(); + obj.id = id; + queue.queue.push(obj); + + $table.show(); + var estimate = FileCrypto.computeEncryptedSize(obj.blob.byteLength, obj.metadata); + + var $progressBar = $('
', {'class':'progressContainer'}); + var $progressValue = $('', {'class':'progressValue'}).text(Messages.upload_pending); + + var $tr = $('
').append($link).appendTo($tr); + $('').text(prettySize(estimate)).appendTo($tr); + $('', {'class': 'upProgress'}).append($progressBar).append($progressValue).appendTo($tr); + $('', {'class': 'upCancel'}).append($cancel).appendTo($tr); + + queue.next(); + }; + + var handleFile = File.handleFile = function (file, e, thumbnail) { + var thumb; + var finish = function (arrayBuffer) { + var metadata = { + name: file.name, + type: file.type, + }; + if (thumb) { metadata.thumbnail = thumb; } + queue.push({ + blob: arrayBuffer, + metadata: metadata, + dropEvent: e + }); + }; + + var processFile = function () { + blobToArrayBuffer(file, function (e, buffer) { + finish(buffer); + }); + }; + + if (!thumbnail) { return void processFile(); } + blobToArrayBuffer(thumbnail, function (e, buffer) { + if (e) { console.error(e); } + thumb = arrayBufferToString(buffer); + processFile(); + }); + }; + + var onFileDrop = File.onFileDrop = function (file, e) { + if (!common.isLoggedIn()) { + return Cryptpad.alert(common.Messages.upload_mustLogin); + } + + Array.prototype.slice.call(file).forEach(function (d) { + handleFile(d, e); + }); + }; + + var createAreaHandlers = File.createDropArea = function ($area, $hoverArea) { + var counter = 0; + if (!$hoverArea) { $hoverArea = $area; } + if (!$area) { return; } + $hoverArea + .on('dragenter', function (e) { + e.preventDefault(); + e.stopPropagation(); + counter++; + $hoverArea.addClass('hovering'); + }) + .on('dragleave', function (e) { + e.preventDefault(); + e.stopPropagation(); + counter--; + if (counter <= 0) { + $hoverArea.removeClass('hovering'); + } + }); + + $area + .on('drag dragstart dragend dragover drop dragenter dragleave', function (e) { + e.preventDefault(); + e.stopPropagation(); + }) + .on('drop', function (e) { + e.stopPropagation(); + + var dropped = e.originalEvent.dataTransfer.files; + counter = 0; + $hoverArea.removeClass('hovering'); + onFileDrop(dropped, e); + }); + }; + + var createUploader = function ($area, $hover, $body) { + if (!config.noHandlers) { + createAreaHandlers($area, null); + } + createTableContainer($body); + }; + + createUploader(config.dropArea, config.hoverArea, config.body); + + return File; + }; + + return module; +}); diff --git a/www/common/sframe-common-interface.js b/www/common/sframe-common-interface.js index f0f3a7da0..57fb3c6d1 100644 --- a/www/common/sframe-common-interface.js +++ b/www/common/sframe-common-interface.js @@ -260,6 +260,60 @@ define([ return $userAdmin; }; - + + UI.createFileDialog = function (cfg) { + var common = cfg.common; + var $blockContainer = Cryptpad.createModal({ + id: 'fileDialog', + $body: cfg.$body + }); + var $block = $blockContainer.find('.cp-modal'); + var $description = $('

').text(Messages.filePicker_description); + $block.append($description); + var $filter = $('

', {'class': 'cp-modal-form'}).appendTo($block); + var $container = $('', {'class': 'fileContainer'}).appendTo($block); + var updateContainer = function () { + $container.html(''); + var filter = $filter.find('.filter').val().trim(); + var todo = function (err, list) { + if (err) { return void console.error(err); } + Object.keys(list).forEach(function (id) { + var data = list[id]; + var name = data.title || '?'; + if (filter && name.toLowerCase().indexOf(filter.toLowerCase()) === -1) { + return; + } + var $span = $('', { + 'class': 'element', + 'title': name, + }).appendTo($container); + $span.append(Cryptpad.getFileIcon(data)); + $span.append(name); + $span.click(function () { + if (typeof cfg.onSelect === "function") { cfg.onSelect(data.href); } + $blockContainer.hide(); + }); + }); + }; + common.getFilesList(todo); + }; + var to; + $('', { + type: 'text', + 'class': 'filter', + 'placeholder': Messages.filePicker_filter + }).appendTo($filter).on('keypress', function () { + if (to) { window.clearTimeout(to); } + to = window.setTimeout(updateContainer, 300); + }); + //$filter.append(' '+Messages.or+' '); + /*var data = {FM: cfg.data.FM}; + $filter.append(common.createButton('upload', false, data, function () { + $blockContainer.hide(); + }));*/ + updateContainer(); + $blockContainer.show(); + }; + return UI; }); diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index a355734de..c819b3c30 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -215,6 +215,38 @@ define([ }); + sframeChan.on('Q_UPLOAD_FILE', function (data, cb) { + var sendEvent = function (data) { + sframeChan.event("EV_FILE_UPLOAD_STATE", data); + }; + var updateProgress = function (progressValue) { + sendEvent({ + progress: progressValue + }); + }; + var onComplete = function (href) { + sendEvent({ + complete: true, + href: href + }); + }; + var onError = function (e) { + sendEvent({ + error: e + }); + }; + var onPending = function (cb) { + sframeChan.query('Q_CANCEL_PENDING_FILE_UPLOAD', null, function (err, data) { + if (data) { + cb(); + } + }); + }; + data.blob = Crypto.Nacl.util.decodeBase64(data.blob); + Cryptpad.uploadFileSecure(data, data.noStore, Cryptpad, updateProgress, onComplete, onError, onPending); + cb(); + }); + CpNfOuter.start({ sframeChan: sframeChan, channel: secret.channel, diff --git a/www/common/sframe-common.js b/www/common/sframe-common.js index 6beb1e83d..83a0bd805 100644 --- a/www/common/sframe-common.js +++ b/www/common/sframe-common.js @@ -7,12 +7,13 @@ define([ '/common/sframe-common-title.js', '/common/sframe-common-interface.js', '/common/sframe-common-history.js', + '/common/sframe-common-file.js', '/common/metadata-manager.js', '/customize/application_config.js', '/common/cryptpad-common.js', '/common/common-realtime.js' -], function ($, nThen, Messages, CpNfInner, SFrameChannel, Title, UI, History, MetadataMgr, +], function ($, nThen, Messages, CpNfInner, SFrameChannel, Title, UI, History, File, MetadataMgr, AppConfig, Cryptpad, CommonRealtime) { // Chainpad Netflux Inner @@ -36,6 +37,9 @@ define([ funcs.getCryptpadCommon = function () { return Cryptpad; }; + funcs.getSframeChannel = function () { + return ctx.sframeChan; + }; var isLoggedIn = funcs.isLoggedIn = function () { if (!ctx.cpNfInner) { throw new Error("cpNfInner is not ready!"); } @@ -51,6 +55,7 @@ define([ // UI funcs.createUserAdminMenu = UI.createUserAdminMenu; funcs.displayAvatar = UI.displayAvatar; + funcs.createFileDialog = UI.createFileDialog; // History funcs.getHistory = function (config) { return History.create(funcs, config); }; @@ -118,10 +123,14 @@ define([ ctx.sframeChan.query('Q_SET_PAD_ATTRIBUTE', { key: key, value: value - }, function (err, data) { - cb(); - }); + }, cb); + }; + + // Files + funcs.uploadFile = function (data, cb) { + ctx.sframeChan.query('Q_UPLOAD_FILE', data, cb); }; + funcs.createFileManager = function (config) { return File.create(funcs, config); }; // Friends var pendingFriends = []; @@ -149,7 +158,7 @@ define([ url: href, }); }; - var prepareFeedback = function (key) { + var prepareFeedback = funcs.prepareFeedback = function (key) { if (typeof(key) !== 'string') { return $.noop; } var type = ctx.metadataMgr.getMetadata().type; @@ -304,6 +313,14 @@ define([ return button; }; + + // Can, only be called by the filepicker app + funcs.getFilesList = function (cb) { + ctx.sframeChan.query('Q_GET_FILES_LIST', null, function (err, data) { + cb(err || data.error, data.data); + }); + }; + /* funcs.storeLinkToClipboard = function (readOnly, cb) { ctx.sframeChan.query('Q_STORE_LINK_TO_CLIPBOARD', readOnly, function (err) { if (cb) { cb(err); } diff --git a/www/common/sframe-protocol.js b/www/common/sframe-protocol.js index 1db4c01a8..8f3c81d43 100644 --- a/www/common/sframe-protocol.js +++ b/www/common/sframe-protocol.js @@ -88,4 +88,12 @@ define({ // Get and set pad attributes stored in the drive from the inner iframe 'Q_GET_PAD_ATTRIBUTE': true, 'Q_SET_PAD_ATTRIBUTE': true, + + // Get all the files from the drive to display them in a file picker secure app + 'Q_GET_FILES_LIST': true, + + // File upload queries and events + 'Q_UPLOAD_FILE': true, + 'EV_FILE_UPLOAD_STATE': true, + 'Q_CANCEL_PENDING_FILE_UPLOAD': true, }); diff --git a/www/common/userObject.js b/www/common/userObject.js index 6eab74bc3..bb9094ea5 100644 --- a/www/common/userObject.js +++ b/www/common/userObject.js @@ -19,6 +19,11 @@ define([ var NEW_FOLDER_NAME = Messages.fm_newFolder; var NEW_FILE_NAME = Messages.fm_newFile; + exp.ROOT = ROOT; + exp.UNSORTED = UNSORTED; + exp.TRASH = TRASH; + exp.TEMPLATE = TEMPLATE; + // Logging var logging = function () { console.log.apply(console, arguments); diff --git a/www/filepicker/index.html b/www/filepicker/index.html new file mode 100644 index 000000000..8be90cfb5 --- /dev/null +++ b/www/filepicker/index.html @@ -0,0 +1,30 @@ + + + + CryptPad + + + + + + + +