');
if($attachment.length) {
$attachmentPreview = $('#attachmentPreview');
$fileInput = $('#file');
addDragDropHandler();
addClipboardEventHandler();
}
}
return me;
})();
/**
* (view) Shows discussion thread and handles replies
*
* @name DiscussionViewer
* @class
*/
const DiscussionViewer = (function () {
const me = {};
let $commentTail,
$discussion,
$reply,
$replyMessage,
$replyNickname,
$replyStatus,
$commentContainer,
replyCommentId;
/**
* initializes the templates
*
* @name DiscussionViewer.initTemplates
* @private
* @function
*/
function initTemplates()
{
$reply = Model.getTemplate('reply');
$replyMessage = $reply.find('#replymessage');
$replyNickname = $reply.find('#nickname');
$replyStatus = $reply.find('#replystatus');
// cache jQuery elements
$commentTail = Model.getTemplate('commenttail');
}
/**
* open the comment entry when clicking the "Reply" button of a comment
*
* @name DiscussionViewer.openReply
* @private
* @function
* @param {Event} event
*/
function openReply(event)
{
const $source = $(event.target);
// clear input
$replyMessage.val('');
$replyNickname.val('');
// get comment id from source element
replyCommentId = $source.parent().prop('id').split('_')[1];
// move to correct position
$source.after($reply);
// show
$reply.removeClass('hidden');
$replyMessage.focus();
event.preventDefault();
}
/**
* custom handler for displaying notifications in own status message area
*
* @name DiscussionViewer.handleNotification
* @function
* @param {string} alertType
* @return {bool|jQuery}
*/
me.handleNotification = function(alertType)
{
// ignore loading messages
if (alertType === 'loading') {
return false;
}
if (alertType === 'danger') {
$replyStatus.removeClass('alert-info');
$replyStatus.addClass('alert-danger');
$replyStatus.find(':first').removeClass('glyphicon-alert');
$replyStatus.find(':first').addClass('glyphicon-info-sign');
} else {
$replyStatus.removeClass('alert-danger');
$replyStatus.addClass('alert-info');
$replyStatus.find(':first').removeClass('glyphicon-info-sign');
$replyStatus.find(':first').addClass('glyphicon-alert');
}
return $replyStatus;
};
/**
* adds another comment
*
* @name DiscussionViewer.addComment
* @function
* @param {Comment} comment
* @param {string} commentText
* @param {string} nickname
*/
me.addComment = function(comment, commentText, nickname)
{
if (!commentText) {
commentText = 'comment decryption failed';
}
// create new comment based on template
const $commentEntry = Model.getTemplate('comment');
$commentEntry.prop('id', 'comment_' + comment.id);
const $commentEntryData = $commentEntry.find('div.commentdata');
// set & parse text
commentText = Helper.preformatTextForDomPurify(commentText, 'plaintext');
$commentEntryData.html(
DOMPurify.sanitize(
Helper.urls2links(commentText), {
ALLOWED_TAGS: ['a'],
ALLOWED_ATTR: ['href', 'rel']
}
)
);
// set nickname
if (nickname.length > 0) {
$commentEntry.find('span.nickname').text(nickname);
} else {
$commentEntry.find('span.nickname').html('');
I18n._($commentEntry.find('span.nickname i'), 'Anonymous');
}
// set date
$commentEntry.find('span.commentdate')
.text(' (' + (new Date(comment.getCreated() * 1000).toLocaleString()) + ')')
.attr('title', 'CommentID: ' + comment.id);
// if an avatar is available, display it
const icon = comment.getIcon();
if (icon) {
$commentEntry.find('span.nickname')
.before(
' '
);
$(document).on('languageLoaded', function () {
$commentEntry.find('img.vizhash')
.prop('title', I18n._('Avatar generated from IP address'));
});
}
// starting point (default value/fallback)
let $place = $commentContainer;
// if parent comment exists
const $parentComment = $('#comment_' + comment.parentid);
if ($parentComment.length) {
// use parent as position for new comment, so it is shifted
// to the right
$place = $parentComment;
}
// finally append comment
$place.append($commentEntry);
};
/**
* finishes the discussion area after last comment
*
* @name DiscussionViewer.finishDiscussion
* @function
*/
me.finishDiscussion = function()
{
// add 'add new comment' area
$commentContainer.append($commentTail);
// show discussions
$discussion.removeClass('hidden');
};
/**
* removes the old discussion and prepares everything for creating a new
* one.
*
* @name DiscussionViewer.prepareNewDiscussion
* @function
*/
me.prepareNewDiscussion = function()
{
$commentContainer.html('');
$discussion.addClass('hidden');
// (re-)init templates
initTemplates();
};
/**
* returns the users message from the reply form
*
* @name DiscussionViewer.getReplyMessage
* @function
* @return {String}
*/
me.getReplyMessage = function()
{
return $replyMessage.val();
};
/**
* returns the users nickname (if any) from the reply form
*
* @name DiscussionViewer.getReplyNickname
* @function
* @return {String}
*/
me.getReplyNickname = function()
{
return $replyNickname.val();
};
/**
* returns the id of the parent comment the user is replying to
*
* @name DiscussionViewer.getReplyCommentId
* @function
* @return {int|undefined}
*/
me.getReplyCommentId = function()
{
return replyCommentId;
};
/**
* highlights a specific comment and scrolls to it if necessary
*
* @name DiscussionViewer.highlightComment
* @function
* @param {string} commentId
* @param {bool} fadeOut - whether to fade out the comment
*/
me.highlightComment = function(commentId, fadeOut)
{
const $comment = $('#comment_' + commentId);
// in case comment does not exist, cancel
if ($comment.length === 0) {
return;
}
$comment.addClass('highlight');
const highlightComment = function () {
if (fadeOut === true) {
setTimeout(function () {
$comment.removeClass('highlight');
}, 300);
}
};
if (UiHelper.isVisible($comment)) {
return highlightComment();
}
UiHelper.scrollTo($comment, 100, 'swing', highlightComment);
};
/**
* initiate
*
* preloads jQuery elements
*
* @name DiscussionViewer.init
* @function
*/
me.init = function()
{
// bind events to templates (so they are later cloned)
$('#commenttailtemplate, #commenttemplate').find('button').on('click', openReply);
$('#replytemplate').find('button').on('click', PasteEncrypter.sendComment);
$commentContainer = $('#commentcontainer');
$discussion = $('#discussion');
};
return me;
})();
/**
* Manage top (navigation) bar
*
* @name TopNav
* @param {object} window
* @param {object} document
* @class
*/
const TopNav = (function (window, document) {
const me = {};
let createButtonsDisplayed = false,
viewButtonsDisplayed = false,
$attach,
$burnAfterReading,
$burnAfterReadingOption,
$cloneButton,
$customAttachment,
$expiration,
$fileRemoveButton,
$fileWrap,
$formatter,
$newButton,
$openDiscussion,
$openDiscussionOption,
$password,
$passwordInput,
$rawTextButton,
$qrCodeLink,
$emailLink,
$sendButton,
$retryButton,
pasteExpiration = null,
retryButtonCallback;
/**
* set the expiration on bootstrap templates in dropdown
*
* @name TopNav.updateExpiration
* @private
* @function
* @param {Event} event
*/
function updateExpiration(event)
{
// get selected option
const target = $(event.target);
// update dropdown display and save new expiration time
$('#pasteExpirationDisplay').text(target.text());
pasteExpiration = target.data('expiration');
event.preventDefault();
}
/**
* set the format on bootstrap templates in dropdown from user interaction
*
* @name TopNav.updateFormat
* @private
* @function
* @param {Event} event
*/
function updateFormat(event)
{
// get selected option
const $target = $(event.target);
// update dropdown display and save new format
const newFormat = $target.data('format');
$('#pasteFormatterDisplay').text($target.text());
PasteViewer.setFormat(newFormat);
// update preview
if (Editor.isPreview()) {
PasteViewer.run();
}
event.preventDefault();
}
/**
* when "burn after reading" is checked, disable discussion
*
* @name TopNav.changeBurnAfterReading
* @private
* @function
*/
function changeBurnAfterReading()
{
if ($burnAfterReading.is(':checked')) {
$openDiscussionOption.addClass('buttondisabled');
$openDiscussion.prop('checked', false);
// if button is actually disabled, force-enable it and uncheck other button
$burnAfterReadingOption.removeClass('buttondisabled');
} else {
$openDiscussionOption.removeClass('buttondisabled');
}
}
/**
* when discussion is checked, disable "burn after reading"
*
* @name TopNav.changeOpenDiscussion
* @private
* @function
*/
function changeOpenDiscussion()
{
if ($openDiscussion.is(':checked')) {
$burnAfterReadingOption.addClass('buttondisabled');
$burnAfterReading.prop('checked', false);
// if button is actually disabled, force-enable it and uncheck other button
$openDiscussionOption.removeClass('buttondisabled');
} else {
$burnAfterReadingOption.removeClass('buttondisabled');
}
}
/**
* return raw text
*
* @name TopNav.rawText
* @private
* @function
*/
function rawText()
{
TopNav.hideAllButtons();
Alert.showLoading('Showing raw text…', 'time');
let paste = PasteViewer.getText();
// push a new state to allow back navigation with browser back button
history.pushState(
{type: 'raw'},
document.title,
// recreate paste URL
Helper.baseUri() + '?' + Model.getPasteId() + '#' +
CryptTool.base58encode(Model.getPasteKey())
);
// we use text/html instead of text/plain to avoid a bug when
// reloading the raw text view (it reverts to type text/html)
const $head = $('head').children().not('noscript, script, link[type="text/css"]'),
newDoc = document.open('text/html', 'replace');
newDoc.write('');
for (let i = 0; i < $head.length; ++i) {
newDoc.write($head[i].outerHTML);
}
newDoc.write('' + DOMPurify.sanitize(Helper.htmlEntities(paste)) + '
');
newDoc.close();
}
/**
* saves the language in a cookie and reloads the page
*
* @name TopNav.setLanguage
* @private
* @function
* @param {Event} event
*/
function setLanguage(event)
{
document.cookie = 'lang=' + $(event.target).data('lang');
UiHelper.reloadHome();
}
/**
* hides all messages and creates a new paste
*
* @name TopNav.clickNewPaste
* @private
* @function
*/
function clickNewPaste()
{
Controller.hideStatusMessages();
Controller.newPaste();
}
/**
* retrys some callback registered before
*
* @name TopNav.clickRetryButton
* @private
* @function
* @param {Event} event
*/
function clickRetryButton(event)
{
retryButtonCallback(event);
}
/**
* removes the existing attachment
*
* @name TopNav.removeAttachment
* @private
* @function
* @param {Event} event
*/
function removeAttachment(event)
{
// if custom attachment is used, remove it first
if (!$customAttachment.hasClass('hidden')) {
AttachmentViewer.removeAttachment();
$customAttachment.addClass('hidden');
$fileWrap.removeClass('hidden');
}
// in any case, remove saved attachment data
AttachmentViewer.removeAttachmentData();
// hide UI for selected files
// our up-to-date jQuery can handle it :)
$fileWrap.find('input').val('');
AttachmentViewer.clearDragAndDrop();
// pevent '#' from appearing in the URL
event.preventDefault();
}
/**
* Shows the QR code of the current paste (URL).
*
* @name TopNav.displayQrCode
* @private
* @function
*/
function displayQrCode()
{
const qrCanvas = kjua({
render: 'canvas',
text: window.location.href
});
$('#qrcode-display').html(qrCanvas);
}
/**
* Template Email body.
*
* @name TopNav.templateEmailBody
* @private
* @param {string} expirationDateString
* @param {bool} isBurnafterreading
*/
function templateEmailBody(expirationDateString, isBurnafterreading)
{
const EOL = '\n';
const BULLET = ' - ';
let emailBody = '';
if (expirationDateString !== null || isBurnafterreading) {
emailBody += I18n._('Notice:');
emailBody += EOL;
if (expirationDateString !== null) {
emailBody += EOL;
emailBody += BULLET;
emailBody += I18n._(
'This link will expire after %s.',
expirationDateString
);
}
if (isBurnafterreading) {
emailBody += EOL;
emailBody += BULLET;
emailBody += I18n._(
'This link can only be accessed once, do not use back or refresh button in your browser.'
);
}
emailBody += EOL;
emailBody += EOL;
}
emailBody += I18n._('Link:');
emailBody += EOL;
emailBody += `${window.location.href}`;
return emailBody;
}
/**
* Trigger Email send.
*
* @name TopNav.triggerEmailSend
* @private
* @param {string} emailBody
*/
function triggerEmailSend(emailBody)
{
window.open(
`mailto:?body=${encodeURIComponent(emailBody)}`,
'_self',
'noopener, noreferrer'
);
}
/**
* Send Email with current paste (URL).
*
* @name TopNav.sendEmail
* @private
* @function
* @param {Date|null} expirationDate date of expiration
* @param {bool} isBurnafterreading whether it is burn after reading
*/
function sendEmail(expirationDate, isBurnafterreading)
{
const expirationDateRoundedToSecond = new Date(expirationDate);
// round down at least 30 seconds to make up for the delay of request
expirationDateRoundedToSecond.setUTCSeconds(
expirationDateRoundedToSecond.getUTCSeconds() - 30
);
expirationDateRoundedToSecond.setUTCSeconds(0);
const $emailconfirmmodal = $('#emailconfirmmodal');
if ($emailconfirmmodal.length > 0) {
if (expirationDate !== null) {
I18n._(
$emailconfirmmodal.find('#emailconfirm-display'),
'Recipient may become aware of your timezone, convert time to UTC?'
);
const $emailconfirmTimezoneCurrent = $emailconfirmmodal.find('#emailconfirm-timezone-current');
const $emailconfirmTimezoneUtc = $emailconfirmmodal.find('#emailconfirm-timezone-utc');
$emailconfirmTimezoneCurrent.off('click.sendEmailCurrentTimezone');
$emailconfirmTimezoneCurrent.on('click.sendEmailCurrentTimezone', () => {
const emailBody = templateEmailBody(expirationDateRoundedToSecond.toLocaleString(), isBurnafterreading);
$emailconfirmmodal.modal('hide');
triggerEmailSend(emailBody);
});
$emailconfirmTimezoneUtc.off('click.sendEmailUtcTimezone');
$emailconfirmTimezoneUtc.on('click.sendEmailUtcTimezone', () => {
const emailBody = templateEmailBody(expirationDateRoundedToSecond.toLocaleString(
undefined,
// we don't use Date.prototype.toUTCString() because we would like to avoid GMT
{ timeZone: 'UTC', dateStyle: 'long', timeStyle: 'long' }
), isBurnafterreading);
$emailconfirmmodal.modal('hide');
triggerEmailSend(emailBody);
});
$emailconfirmmodal.modal('show');
} else {
triggerEmailSend(templateEmailBody(null, isBurnafterreading));
}
} else {
let emailBody = '';
if (expirationDate !== null) {
const expirationDateString = window.confirm(
I18n._('Recipient may become aware of your timezone, convert time to UTC?')
) ? expirationDateRoundedToSecond.toLocaleString(
undefined,
// we don't use Date.prototype.toUTCString() because we would like to avoid GMT
{ timeZone: 'UTC', dateStyle: 'long', timeStyle: 'long' }
) : expirationDateRoundedToSecond.toLocaleString();
emailBody = templateEmailBody(expirationDateString, isBurnafterreading);
} else {
emailBody = templateEmailBody(null, isBurnafterreading);
}
triggerEmailSend(emailBody);
}
}
/**
* Shows all navigation elements for viewing an existing paste
*
* @name TopNav.showViewButtons
* @function
*/
me.showViewButtons = function()
{
if (viewButtonsDisplayed) {
return;
}
$newButton.removeClass('hidden');
$cloneButton.removeClass('hidden');
$rawTextButton.removeClass('hidden');
$qrCodeLink.removeClass('hidden');
viewButtonsDisplayed = true;
};
/**
* Hides all navigation elements for viewing an existing paste
*
* @name TopNav.hideViewButtons
* @function
*/
me.hideViewButtons = function()
{
if (!viewButtonsDisplayed) {
return;
}
$cloneButton.addClass('hidden');
$newButton.addClass('hidden');
$rawTextButton.addClass('hidden');
$qrCodeLink.addClass('hidden');
me.hideEmailButton();
viewButtonsDisplayed = false;
};
/**
* Hides all elements belonging to existing pastes
*
* @name TopNav.hideAllButtons
* @function
*/
me.hideAllButtons = function()
{
me.hideViewButtons();
me.hideCreateButtons();
};
/**
* shows all elements needed when creating a new paste
*
* @name TopNav.showCreateButtons
* @function
*/
me.showCreateButtons = function()
{
if (createButtonsDisplayed) {
return;
}
$attach.removeClass('hidden');
$burnAfterReadingOption.removeClass('hidden');
$expiration.removeClass('hidden');
$formatter.removeClass('hidden');
$newButton.removeClass('hidden');
$openDiscussionOption.removeClass('hidden');
$password.removeClass('hidden');
$sendButton.removeClass('hidden');
createButtonsDisplayed = true;
};
/**
* shows all elements needed when creating a new paste
*
* @name TopNav.hideCreateButtons
* @function
*/
me.hideCreateButtons = function()
{
if (!createButtonsDisplayed) {
return;
}
$newButton.addClass('hidden');
$sendButton.addClass('hidden');
$expiration.addClass('hidden');
$formatter.addClass('hidden');
$burnAfterReadingOption.addClass('hidden');
$openDiscussionOption.addClass('hidden');
$password.addClass('hidden');
$attach.addClass('hidden');
createButtonsDisplayed = false;
};
/**
* only shows the "new paste" button
*
* @name TopNav.showNewPasteButton
* @function
*/
me.showNewPasteButton = function()
{
$newButton.removeClass('hidden');
};
/**
* only shows the "retry" button
*
* @name TopNav.showRetryButton
* @function
*/
me.showRetryButton = function()
{
$retryButton.removeClass('hidden');
}
/**
* hides the "retry" button
*
* @name TopNav.hideRetryButton
* @function
*/
me.hideRetryButton = function()
{
$retryButton.addClass('hidden');
}
/**
* show the "email" button
*
* @name TopNav.showEmailbutton
* @function
* @param {int|undefined} optionalRemainingTimeInSeconds
*/
me.showEmailButton = function(optionalRemainingTimeInSeconds)
{
try {
// we cache expiration date in closure to avoid inaccurate expiration datetime
const expirationDate = Helper.calculateExpirationDate(
new Date(),
typeof optionalRemainingTimeInSeconds === 'number' ? optionalRemainingTimeInSeconds : TopNav.getExpiration()
);
const isBurnafterreading = TopNav.getBurnAfterReading();
$emailLink.removeClass('hidden');
$emailLink.off('click.sendEmail');
$emailLink.on('click.sendEmail', () => {
sendEmail(expirationDate, isBurnafterreading);
});
} catch (error) {
console.error(error);
Alert.showError('Cannot calculate expiration date.');
}
}
/**
* hide the "email" button
*
* @name TopNav.hideEmailButton
* @function
*/
me.hideEmailButton = function()
{
$emailLink.addClass('hidden');
$emailLink.off('click.sendEmail');
}
/**
* only hides the clone button
*
* @name TopNav.hideCloneButton
* @function
*/
me.hideCloneButton = function()
{
$cloneButton.addClass('hidden');
};
/**
* only hides the raw text button
*
* @name TopNav.hideRawButton
* @function
*/
me.hideRawButton = function()
{
$rawTextButton.addClass('hidden');
};
/**
* only hides the qr code button
*
* @name TopNav.hideQrCodeButton
* @function
*/
me.hideQrCodeButton = function()
{
$qrCodeLink.addClass('hidden');
}
/**
* hide all irrelevant buttons when viewing burn after reading paste
*
* @name TopNav.hideBurnAfterReadingButtons
* @function
*/
me.hideBurnAfterReadingButtons = function()
{
me.hideCloneButton();
me.hideQrCodeButton();
me.hideEmailButton();
}
/**
* hides the file selector in attachment
*
* @name TopNav.hideFileSelector
* @function
*/
me.hideFileSelector = function()
{
$fileWrap.addClass('hidden');
};
/**
* shows the custom attachment
*
* @name TopNav.showCustomAttachment
* @function
*/
me.showCustomAttachment = function()
{
$customAttachment.removeClass('hidden');
};
/**
* hides the custom attachment
*
* @name TopNav.hideCustomAttachment
* @function
*/
me.hideCustomAttachment = function()
{
$customAttachment.addClass('hidden');
$fileWrap.removeClass('hidden');
};
/**
* collapses the navigation bar, only if expanded
*
* @name TopNav.collapseBar
* @function
*/
me.collapseBar = function()
{
if ($('#navbar').attr('aria-expanded') === 'true') {
$('.navbar-toggle').click();
}
};
/**
* returns the currently set expiration time
*
* @name TopNav.getExpiration
* @function
* @return {int}
*/
me.getExpiration = function()
{
return pasteExpiration;
};
/**
* returns the currently selected file(s)
*
* @name TopNav.getFileList
* @function
* @return {FileList|null}
*/
me.getFileList = function()
{
const $file = $('#file');
// if no file given, return null
if (!$file.length || !$file[0].files.length) {
return null;
}
// ensure the selected file is still accessible
if (!($file[0].files && $file[0].files[0])) {
return null;
}
return $file[0].files;
};
/**
* returns the state of the burn after reading checkbox
*
* @name TopNav.getBurnAfterReading
* @function
* @return {bool}
*/
me.getBurnAfterReading = function()
{
return $burnAfterReading.is(':checked');
};
/**
* returns the state of the discussion checkbox
*
* @name TopNav.getOpenDiscussion
* @function
* @return {bool}
*/
me.getOpenDiscussion = function()
{
return $openDiscussion.is(':checked');
};
/**
* returns the entered password
*
* @name TopNav.getPassword
* @function
* @return {string}
*/
me.getPassword = function()
{
// when password is disabled $passwordInput.val() will return undefined
return $passwordInput.val() || '';
};
/**
* returns the element where custom attachments can be placed
*
* Used by AttachmentViewer when an attachment is cloned here.
*
* @name TopNav.getCustomAttachment
* @function
* @return {jQuery}
*/
me.getCustomAttachment = function()
{
return $customAttachment;
};
/**
* Set a function to call when the retry button is clicked.
*
* @name TopNav.setRetryCallback
* @function
* @param {function} callback
*/
me.setRetryCallback = function(callback)
{
retryButtonCallback = callback;
}
/**
* Highlight file upload
*
* @name TopNav.highlightFileupload
* @function
*/
me.highlightFileupload = function()
{
// visually indicate file uploaded
const $attachDropdownToggle = $attach.children('.dropdown-toggle');
if ($attachDropdownToggle.attr('aria-expanded') === 'false') {
$attachDropdownToggle.click();
}
$fileWrap.addClass('highlight');
setTimeout(function () {
$fileWrap.removeClass('highlight');
}, 300);
}
/**
* set the format on bootstrap templates in dropdown programmatically
*
* @name TopNav.setFormat
* @function
*/
me.setFormat = function(format)
{
$formatter.parent().find(`a[data-format="${format}"]`).click();
}
/**
* returns if attachment dropdown is readonly, not editable
*
* @name TopNav.isAttachmentReadonly
* @function
* @return {bool}
*/
me.isAttachmentReadonly = function()
{
return $attach.hasClass('hidden');
}
/**
* init navigation manager
*
* preloads jQuery elements
*
* @name TopNav.init
* @function
*/
me.init = function()
{
$attach = $('#attach');
$burnAfterReading = $('#burnafterreading');
$burnAfterReadingOption = $('#burnafterreadingoption');
$cloneButton = $('#clonebutton');
$customAttachment = $('#customattachment');
$expiration = $('#expiration');
$fileRemoveButton = $('#fileremovebutton');
$fileWrap = $('#filewrap');
$formatter = $('#formatter');
$newButton = $('#newbutton');
$openDiscussion = $('#opendiscussion');
$openDiscussionOption = $('#opendiscussionoption');
$password = $('#password');
$passwordInput = $('#passwordinput');
$rawTextButton = $('#rawtextbutton');
$retryButton = $('#retrybutton');
$sendButton = $('#sendbutton');
$qrCodeLink = $('#qrcodelink');
$emailLink = $('#emaillink');
// bootstrap template drop down
$('#language ul.dropdown-menu li a').click(setLanguage);
// page template drop down
$('#language select option').click(setLanguage);
// bind events
$burnAfterReading.change(changeBurnAfterReading);
$openDiscussionOption.change(changeOpenDiscussion);
$newButton.click(clickNewPaste);
$sendButton.click(PasteEncrypter.sendPaste);
$cloneButton.click(Controller.clonePaste);
$rawTextButton.click(rawText);
$retryButton.click(clickRetryButton);
$fileRemoveButton.click(removeAttachment);
$qrCodeLink.click(displayQrCode);
// bootstrap template drop downs
$('ul.dropdown-menu li a', $('#expiration').parent()).click(updateExpiration);
$('ul.dropdown-menu li a', $('#formatter').parent()).click(updateFormat);
// initiate default state of checkboxes
changeBurnAfterReading();
changeOpenDiscussion();
// get default value from template or fall back to set value
pasteExpiration = Model.getExpirationDefault() || pasteExpiration;
createButtonsDisplayed = false;
viewButtonsDisplayed = false;
};
return me;
})(window, document);
/**
* Responsible for AJAX requests, transparently handles encryption…
*
* @name ServerInteraction
* @class
*/
const ServerInteraction = (function () {
const me = {};
let successFunc = null,
failureFunc = null,
symmetricKey = null,
url,
data,
password;
/**
* public variable ('constant') for errors to prevent magic numbers
*
* @name ServerInteraction.error
* @readonly
* @enum {Object}
*/
me.error = {
okay: 0,
custom: 1,
unknown: 2,
serverError: 3
};
/**
* ajaxHeaders to send in AJAX requests
*
* @name ServerInteraction.ajaxHeaders
* @private
* @readonly
* @enum {Object}
*/
const ajaxHeaders = {'X-Requested-With': 'JSONHttpRequest'};
/**
* called after successful upload
*
* @name ServerInteraction.success
* @private
* @function
* @param {int} status
* @param {int} result - optional
*/
function success(status, result)
{
if (successFunc !== null) {
// add useful data to result
result.encryptionKey = symmetricKey;
successFunc(status, result);
}
}
/**
* called after a upload failure
*
* @name ServerInteraction.fail
* @private
* @function
* @param {int} status - internal code
* @param {int} result - original error code
*/
function fail(status, result)
{
if (failureFunc !== null) {
failureFunc(status, result);
}
}
/**
* actually uploads the data
*
* @name ServerInteraction.run
* @function
*/
me.run = function()
{
let isPost = Object.keys(data).length > 0,
ajaxParams = {
type: isPost ? 'POST' : 'GET',
url: url,
headers: ajaxHeaders,
dataType: 'json',
success: function(result) {
if (result.status === 0) {
success(0, result);
} else if (result.status === 1) {
fail(1, result);
} else {
fail(2, result);
}
}
};
if (isPost) {
ajaxParams.data = JSON.stringify(data);
}
$.ajax(ajaxParams).fail(function(jqXHR, textStatus, errorThrown) {
console.error(textStatus, errorThrown);
fail(3, jqXHR);
});
};
/**
* return currently set data, used in unit testing
*
* @name ServerInteraction.getData
* @function
*/
me.getData = function()
{
return data;
};
/**
* set success function
*
* @name ServerInteraction.setUrl
* @function
* @param {function} newUrl
*/
me.setUrl = function(newUrl)
{
url = newUrl;
};
/**
* sets the password to use (first value) and optionally also the
* encryption key (not recommended, it is automatically generated).
*
* Note: Call this after prepare() as prepare() resets these values.
*
* @name ServerInteraction.setCryptValues
* @function
* @param {string} newPassword
* @param {string} newKey - optional
*/
me.setCryptParameters = function(newPassword, newKey)
{
password = newPassword;
if (typeof newKey !== 'undefined') {
symmetricKey = newKey;
}
};
/**
* set success function
*
* @name ServerInteraction.setSuccess
* @function
* @param {function} func
*/
me.setSuccess = function(func)
{
successFunc = func;
};
/**
* set failure function
*
* @name ServerInteraction.setFailure
* @function
* @param {function} func
*/
me.setFailure = function(func)
{
failureFunc = func;
};
/**
* prepares a new upload
*
* Call this when doing a new upload to reset any data from potential
* previous uploads. Must be called before any other method of this
* module.
*
* @name ServerInteraction.prepare
* @function
* @return {object}
*/
me.prepare = function()
{
// entropy should already be checked!
// reset password
password = '';
// reset key, so it a new one is generated when it is used
symmetricKey = null;
// reset data
successFunc = null;
failureFunc = null;
url = Helper.baseUri();
data = {};
};
/**
* encrypts and sets the data
*
* @name ServerInteraction.setCipherMessage
* @async
* @function
* @param {object} cipherMessage
*/
me.setCipherMessage = async function(cipherMessage)
{
if (
symmetricKey === null ||
(typeof symmetricKey === 'string' && symmetricKey === '')
) {
symmetricKey = CryptTool.getSymmetricKey();
}
if (!data.hasOwnProperty('adata')) {
data['adata'] = [];
}
let cipherResult = await CryptTool.cipher(symmetricKey, password, JSON.stringify(cipherMessage), data['adata']);
data['v'] = 2;
data['ct'] = cipherResult[0];
data['adata'] = cipherResult[1];
};
/**
* set the additional metadata to send unencrypted
*
* @name ServerInteraction.setUnencryptedData
* @function
* @param {string} index
* @param {mixed} element
*/
me.setUnencryptedData = function(index, element)
{
data[index] = element;
};
/**
* Helper, which parses shows a general error message based on the result of the ServerInteraction
*
* @name ServerInteraction.parseUploadError
* @function
* @param {int} status
* @param {object} data
* @param {string} doThisThing - a human description of the action, which was tried
* @return {array}
*/
me.parseUploadError = function(status, data, doThisThing) {
let errorArray;
switch (status) {
case me.error.custom:
errorArray = ['Could not ' + doThisThing + ': %s', data.message];
break;
case me.error.unknown:
errorArray = ['Could not ' + doThisThing + ': %s', I18n._('unknown status')];
break;
case me.error.serverError:
errorArray = ['Could not ' + doThisThing + ': %s', I18n._('server error or not responding')];
break;
default:
errorArray = ['Could not ' + doThisThing + ': %s', I18n._('unknown error')];
break;
}
return errorArray;
};
return me;
})();
/**
* (controller) Responsible for encrypting paste and sending it to server.
*
* Does upload, encryption is done transparently by ServerInteraction.
*
* @name PasteEncrypter
* @class
*/
const PasteEncrypter = (function () {
const me = {};
/**
* called after successful paste upload
*
* @name PasteEncrypter.showCreatedPaste
* @private
* @function
* @param {int} status
* @param {object} data
*/
function showCreatedPaste(status, data) {
Alert.hideLoading();
Alert.hideMessages();
// show notification
const baseUri = Helper.baseUri() + '?',
url = baseUri + data.id + '#' + CryptTool.base58encode(data.encryptionKey),
deleteUrl = baseUri + 'pasteid=' + data.id + '&deletetoken=' + data.deletetoken;
PasteStatus.createPasteNotification(url, deleteUrl);
// show new URL in browser bar
history.pushState({type: 'newpaste'}, document.title, url);
TopNav.showViewButtons();
// this cannot be grouped with showViewButtons due to remaining time calculation
TopNav.showEmailButton();
TopNav.hideRawButton();
Editor.hide();
// parse and show text
// (preparation already done in me.sendPaste())
PasteViewer.run();
}
/**
* called after successful comment upload
*
* @name PasteEncrypter.showUploadedComment
* @private
* @function
* @param {int} status
* @param {object} data
*/
function showUploadedComment(status, data) {
// show success message
Alert.showStatus('Comment posted.');
// reload paste
Controller.refreshPaste(function () {
// highlight sent comment
DiscussionViewer.highlightComment(data.id, true);
// reset error handler
Alert.setCustomHandler(null);
});
}
/**
* send a reply in a discussion
*
* @name PasteEncrypter.sendComment
* @async
* @function
*/
me.sendComment = async function()
{
Alert.hideMessages();
Alert.setCustomHandler(DiscussionViewer.handleNotification);
// UI loading state
TopNav.hideAllButtons();
Alert.showLoading('Sending comment…', 'cloud-upload');
// get data
const plainText = DiscussionViewer.getReplyMessage(),
nickname = DiscussionViewer.getReplyNickname(),
parentid = DiscussionViewer.getReplyCommentId();
// do not send if there is no data
if (plainText.length === 0) {
// revert loading status…
Alert.hideLoading();
Alert.setCustomHandler(null);
TopNav.showViewButtons();
return;
}
// prepare server interaction
ServerInteraction.prepare();
ServerInteraction.setCryptParameters(Prompt.getPassword(), Model.getPasteKey());
// set success/fail functions
ServerInteraction.setSuccess(showUploadedComment);
ServerInteraction.setFailure(function (status, data) {
// revert loading status…
Alert.hideLoading();
TopNav.showViewButtons();
// …show error message…
Alert.showError(
ServerInteraction.parseUploadError(status, data, 'post comment')
);
// …and reset error handler
Alert.setCustomHandler(null);
});
// fill it with unencrypted params
ServerInteraction.setUnencryptedData('pasteid', Model.getPasteId());
if (typeof parentid === 'undefined') {
// if parent id is not set, this is the top-most comment, so use
// paste id as parent, as the root element of the discussion tree
ServerInteraction.setUnencryptedData('parentid', Model.getPasteId());
} else {
ServerInteraction.setUnencryptedData('parentid', parentid);
}
// prepare cypher message
let cipherMessage = {
'comment': plainText
};
if (nickname.length > 0) {
cipherMessage['nickname'] = nickname;
}
await ServerInteraction.setCipherMessage(cipherMessage).catch(Alert.showError);
ServerInteraction.run();
};
/**
* sends a new paste to server
*
* @name PasteEncrypter.sendPaste
* @async
* @function
*/
me.sendPaste = async function()
{
// hide previous (error) messages
Controller.hideStatusMessages();
// UI loading state
TopNav.hideAllButtons();
Alert.showLoading('Sending paste…', 'cloud-upload');
TopNav.collapseBar();
// get data
const plainText = Editor.getText(),
format = PasteViewer.getFormat(),
// the methods may return different values if no files are attached (null, undefined or false)
files = TopNav.getFileList() || AttachmentViewer.getFile() || AttachmentViewer.hasAttachment();
// do not send if there is no data
if (plainText.length === 0 && !files) {
// revert loading status…
Alert.hideLoading();
TopNav.showCreateButtons();
return;
}
// prepare server interaction
ServerInteraction.prepare();
ServerInteraction.setCryptParameters(TopNav.getPassword());
// set success/fail functions
ServerInteraction.setSuccess(showCreatedPaste);
ServerInteraction.setFailure(function (status, data) {
// revert loading status…
Alert.hideLoading();
TopNav.showCreateButtons();
// show error message
Alert.showError(
ServerInteraction.parseUploadError(status, data, 'create paste')
);
});
// fill it with unencrypted submitted options
ServerInteraction.setUnencryptedData('adata', [
null, format,
TopNav.getOpenDiscussion() ? 1 : 0,
TopNav.getBurnAfterReading() ? 1 : 0
]);
ServerInteraction.setUnencryptedData('meta', {'expire': TopNav.getExpiration()});
// prepare PasteViewer for later preview
PasteViewer.setText(plainText);
PasteViewer.setFormat(format);
// prepare cypher message
let file = AttachmentViewer.getAttachmentData(),
cipherMessage = {
'paste': plainText
};
if (typeof file !== 'undefined' && file !== null) {
cipherMessage['attachment'] = file;
cipherMessage['attachment_name'] = AttachmentViewer.getFile().name;
} else if (AttachmentViewer.hasAttachment()) {
// fall back to cloned part
let attachment = AttachmentViewer.getAttachment();
cipherMessage['attachment'] = attachment[0];
cipherMessage['attachment_name'] = attachment[1];
// we need to retrieve data from blob if browser already parsed it in memory
if (typeof attachment[0] === 'string' && attachment[0].startsWith('blob:')) {
Alert.showStatus(
[
'Retrieving cloned file \'%s\' from memory...',
attachment[1]
],
'copy'
);
try {
const blobData = await $.ajax({
type: 'GET',
url: `${attachment[0]}`,
processData: false,
timeout: 10000,
xhrFields: {
withCredentials: false,
responseType: 'blob'
}
});
if (blobData instanceof window.Blob) {
const fileReading = new Promise(function(resolve, reject) {
const fileReader = new FileReader();
fileReader.onload = function (event) {
resolve(event.target.result);
};
fileReader.onerror = function (error) {
reject(error);
}
fileReader.readAsDataURL(blobData);
});
cipherMessage['attachment'] = await fileReading;
} else {
const error = 'Cannot process attachment data.';
Alert.showError(error);
throw new TypeError(error);
}
} catch (error) {
console.error(error);
Alert.showError('Cannot retrieve attachment.');
throw error;
}
}
}
// encrypt message
await ServerInteraction.setCipherMessage(cipherMessage).catch(Alert.showError);
// send data
ServerInteraction.run();
};
return me;
})();
/**
* (controller) Responsible for decrypting cipherdata and passing data to view.
*
* Only decryption, no download.
*
* @name PasteDecrypter
* @class
*/
const PasteDecrypter = (function () {
const me = {};
/**
* decrypt data or prompts for password in case of failure
*
* @name PasteDecrypter.decryptOrPromptPassword
* @private
* @async
* @function
* @param {string} key
* @param {string} password - optional, may be an empty string
* @param {string} cipherdata
* @throws {string}
* @return {false|string} false, when unsuccessful or string (decrypted data)
*/
async function decryptOrPromptPassword(key, password, cipherdata)
{
// try decryption without password
const plaindata = await CryptTool.decipher(key, password, cipherdata);
// if it fails, request password
if (plaindata.length === 0 && password.length === 0) {
// show prompt
Prompt.requestPassword();
// Thus, we cannot do anything yet, we need to wait for the user
// input.
return false;
}
// if all tries failed, we can only return an error
if (plaindata.length === 0) {
return false;
}
return plaindata;
}
/**
* decrypt the actual paste text
*
* @name PasteDecrypter.decryptPaste
* @private
* @async
* @function
* @param {Paste} paste - paste data in object form
* @param {string} key
* @param {string} password
* @throws {string}
* @return {Promise}
*/
async function decryptPaste(paste, key, password)
{
let pastePlain = await decryptOrPromptPassword(
key, password,
paste.getCipherData()
);
if (pastePlain === false) {
if (password.length === 0) {
throw 'waiting on user to provide a password';
} else {
Alert.hideLoading();
// reset password, so it can be re-entered
Prompt.reset();
TopNav.showRetryButton();
throw 'Could not decrypt data. Did you enter a wrong password? Retry with the button at the top.';
}
}
if (paste.v > 1) {
// version 2 paste
const pasteMessage = JSON.parse(pastePlain);
if (pasteMessage.hasOwnProperty('attachment') && pasteMessage.hasOwnProperty('attachment_name')) {
AttachmentViewer.setAttachment(pasteMessage.attachment, pasteMessage.attachment_name);
AttachmentViewer.showAttachment();
}
pastePlain = pasteMessage.paste;
} else {
// version 1 paste
if (paste.hasOwnProperty('attachment') && paste.hasOwnProperty('attachmentname')) {
Promise.all([
CryptTool.decipher(key, password, paste.attachment),
CryptTool.decipher(key, password, paste.attachmentname)
]).then((attachment) => {
AttachmentViewer.setAttachment(attachment[0], attachment[1]);
AttachmentViewer.showAttachment();
});
}
}
PasteViewer.setFormat(paste.getFormat());
PasteViewer.setText(pastePlain);
PasteViewer.run();
}
/**
* decrypts all comments and shows them
*
* @name PasteDecrypter.decryptComments
* @private
* @async
* @function
* @param {Paste} paste - paste data in object form
* @param {string} key
* @param {string} password
* @return {Promise}
*/
async function decryptComments(paste, key, password)
{
// remove potential previous discussion
DiscussionViewer.prepareNewDiscussion();
const commentDecryptionPromises = [];
// iterate over comments
for (let i = 0; i < paste.comments.length; ++i) {
const comment = new Comment(paste.comments[i]),
commentPromise = CryptTool.decipher(key, password, comment.getCipherData());
paste.comments[i] = comment;
if (comment.v > 1) {
// version 2 comment
commentDecryptionPromises.push(
commentPromise.then(function (commentJson) {
const commentMessage = JSON.parse(commentJson);
return [
commentMessage.comment || '',
commentMessage.nickname || ''
];
})
);
} else {
// version 1 comment
commentDecryptionPromises.push(
Promise.all([
commentPromise,
paste.comments[i].meta.hasOwnProperty('nickname') ?
CryptTool.decipher(key, password, paste.comments[i].meta.nickname) :
Promise.resolve('')
])
);
}
}
return Promise.all(commentDecryptionPromises).then(function (plaintexts) {
for (let i = 0; i < paste.comments.length; ++i) {
if (plaintexts[i][0].length === 0) {
continue;
}
DiscussionViewer.addComment(
paste.comments[i],
plaintexts[i][0],
plaintexts[i][1]
);
}
});
}
/**
* show decrypted text in the display area, including discussion (if open)
*
* @name PasteDecrypter.run
* @function
* @param {Paste} [paste] - (optional) object including comments to display (items = array with keys ('data','meta'))
*/
me.run = function(paste)
{
Alert.hideMessages();
Alert.showLoading('Decrypting paste…', 'cloud-download');
if (typeof paste === 'undefined') {
// get cipher data and wait until it is available
Model.getPasteData(me.run);
return;
}
let key = Model.getPasteKey(),
password = Prompt.getPassword(),
decryptionPromises = [];
TopNav.setRetryCallback(function () {
TopNav.hideRetryButton();
me.run(paste);
});
// decrypt paste & attachments
decryptionPromises.push(decryptPaste(paste, key, password));
// if the discussion is opened on this paste, display it
if (paste.isDiscussionEnabled()) {
decryptionPromises.push(decryptComments(paste, key, password));
}
// shows the remaining time (until) deletion
PasteStatus.showRemainingTime(paste);
Promise.all(decryptionPromises)
.then(() => {
Alert.hideLoading();
TopNav.showViewButtons();
// discourage cloning (it cannot really be prevented)
if (paste.isBurnAfterReadingEnabled()) {
TopNav.hideBurnAfterReadingButtons();
} else {
// we have to pass in remaining_time here
TopNav.showEmailButton(paste.getTimeToLive());
}
// only offer adding comments, after paste was successfully decrypted
if (paste.isDiscussionEnabled()) {
DiscussionViewer.finishDiscussion();
}
})
.catch((err) => {
// wait for the user to type in the password,
// then PasteDecrypter.run will be called again
Alert.showError(err);
});
};
return me;
})();
/**
* (controller) main PrivateBin logic
*
* @name Controller
* @param {object} window
* @param {object} document
* @class
*/
const Controller = (function (window, document) {
const me = {};
/**
* hides all status messages no matter which module showed them
*
* @name Controller.hideStatusMessages
* @function
*/
me.hideStatusMessages = function()
{
PasteStatus.hideMessages();
Alert.hideMessages();
};
/**
* creates a new paste
*
* @name Controller.newPaste
* @function
*/
me.newPaste = function()
{
// Important: This *must not* run Alert.hideMessages() as previous
// errors from viewing a paste should be shown.
TopNav.hideAllButtons();
Alert.showLoading('Preparing new paste…', 'time');
PasteStatus.hideMessages();
PasteViewer.hide();
Editor.resetInput();
Editor.show();
Editor.focusInput();
AttachmentViewer.removeAttachment();
TopNav.showCreateButtons();
// newPaste could be called when user is on paste clone editing view
TopNav.hideCustomAttachment();
AttachmentViewer.clearDragAndDrop();
AttachmentViewer.removeAttachmentData();
Alert.hideLoading();
history.pushState({type: 'create'}, document.title, Helper.baseUri());
// clear discussion
DiscussionViewer.prepareNewDiscussion();
};
/**
* shows the loaded paste
*
* @name Controller.showPaste
* @function
*/
me.showPaste = function()
{
try {
Model.getPasteKey();
} catch (err) {
console.error(err);
// missing decryption key (or paste ID) in URL?
if (window.location.hash.length === 0) {
Alert.showError('Cannot decrypt paste: Decryption key missing in URL (Did you use a redirector or an URL shortener which strips part of the URL?)');
return;
}
}
// show proper elements on screen
PasteDecrypter.run();
};
/**
* refreshes the loaded paste to show potential new data
*
* @name Controller.refreshPaste
* @function
* @param {function} callback
*/
me.refreshPaste = function(callback)
{
// save window position to restore it later
const orgPosition = $(window).scrollTop();
Model.getPasteData(function (data) {
ServerInteraction.prepare();
ServerInteraction.setUrl(Helper.baseUri() + '?pasteid=' + Model.getPasteId());
ServerInteraction.setFailure(function (status, data) {
// revert loading status…
Alert.hideLoading();
TopNav.showViewButtons();
// show error message
Alert.showError(
ServerInteraction.parseUploadError(status, data, 'refresh display')
);
});
ServerInteraction.setSuccess(function (status, data) {
PasteDecrypter.run(new Paste(data));
// restore position
window.scrollTo(0, orgPosition);
// NOTE: could create problems as callback may be called
// asyncronously if PasteDecrypter e.g. needs to wait for a
// password being entered
callback();
});
ServerInteraction.run();
}, false); // this false is important as it circumvents the cache
}
/**
* clone the current paste
*
* @name Controller.clonePaste
* @function
*/
me.clonePaste = function()
{
TopNav.collapseBar();
TopNav.hideAllButtons();
// hide messages from previous paste
me.hideStatusMessages();
// erase the id and the key in url
history.pushState({type: 'clone'}, document.title, Helper.baseUri());
if (AttachmentViewer.hasAttachment()) {
AttachmentViewer.moveAttachmentTo(
TopNav.getCustomAttachment(),
'Cloned: \'%s\''
);
TopNav.hideFileSelector();
AttachmentViewer.hideAttachment();
// NOTE: it also looks nice without removing the attachment
// but for a consistent display we remove it…
AttachmentViewer.hideAttachmentPreview();
TopNav.showCustomAttachment();
// show another status message to make the user aware that the
// file was cloned too!
Alert.showStatus(
[
'The cloned file \'%s\' was attached to this paste.',
AttachmentViewer.getAttachment()[1]
],
'copy'
);
}
Editor.setText(PasteViewer.getText());
// also clone the format
TopNav.setFormat(PasteViewer.getFormat());
PasteViewer.hide();
Editor.show();
TopNav.showCreateButtons();
// clear discussion
DiscussionViewer.prepareNewDiscussion();
};
/**
* try initializing zlib or display a warning if it fails,
* extracted from main init to allow unit testing
*
* @name Controller.initZ
* @function
*/
me.initZ = function()
{
z = zlib.catch(function () {
if ($('body').data('compression') !== 'none') {
Alert.showWarning('Your browser doesn\'t support WebAssembly, used for zlib compression. You can create uncompressed documents, but can\'t read compressed ones.');
}
});
}
/**
* application start
*
* @name Controller.init
* @function
*/
me.init = function()
{
// first load translations
I18n.loadTranslations();
DOMPurify.setConfig({
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
SAFE_FOR_JQUERY: true
});
// center all modals
$('.modal').on('show.bs.modal', function(e) {
$(e.target).css({
display: 'flex'
});
});
// initialize other modules/"classes"
Alert.init();
Model.init();
AttachmentViewer.init();
DiscussionViewer.init();
Editor.init();
PasteStatus.init();
PasteViewer.init();
Prompt.init();
TopNav.init();
UiHelper.init();
// check for legacy browsers before going any further
if (!Legacy.Check.getInit()) {
// Legacy check didn't complete, wait and try again
setTimeout(init, 500);
return;
}
if (!Legacy.Check.getStatus()) {
// something major is wrong, stop right away
return;
}
me.initZ();
// check whether existing paste needs to be shown
try {
Model.getPasteId();
} catch (e) {
// otherwise create a new paste
return me.newPaste();
}
// if delete token is passed (i.e. paste has been deleted by this
// access), there is nothing more to do
if (Model.hasDeleteToken()) {
return;
}
// display an existing paste
return me.showPaste();
}
return me;
})(window, document);
return {
Helper: Helper,
I18n: I18n,
CryptTool: CryptTool,
Model: Model,
UiHelper: UiHelper,
Alert: Alert,
PasteStatus: PasteStatus,
Prompt: Prompt,
Editor: Editor,
PasteViewer: PasteViewer,
AttachmentViewer: AttachmentViewer,
DiscussionViewer: DiscussionViewer,
TopNav: TopNav,
ServerInteraction: ServerInteraction,
PasteEncrypter: PasteEncrypter,
PasteDecrypter: PasteDecrypter,
Controller: Controller
};
})(jQuery, RawDeflate);