|
@@ -56,7 +56,7 @@ window.PrivateBin = (function () {
|
|
|
*/
|
|
*/
|
|
|
const purifyHtmlConfig = {
|
|
const purifyHtmlConfig = {
|
|
|
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
|
|
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
|
|
|
- ALLOWED_ATTR: ['href', 'id', 'target', 'rel', 'class', 'style'],
|
|
|
|
|
|
|
+ ALLOWED_ATTR: ['href'],
|
|
|
USE_PROFILES: {
|
|
USE_PROFILES: {
|
|
|
html: true
|
|
html: true
|
|
|
}
|
|
}
|
|
@@ -72,9 +72,8 @@ window.PrivateBin = (function () {
|
|
|
*/
|
|
*/
|
|
|
const purifyHtmlConfigStrictSubset = {
|
|
const purifyHtmlConfigStrictSubset = {
|
|
|
ALLOWED_URI_REGEXP: purifyHtmlConfig.ALLOWED_URI_REGEXP,
|
|
ALLOWED_URI_REGEXP: purifyHtmlConfig.ALLOWED_URI_REGEXP,
|
|
|
- ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
|
|
|
|
|
- ALLOWED_ATTR: ['href', 'id', 'target', 'rel'],
|
|
|
|
|
- ADD_ATTR: ['target', 'rel']
|
|
|
|
|
|
|
+ ALLOWED_TAGS: ['a'],
|
|
|
|
|
+ ALLOWED_ATTR: ['href']
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -89,6 +88,20 @@ window.PrivateBin = (function () {
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * DOMpurify settings for translated messages, which are allowed to include limited HTML.
|
|
|
|
|
+ *
|
|
|
|
|
+ * NOTE: The key {@link purifyHtmlConfig.USE_PROFILES} **must not** be included,
|
|
|
|
|
+ * as otherwise `USE_PROFILES` takes precedence over {@link purifyHtmlConfigStrictSubset.ALLOWED_TAGS}.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @private
|
|
|
|
|
+ */
|
|
|
|
|
+ const purifyTranslationConfig = {
|
|
|
|
|
+ ALLOWED_URI_REGEXP: /^(?:https?:)/i, // the only URL expected here is the generated document, so only http & https
|
|
|
|
|
+ ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
|
|
|
|
|
+ ALLOWED_ATTR: ['href', 'id'] // one of the messages contains an id attribute
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* URL fragment prefix requiring load confirmation
|
|
* URL fragment prefix requiring load confirmation
|
|
|
*
|
|
*
|
|
@@ -492,11 +505,9 @@ window.PrivateBin = (function () {
|
|
|
const name = cname + '=',
|
|
const name = cname + '=',
|
|
|
ca = document.cookie.split(';');
|
|
ca = document.cookie.split(';');
|
|
|
for (let i = 0; i < ca.length; ++i) {
|
|
for (let i = 0; i < ca.length; ++i) {
|
|
|
- let c = ca[i];
|
|
|
|
|
- while (c.charAt(0) === ' ') {
|
|
|
|
|
- c = c.substring(1);
|
|
|
|
|
- }
|
|
|
|
|
- if (c.indexOf(name) === 0) {
|
|
|
|
|
|
|
+ let c = ca[i].trim();
|
|
|
|
|
+ if (c.startsWith(name))
|
|
|
|
|
+ {
|
|
|
return c.substring(name.length, c.length);
|
|
return c.substring(name.length, c.length);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -667,7 +678,7 @@ window.PrivateBin = (function () {
|
|
|
* @prop {string[]}
|
|
* @prop {string[]}
|
|
|
* @readonly
|
|
* @readonly
|
|
|
*/
|
|
*/
|
|
|
- const supportedLanguages = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fa', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ro', 'ru', 'sk', 'sl', 'sv', 'th', 'tr', 'uk', 'zh'];
|
|
|
|
|
|
|
+ const supportedLanguages = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fa', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'ko', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ro', 'ru', 'sk', 'sl', 'sv', 'th', 'tr', 'uk', 'zh', 'zh-tw'];
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* built in language
|
|
* built in language
|
|
@@ -807,7 +818,7 @@ window.PrivateBin = (function () {
|
|
|
|
|
|
|
|
if (containsHtml) {
|
|
if (containsHtml) {
|
|
|
// only allow tags/attributes we actually use in translations
|
|
// only allow tags/attributes we actually use in translations
|
|
|
- output = DOMPurify.sanitize(output, purifyHtmlConfigStrictSubset);
|
|
|
|
|
|
|
+ output = DOMPurify.sanitize(output, purifyTranslationConfig);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// if element is given, insert translation
|
|
// if element is given, insert translation
|
|
@@ -857,16 +868,18 @@ window.PrivateBin = (function () {
|
|
|
case 'oc':
|
|
case 'oc':
|
|
|
case 'tr':
|
|
case 'tr':
|
|
|
case 'zh':
|
|
case 'zh':
|
|
|
|
|
+ case 'zh-tw':
|
|
|
return n > 1 ? 1 : 0;
|
|
return n > 1 ? 1 : 0;
|
|
|
case 'he':
|
|
case 'he':
|
|
|
return n === 1 ? 0 : (n === 2 ? 1 : ((n < 0 || n > 10) && (n % 10 === 0) ? 2 : 3));
|
|
return n === 1 ? 0 : (n === 2 ? 1 : ((n < 0 || n > 10) && (n % 10 === 0) ? 2 : 3));
|
|
|
case 'id':
|
|
case 'id':
|
|
|
case 'ja':
|
|
case 'ja':
|
|
|
case 'jbo':
|
|
case 'jbo':
|
|
|
|
|
+ case 'ko':
|
|
|
case 'th':
|
|
case 'th':
|
|
|
return 0;
|
|
return 0;
|
|
|
case 'lt':
|
|
case 'lt':
|
|
|
- return n % 10 === 1 && n % 100 !== 11 ? 0 : ((n % 10 >= 2 && n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
|
|
|
|
|
|
|
+ return n % 10 === 1 && n % 100 !== 11 ? 0 : ((n % 10 >= 2 && (n % 100 < 10 || n % 100 >= 20)) ? 1 : 2);
|
|
|
case 'pl':
|
|
case 'pl':
|
|
|
return n === 1 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
|
|
return n === 1 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
|
|
|
case 'ro':
|
|
case 'ro':
|
|
@@ -893,8 +906,16 @@ window.PrivateBin = (function () {
|
|
|
|
|
|
|
|
// auto-select language based on browser settings
|
|
// auto-select language based on browser settings
|
|
|
if (newLanguage.length === 0) {
|
|
if (newLanguage.length === 0) {
|
|
|
- newLanguage = (navigator.language || navigator.userLanguage || 'en');
|
|
|
|
|
- if (newLanguage.indexOf('-') > 0) {
|
|
|
|
|
|
|
+ newLanguage = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
|
|
|
|
|
+ const isTraditionalChinese = newLanguage.startsWith('zh-tw-') ||
|
|
|
|
|
+ newLanguage === 'zh-hant' || newLanguage.startsWith('zh-hant-') ||
|
|
|
|
|
+ newLanguage === 'zh-hk' || newLanguage.startsWith('zh-hk-') ||
|
|
|
|
|
+ newLanguage === 'zh-mo' || newLanguage.startsWith('zh-mo-');
|
|
|
|
|
+ // alias Traditional Chinese browser tags to PrivateBin's zh-tw locale;
|
|
|
|
|
+ // otherwise try the full tag and fall back to the base language
|
|
|
|
|
+ if (isTraditionalChinese && supportedLanguages.includes('zh-tw')) {
|
|
|
|
|
+ newLanguage = 'zh-tw';
|
|
|
|
|
+ } else if (!supportedLanguages.includes(newLanguage) && newLanguage.includes('-')) {
|
|
|
newLanguage = newLanguage.split('-')[0];
|
|
newLanguage = newLanguage.split('-')[0];
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -911,7 +932,7 @@ window.PrivateBin = (function () {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// if language is not supported, show error
|
|
// if language is not supported, show error
|
|
|
- if (supportedLanguages.indexOf(newLanguage) === -1) {
|
|
|
|
|
|
|
+ if (!supportedLanguages.includes(newLanguage)) {
|
|
|
console.error('Language \'%s\' is not supported. Translation failed, fallback to English.', newLanguage);
|
|
console.error('Language \'%s\' is not supported. Translation failed, fallback to English.', newLanguage);
|
|
|
language = 'en';
|
|
language = 'en';
|
|
|
return;
|
|
return;
|
|
@@ -938,6 +959,27 @@ window.PrivateBin = (function () {
|
|
|
});
|
|
});
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Register a callback to be invoked when translations have been loaded.
|
|
|
|
|
+ *
|
|
|
|
|
+ * This is useful for code that needs to re-apply translated strings to
|
|
|
|
|
+ * DOM attributes (e.g. title attributes) that cannot be handled by
|
|
|
|
|
+ * I18n.translate's built-in element re-translation mechanism (which only
|
|
|
|
|
+ * sets textContent/innerHTML).
|
|
|
|
|
+ *
|
|
|
|
|
+ * Note: If translations are already loaded when this is called, the
|
|
|
|
|
+ * callback will not be invoked. Callers should apply the translation
|
|
|
|
|
+ * immediately as well, to cover the case where the language is already
|
|
|
|
|
+ * available.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @name I18n.onLanguageLoaded
|
|
|
|
|
+ * @function
|
|
|
|
|
+ * @param {function} callback - function to call when language is loaded
|
|
|
|
|
+ */
|
|
|
|
|
+ me.onLanguageLoaded = function (callback) {
|
|
|
|
|
+ document.addEventListener(languageLoadedEvent, callback);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* resets state, used for unit testing
|
|
* resets state, used for unit testing
|
|
|
*
|
|
*
|
|
@@ -963,7 +1005,7 @@ window.PrivateBin = (function () {
|
|
|
function isStringContainsHtml(messageId) {
|
|
function isStringContainsHtml(messageId) {
|
|
|
// message IDs are allowed to contain anchors, spans, keyboard and emphasis tags
|
|
// message IDs are allowed to contain anchors, spans, keyboard and emphasis tags
|
|
|
// we can recognize all of them by only checking for anchors and keyboard tags
|
|
// we can recognize all of them by only checking for anchors and keyboard tags
|
|
|
- return typeof messageId === 'string' && (messageId.indexOf('<a') !== -1 || messageId.indexOf('<kbd') !== -1);
|
|
|
|
|
|
|
+ return typeof messageId === 'string' && (messageId.includes('<a') || messageId.includes('<kbd'));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return me;
|
|
return me;
|
|
@@ -985,49 +1027,6 @@ window.PrivateBin = (function () {
|
|
|
*/
|
|
*/
|
|
|
const base58 = new baseX('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz');
|
|
const base58 = new baseX('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz');
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * convert UTF-8 string stored in a DOMString to a standard UTF-16 DOMString
|
|
|
|
|
- *
|
|
|
|
|
- * Iterates over the bytes of the message, converting them all hexadecimal
|
|
|
|
|
- * percent encoded representations, then URI decodes them all
|
|
|
|
|
- *
|
|
|
|
|
- * @name CryptTool.utf8To16
|
|
|
|
|
- * @function
|
|
|
|
|
- * @private
|
|
|
|
|
- * @param {string} message UTF-8 string
|
|
|
|
|
- * @return {string} UTF-16 string
|
|
|
|
|
- */
|
|
|
|
|
- function utf8To16(message) {
|
|
|
|
|
- return decodeURIComponent(
|
|
|
|
|
- message.split('').map(
|
|
|
|
|
- function (character) {
|
|
|
|
|
- return '%' + ('00' + character.charCodeAt(0).toString(16)).slice(-2);
|
|
|
|
|
- }
|
|
|
|
|
- ).join('')
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * convert DOMString (UTF-16) to a UTF-8 string stored in a DOMString
|
|
|
|
|
- *
|
|
|
|
|
- * URI encodes the message, then finds the percent encoded characters
|
|
|
|
|
- * and transforms these hexadecimal representation back into bytes
|
|
|
|
|
- *
|
|
|
|
|
- * @name CryptTool.utf16To8
|
|
|
|
|
- * @function
|
|
|
|
|
- * @private
|
|
|
|
|
- * @param {string} message UTF-16 string
|
|
|
|
|
- * @return {string} UTF-8 string
|
|
|
|
|
- */
|
|
|
|
|
- function utf16To8(message) {
|
|
|
|
|
- return encodeURIComponent(message).replace(
|
|
|
|
|
- /%([0-9A-F]{2})/g,
|
|
|
|
|
- function (match, hexCharacter) {
|
|
|
|
|
- return String.fromCharCode('0x' + hexCharacter);
|
|
|
|
|
- }
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
/**
|
|
/**
|
|
|
* convert ArrayBuffer into a UTF-8 string
|
|
* convert ArrayBuffer into a UTF-8 string
|
|
|
*
|
|
*
|
|
@@ -1039,14 +1038,11 @@ window.PrivateBin = (function () {
|
|
|
* @param {ArrayBuffer} messageArray
|
|
* @param {ArrayBuffer} messageArray
|
|
|
* @return {string} message
|
|
* @return {string} message
|
|
|
*/
|
|
*/
|
|
|
- function arraybufferToString(messageArray) {
|
|
|
|
|
- const array = new Uint8Array(messageArray);
|
|
|
|
|
- let message = '',
|
|
|
|
|
- i = 0;
|
|
|
|
|
- while (i < array.length) {
|
|
|
|
|
- message += String.fromCharCode(array[i++]);
|
|
|
|
|
- }
|
|
|
|
|
- return message;
|
|
|
|
|
|
|
+ function arraybufferToString(messageArray)
|
|
|
|
|
+ {
|
|
|
|
|
+ return Array.from(new Uint8Array(messageArray))
|
|
|
|
|
+ .map(byte => String.fromCharCode(byte))
|
|
|
|
|
+ .join('');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -1060,12 +1056,12 @@ window.PrivateBin = (function () {
|
|
|
* @param {string} message UTF-8 string
|
|
* @param {string} message UTF-8 string
|
|
|
* @return {Uint8Array} array
|
|
* @return {Uint8Array} array
|
|
|
*/
|
|
*/
|
|
|
- function stringToArraybuffer(message) {
|
|
|
|
|
- const messageArray = new Uint8Array(message.length);
|
|
|
|
|
- for (let i = 0; i < message.length; ++i) {
|
|
|
|
|
- messageArray[i] = message.charCodeAt(i);
|
|
|
|
|
- }
|
|
|
|
|
- return messageArray;
|
|
|
|
|
|
|
+ function stringToArraybuffer(message)
|
|
|
|
|
+ {
|
|
|
|
|
+ return Uint8Array.from(
|
|
|
|
|
+ message,
|
|
|
|
|
+ character => character.charCodeAt(0)
|
|
|
|
|
+ );
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -1082,9 +1078,7 @@ window.PrivateBin = (function () {
|
|
|
* @return {ArrayBuffer} data
|
|
* @return {ArrayBuffer} data
|
|
|
*/
|
|
*/
|
|
|
async function compress(message, mode, zlib) {
|
|
async function compress(message, mode, zlib) {
|
|
|
- message = stringToArraybuffer(
|
|
|
|
|
- utf16To8(message)
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ message = new TextEncoder().encode(message);
|
|
|
if (mode === 'zlib') {
|
|
if (mode === 'zlib') {
|
|
|
if (typeof zlib === 'undefined') {
|
|
if (typeof zlib === 'undefined') {
|
|
|
throw new Error('Error compressing document, due to missing WebAssembly support.');
|
|
throw new Error('Error compressing document, due to missing WebAssembly support.');
|
|
@@ -1116,9 +1110,7 @@ window.PrivateBin = (function () {
|
|
|
new Uint8Array(data)
|
|
new Uint8Array(data)
|
|
|
).buffer;
|
|
).buffer;
|
|
|
}
|
|
}
|
|
|
- return utf8To16(
|
|
|
|
|
- arraybufferToString(data)
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ return new TextDecoder('utf-8', {fatal: true}).decode(data);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -1453,7 +1445,7 @@ window.PrivateBin = (function () {
|
|
|
* @throws {string}
|
|
* @throws {string}
|
|
|
*/
|
|
*/
|
|
|
me.getPasteId = function () {
|
|
me.getPasteId = function () {
|
|
|
- const idRegEx = /^[a-z0-9]{16}$/;
|
|
|
|
|
|
|
+ const idRegEx = /^[a-f0-9]{16}$/;
|
|
|
|
|
|
|
|
// return cached value
|
|
// return cached value
|
|
|
if (id !== null) {
|
|
if (id !== null) {
|
|
@@ -1489,7 +1481,7 @@ window.PrivateBin = (function () {
|
|
|
* @return {bool}
|
|
* @return {bool}
|
|
|
*/
|
|
*/
|
|
|
me.hasDeleteToken = function () {
|
|
me.hasDeleteToken = function () {
|
|
|
- return window.location.search.indexOf('deletetoken') !== -1;
|
|
|
|
|
|
|
+ return (new URL(window.location)).searchParams.has('deletetoken');
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -2840,10 +2832,10 @@ window.PrivateBin = (function () {
|
|
|
*/
|
|
*/
|
|
|
function getBlobUrl(data, mimeType) {
|
|
function getBlobUrl(data, mimeType) {
|
|
|
// Transform into a Blob
|
|
// Transform into a Blob
|
|
|
- const buf = new Uint8Array(data.length);
|
|
|
|
|
- for (let i = 0; i < data.length; ++i) {
|
|
|
|
|
- buf[i] = data.charCodeAt(i);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const buf = Uint8Array.from(
|
|
|
|
|
+ data,
|
|
|
|
|
+ character => character.charCodeAt(0)
|
|
|
|
|
+ );
|
|
|
const blob = new window.Blob(
|
|
const blob = new window.Blob(
|
|
|
[buf],
|
|
[buf],
|
|
|
{
|
|
{
|
|
@@ -2879,22 +2871,22 @@ window.PrivateBin = (function () {
|
|
|
|
|
|
|
|
// We explicitly do _not_ use the original mime type for the download link
|
|
// We explicitly do _not_ use the original mime type for the download link
|
|
|
// to always force a download instead of potentially dangerous browser rendering/parsing/interpretation
|
|
// to always force a download instead of potentially dangerous browser rendering/parsing/interpretation
|
|
|
- let safeMimeType = 'application/octet-stream';
|
|
|
|
|
|
|
+ let sanitizedMimeType = 'application/octet-stream';
|
|
|
if (me.isSafeMimeType(mimeType)) {
|
|
if (me.isSafeMimeType(mimeType)) {
|
|
|
- safeMimeType = mimeType;
|
|
|
|
|
|
|
+ sanitizedMimeType = mimeType;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// extract data and convert to binary
|
|
// extract data and convert to binary
|
|
|
- const rawData = attachmentData.substring(base64Start);
|
|
|
|
|
- const decodedData = rawData.length > 0 ? atob(rawData) : '';
|
|
|
|
|
|
|
+ const base64Data = attachmentData.substring(base64Start);
|
|
|
|
|
+ const plainData = base64Data.length > 0 ? atob(base64Data) : '';
|
|
|
|
|
|
|
|
- let blobUrl = getBlobUrl(decodedData, safeMimeType);
|
|
|
|
|
|
|
+ let blobUrl = getBlobUrl(plainData, sanitizedMimeType);
|
|
|
attachmentLink.setAttribute('href', blobUrl);
|
|
attachmentLink.setAttribute('href', blobUrl);
|
|
|
|
|
|
|
|
if (typeof fileName !== 'undefined') {
|
|
if (typeof fileName !== 'undefined') {
|
|
|
attachmentLink.setAttribute('download', fileName);
|
|
attachmentLink.setAttribute('download', fileName);
|
|
|
|
|
|
|
|
- const fileSize = Helper.formatBytes(decodedData.length);
|
|
|
|
|
|
|
+ const fileSize = Helper.formatBytes(plainData.length);
|
|
|
const spans = template.querySelectorAll('span');
|
|
const spans = template.querySelectorAll('span');
|
|
|
const span = spans[spans.length - 1];
|
|
const span = spans[spans.length - 1];
|
|
|
span.textContent += ` (${fileName}, ${fileSize})`;
|
|
span.textContent += ` (${fileName}, ${fileSize})`;
|
|
@@ -2904,18 +2896,36 @@ window.PrivateBin = (function () {
|
|
|
// prevents executing embedded scripts when CSP is not set and user
|
|
// prevents executing embedded scripts when CSP is not set and user
|
|
|
// right-clicks/long-taps and opens the SVG in a new tab - prevented
|
|
// right-clicks/long-taps and opens the SVG in a new tab - prevented
|
|
|
// in the preview by use of an img tag, which disables scripts, too
|
|
// in the preview by use of an img tag, which disables scripts, too
|
|
|
- if (mimeType.match(/^image\/.*svg/i)) {
|
|
|
|
|
- const sanitizedData = DOMPurify.sanitize(
|
|
|
|
|
- decodedData,
|
|
|
|
|
- purifySvgConfig
|
|
|
|
|
- );
|
|
|
|
|
- blobUrl = getBlobUrl(sanitizedData, mimeType);
|
|
|
|
|
|
|
+ if (mimeType.startsWith('image/svg')) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // attempt to UTF-8 decode the SVG data
|
|
|
|
|
+ const svgBuffer = Uint8Array.from(
|
|
|
|
|
+ plainData,
|
|
|
|
|
+ character => character.charCodeAt(0)
|
|
|
|
|
+ );
|
|
|
|
|
+ const utf8ValidatedSvgString = new TextDecoder(
|
|
|
|
|
+ 'utf-8',
|
|
|
|
|
+ {fatal: true}
|
|
|
|
|
+ ).decode(svgBuffer);
|
|
|
|
|
+ const sanitizedData = DOMPurify.sanitize(
|
|
|
|
|
+ utf8ValidatedSvgString,
|
|
|
|
|
+ purifySvgConfig
|
|
|
|
|
+ );
|
|
|
|
|
+ sanitizedMimeType = 'image/svg+xml';
|
|
|
|
|
+ blobUrl = getBlobUrl(sanitizedData, sanitizedMimeType);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // Invalid or non-UTF-8 SVG: download only, no preview as it
|
|
|
|
|
+ // may be used to smuggle multi-byte sequences past DOMpurify
|
|
|
|
|
+ // such as `ļ` to get `\x01<` & `ľ` to get `\x01>`
|
|
|
|
|
+ sanitizedMimeType = 'application/octet-stream';
|
|
|
|
|
+ blobUrl = getBlobUrl(plainData, sanitizedMimeType);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
template.classList.remove('hidden');
|
|
template.classList.remove('hidden');
|
|
|
attachment.appendChild(template);
|
|
attachment.appendChild(template);
|
|
|
|
|
|
|
|
- me.handleBlobAttachmentPreview(attachmentPreview, blobUrl, mimeType);
|
|
|
|
|
|
|
+ me.handleBlobAttachmentPreview(attachmentPreview, blobUrl, sanitizedMimeType);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
@@ -2931,35 +2941,16 @@ window.PrivateBin = (function () {
|
|
|
* @returns {bool}
|
|
* @returns {bool}
|
|
|
*/
|
|
*/
|
|
|
me.isSafeMimeType = function(mimeType) {
|
|
me.isSafeMimeType = function(mimeType) {
|
|
|
- return (
|
|
|
|
|
- mimeType.startsWith('image/') &&
|
|
|
|
|
- !mimeType.includes('svg')
|
|
|
|
|
- ) ||
|
|
|
|
|
- mimeType.startsWith('video/') ||
|
|
|
|
|
- mimeType.startsWith('audio/') ||
|
|
|
|
|
- mimeType.endsWith('/pdf') ||
|
|
|
|
|
- mimeType === 'text/plain';
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- /**
|
|
|
|
|
- * Evaluates whether this is known a safe mime type.
|
|
|
|
|
- *
|
|
|
|
|
- * This means, the media can safely be displayed and e.g. no XSS should be possible.
|
|
|
|
|
- *
|
|
|
|
|
- * @name AttachmentViewer.isSafeMimeType
|
|
|
|
|
- * @function
|
|
|
|
|
- * @param {string}
|
|
|
|
|
- * @returns {bool}
|
|
|
|
|
- */
|
|
|
|
|
- me.isSafeMimeType = function(mimeType) {
|
|
|
|
|
- return (
|
|
|
|
|
|
|
+ return ((
|
|
|
mimeType.startsWith('image/') &&
|
|
mimeType.startsWith('image/') &&
|
|
|
- !mimeType.includes('svg')
|
|
|
|
|
|
|
+ !/svg/i.test(mimeType)
|
|
|
) ||
|
|
) ||
|
|
|
mimeType.startsWith('video/') ||
|
|
mimeType.startsWith('video/') ||
|
|
|
mimeType.startsWith('audio/') ||
|
|
mimeType.startsWith('audio/') ||
|
|
|
- mimeType.endsWith('/pdf') ||
|
|
|
|
|
- mimeType === 'text/plain';
|
|
|
|
|
|
|
+ mimeType === 'application/pdf' ||
|
|
|
|
|
+ mimeType === 'text/plain') &&
|
|
|
|
|
+ // don't accept comments, stray characters, spaces, etc.
|
|
|
|
|
+ /^[a-z0-9][a-z0-9.-]*[a-z0-9]\/[a-z0-9][a-z0-9.+-]*[a-z0-9]$/.test(mimeType);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -3144,9 +3135,12 @@ window.PrivateBin = (function () {
|
|
|
me.getAttachmentMimeType = function (attachmentData) {
|
|
me.getAttachmentMimeType = function (attachmentData) {
|
|
|
// position in data URI string of where mimeType ends
|
|
// position in data URI string of where mimeType ends
|
|
|
const mimeTypeEnd = attachmentData.indexOf(';');
|
|
const mimeTypeEnd = attachmentData.indexOf(';');
|
|
|
|
|
+ if (mimeTypeEnd < 6) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// extract mimeType
|
|
// extract mimeType
|
|
|
- return attachmentData.substring(5, mimeTypeEnd);
|
|
|
|
|
|
|
+ return attachmentData.substring(5, mimeTypeEnd).toLowerCase();
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -3249,12 +3243,12 @@ window.PrivateBin = (function () {
|
|
|
const alreadyIncludesCurrentAttachment = targetElement.querySelectorAll(`[src='${blobUrl}']`).length > 0;
|
|
const alreadyIncludesCurrentAttachment = targetElement.querySelectorAll(`[src='${blobUrl}']`).length > 0;
|
|
|
|
|
|
|
|
if (blobUrl && !alreadyIncludesCurrentAttachment) {
|
|
if (blobUrl && !alreadyIncludesCurrentAttachment) {
|
|
|
- if (mimeType.toLowerCase().startsWith('image/')) {
|
|
|
|
|
|
|
+ if (mimeType.startsWith('image/')) {
|
|
|
const image = document.createElement('img');
|
|
const image = document.createElement('img');
|
|
|
image.setAttribute('src', blobUrl);
|
|
image.setAttribute('src', blobUrl);
|
|
|
image.setAttribute('class', 'img-thumbnail');
|
|
image.setAttribute('class', 'img-thumbnail');
|
|
|
targetElement.appendChild(image);
|
|
targetElement.appendChild(image);
|
|
|
- } else if (mimeType.toLowerCase().startsWith('video/')) {
|
|
|
|
|
|
|
+ } else if (mimeType.startsWith('video/')) {
|
|
|
const video = document.createElement('video');
|
|
const video = document.createElement('video');
|
|
|
video.setAttribute('controls', 'true');
|
|
video.setAttribute('controls', 'true');
|
|
|
video.setAttribute('autoplay', 'true');
|
|
video.setAttribute('autoplay', 'true');
|
|
@@ -3264,7 +3258,7 @@ window.PrivateBin = (function () {
|
|
|
source.setAttribute('src', blobUrl);
|
|
source.setAttribute('src', blobUrl);
|
|
|
video.appendChild(source);
|
|
video.appendChild(source);
|
|
|
targetElement.appendChild(video);
|
|
targetElement.appendChild(video);
|
|
|
- } else if (mimeType.toLowerCase().startsWith('audio/')) {
|
|
|
|
|
|
|
+ } else if (mimeType.startsWith('audio/')) {
|
|
|
const audio = document.createElement('audio');
|
|
const audio = document.createElement('audio');
|
|
|
audio.setAttribute('controls', 'true');
|
|
audio.setAttribute('controls', 'true');
|
|
|
audio.setAttribute('autoplay', 'true');
|
|
audio.setAttribute('autoplay', 'true');
|
|
@@ -3273,7 +3267,7 @@ window.PrivateBin = (function () {
|
|
|
source.setAttribute('src', blobUrl);
|
|
source.setAttribute('src', blobUrl);
|
|
|
audio.appendChild(source);
|
|
audio.appendChild(source);
|
|
|
targetElement.appendChild(audio);
|
|
targetElement.appendChild(audio);
|
|
|
- } else if (mimeType.toLowerCase().endsWith('/pdf')) {
|
|
|
|
|
|
|
+ } else if (mimeType === 'application/pdf') {
|
|
|
const embed = document.createElement('embed');
|
|
const embed = document.createElement('embed');
|
|
|
embed.setAttribute('src', blobUrl);
|
|
embed.setAttribute('src', blobUrl);
|
|
|
embed.setAttribute('type', 'application/pdf');
|
|
embed.setAttribute('type', 'application/pdf');
|
|
@@ -3606,7 +3600,18 @@ window.PrivateBin = (function () {
|
|
|
// if an avatar is available, display it
|
|
// if an avatar is available, display it
|
|
|
const icon = comment.getIcon();
|
|
const icon = comment.getIcon();
|
|
|
if (icon) {
|
|
if (icon) {
|
|
|
|
|
+ const iconTitle = 'Avatar generated from IP address';
|
|
|
const image = document.createElement('img');
|
|
const image = document.createElement('img');
|
|
|
|
|
+ // Set the title immediately with the current translation. If
|
|
|
|
|
+ // the language has not been loaded yet, this will fall back to
|
|
|
|
|
+ // English, so we also register a callback to re-apply the
|
|
|
|
|
+ // translation once available. To avoid a race condition, we
|
|
|
|
|
+ // register the callback first, then translate - if the language
|
|
|
|
|
+ // loads between the two lines, both will set the translated text.
|
|
|
|
|
+ I18n.onLanguageLoaded(function () {
|
|
|
|
|
+ image.setAttribute('title', I18n._(iconTitle));
|
|
|
|
|
+ });
|
|
|
|
|
+ image.setAttribute('title', I18n._(iconTitle));
|
|
|
image.setAttribute('src', icon);
|
|
image.setAttribute('src', icon);
|
|
|
image.setAttribute('class', 'vizhash');
|
|
image.setAttribute('class', 'vizhash');
|
|
|
const nickSpan = commentEntry.querySelector('span.nickname');
|
|
const nickSpan = commentEntry.querySelector('span.nickname');
|
|
@@ -4038,6 +4043,31 @@ window.PrivateBin = (function () {
|
|
|
Controller.newPaste();
|
|
Controller.newPaste();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * sends the document on [Ctrl]+[S] or, on macOS, [Cmd]+[S]
|
|
|
|
|
+ *
|
|
|
|
|
+ * Only acts while the send button is shown, so the browser's own
|
|
|
|
|
+ * "save page" still works when viewing an existing document.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @name TopNav.sendOnShortcut
|
|
|
|
|
+ * @private
|
|
|
|
|
+ * @function
|
|
|
|
|
+ * @param {KeyboardEvent} event
|
|
|
|
|
+ */
|
|
|
|
|
+ function sendOnShortcut(event) {
|
|
|
|
|
+ // the physical S key, so non-Latin keyboard layouts work, too
|
|
|
|
|
+ const isSaveKey = event.code === 'KeyS';
|
|
|
|
|
+ // Alt is excluded because AltGr is reported as Ctrl+Alt and some
|
|
|
|
|
+ // layouts type letters with AltGr+S, Shift to leave "Save page as"
|
|
|
|
|
+ // ([Ctrl]+[Shift]+[S]) to the browser
|
|
|
|
|
+ const hasCorrectModifiers = (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey;
|
|
|
|
|
+ const isButtonAvailable = sendButton && !sendButton.classList.contains('hidden') && !sendButton.disabled;
|
|
|
|
|
+ if (isSaveKey && hasCorrectModifiers && isButtonAvailable) {
|
|
|
|
|
+ event.preventDefault();
|
|
|
|
|
+ sendButton.click();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* retrys some callback registered before
|
|
* retrys some callback registered before
|
|
|
*
|
|
*
|
|
@@ -4146,11 +4176,12 @@ window.PrivateBin = (function () {
|
|
|
*
|
|
*
|
|
|
* @name TopNav.triggerEmailSend
|
|
* @name TopNav.triggerEmailSend
|
|
|
* @private
|
|
* @private
|
|
|
|
|
+ * @param {string} subject
|
|
|
* @param {string} emailBody
|
|
* @param {string} emailBody
|
|
|
*/
|
|
*/
|
|
|
- function triggerEmailSend(emailBody) {
|
|
|
|
|
|
|
+ function triggerEmailSend(subject, emailBody) {
|
|
|
window.open(
|
|
window.open(
|
|
|
- `mailto:?body=${encodeURIComponent(emailBody)}`,
|
|
|
|
|
|
|
+ `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(emailBody)}`,
|
|
|
'_self',
|
|
'_self',
|
|
|
'noopener, noreferrer'
|
|
'noopener, noreferrer'
|
|
|
);
|
|
);
|
|
@@ -4174,6 +4205,9 @@ window.PrivateBin = (function () {
|
|
|
);
|
|
);
|
|
|
expirationDateRoundedToSecond.setUTCSeconds(0);
|
|
expirationDateRoundedToSecond.setUTCSeconds(0);
|
|
|
|
|
|
|
|
|
|
+ // reused as-is by both the confirmation-modal and direct-send paths below
|
|
|
|
|
+ const emailSubject = I18n._('Encrypted note on %s', document.title);
|
|
|
|
|
+
|
|
|
const emailconfirmmodal = document.getElementById('emailconfirmmodal');
|
|
const emailconfirmmodal = document.getElementById('emailconfirmmodal');
|
|
|
if (expirationDate !== null) {
|
|
if (expirationDate !== null) {
|
|
|
const emailconfirmTimezoneCurrent = emailconfirmmodal.querySelector('#emailconfirm-timezone-current');
|
|
const emailconfirmTimezoneCurrent = emailconfirmmodal.querySelector('#emailconfirm-timezone-current');
|
|
@@ -4192,7 +4226,7 @@ window.PrivateBin = (function () {
|
|
|
if (bootstrap5EmailConfirmModal) {
|
|
if (bootstrap5EmailConfirmModal) {
|
|
|
bootstrap5EmailConfirmModal.hide();
|
|
bootstrap5EmailConfirmModal.hide();
|
|
|
}
|
|
}
|
|
|
- triggerEmailSend(emailBody);
|
|
|
|
|
|
|
+ triggerEmailSend(emailSubject, emailBody);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
emailconfirmmodal.addEventListener('shown.bs.modal', () => {
|
|
emailconfirmmodal.addEventListener('shown.bs.modal', () => {
|
|
@@ -4210,7 +4244,7 @@ window.PrivateBin = (function () {
|
|
|
bootstrap5EmailConfirmModal.show();
|
|
bootstrap5EmailConfirmModal.show();
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
- triggerEmailSend(templateEmailBody(null, isBurnafterreading));
|
|
|
|
|
|
|
+ triggerEmailSend(emailSubject, templateEmailBody(null, isBurnafterreading));
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -4284,8 +4318,12 @@ window.PrivateBin = (function () {
|
|
|
expiration.classList.remove('hidden');
|
|
expiration.classList.remove('hidden');
|
|
|
formatter.classList.remove('hidden');
|
|
formatter.classList.remove('hidden');
|
|
|
newButton.classList.remove('hidden');
|
|
newButton.classList.remove('hidden');
|
|
|
- openDiscussionOption.classList.remove('hidden');
|
|
|
|
|
- password.classList.remove('hidden');
|
|
|
|
|
|
|
+ if (openDiscussionOption) {
|
|
|
|
|
+ openDiscussionOption.classList.remove('hidden');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (password) {
|
|
|
|
|
+ password.classList.remove('hidden');
|
|
|
|
|
+ }
|
|
|
sendButton.classList.remove('hidden');
|
|
sendButton.classList.remove('hidden');
|
|
|
|
|
|
|
|
createButtonsDisplayed = true;
|
|
createButtonsDisplayed = true;
|
|
@@ -4307,8 +4345,12 @@ window.PrivateBin = (function () {
|
|
|
expiration.classList.add('hidden');
|
|
expiration.classList.add('hidden');
|
|
|
formatter.classList.add('hidden');
|
|
formatter.classList.add('hidden');
|
|
|
burnAfterReadingOption.classList.add('hidden');
|
|
burnAfterReadingOption.classList.add('hidden');
|
|
|
- openDiscussionOption.classList.add('hidden');
|
|
|
|
|
- password.classList.add('hidden');
|
|
|
|
|
|
|
+ if (openDiscussionOption) {
|
|
|
|
|
+ openDiscussionOption.classList.add('hidden');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (password) {
|
|
|
|
|
+ password.classList.add('hidden');
|
|
|
|
|
+ }
|
|
|
if (attach) {
|
|
if (attach) {
|
|
|
attach.classList.add('hidden');
|
|
attach.classList.add('hidden');
|
|
|
}
|
|
}
|
|
@@ -4334,6 +4376,11 @@ window.PrivateBin = (function () {
|
|
|
*/
|
|
*/
|
|
|
me.showRetryButton = function () {
|
|
me.showRetryButton = function () {
|
|
|
retryButton.classList.remove('hidden');
|
|
retryButton.classList.remove('hidden');
|
|
|
|
|
+ // The Bootstrap 5 template gives Retry its own list item. That item
|
|
|
|
|
+ // still takes a flex gap while only the button is hidden.
|
|
|
|
|
+ if (Helper.isBootstrap5()) {
|
|
|
|
|
+ retryButton.parentElement.classList.remove('hidden');
|
|
|
|
|
+ }
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -4344,6 +4391,9 @@ window.PrivateBin = (function () {
|
|
|
*/
|
|
*/
|
|
|
me.hideRetryButton = function () {
|
|
me.hideRetryButton = function () {
|
|
|
retryButton.classList.add('hidden');
|
|
retryButton.classList.add('hidden');
|
|
|
|
|
+ if (Helper.isBootstrap5()) {
|
|
|
|
|
+ retryButton.parentElement.classList.add('hidden');
|
|
|
|
|
+ }
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -4352,15 +4402,17 @@ window.PrivateBin = (function () {
|
|
|
* @name TopNav.showEmailbutton
|
|
* @name TopNav.showEmailbutton
|
|
|
* @function
|
|
* @function
|
|
|
* @param {number|undefined} optionalRemainingTimeInSeconds
|
|
* @param {number|undefined} optionalRemainingTimeInSeconds
|
|
|
|
|
+ * @param {bool|undefined} optionalBurnAfterReading
|
|
|
*/
|
|
*/
|
|
|
- me.showEmailButton = function (optionalRemainingTimeInSeconds) {
|
|
|
|
|
|
|
+ me.showEmailButton = function (optionalRemainingTimeInSeconds, optionalBurnAfterReading) {
|
|
|
try {
|
|
try {
|
|
|
// we cache expiration date in closure to avoid inaccurate expiration datetime
|
|
// we cache expiration date in closure to avoid inaccurate expiration datetime
|
|
|
const expirationDate = Helper.calculateExpirationDate(
|
|
const expirationDate = Helper.calculateExpirationDate(
|
|
|
new Date(),
|
|
new Date(),
|
|
|
typeof optionalRemainingTimeInSeconds === 'number' ? optionalRemainingTimeInSeconds : TopNav.getExpiration()
|
|
typeof optionalRemainingTimeInSeconds === 'number' ? optionalRemainingTimeInSeconds : TopNav.getExpiration()
|
|
|
);
|
|
);
|
|
|
- const isBurnafterreading = TopNav.getBurnAfterReading();
|
|
|
|
|
|
|
+ const isBurnafterreading = typeof optionalBurnAfterReading === 'boolean' ?
|
|
|
|
|
+ optionalBurnAfterReading : TopNav.getBurnAfterReading();
|
|
|
|
|
|
|
|
emailLink.classList.remove('hidden');
|
|
emailLink.classList.remove('hidden');
|
|
|
emailLink.removeEventListener('click', sendEmail);
|
|
emailLink.removeEventListener('click', sendEmail);
|
|
@@ -4512,7 +4564,7 @@ window.PrivateBin = (function () {
|
|
|
if (openDiscussion) {
|
|
if (openDiscussion) {
|
|
|
openDiscussion.checked = openDiscussionDefault;
|
|
openDiscussion.checked = openDiscussionDefault;
|
|
|
}
|
|
}
|
|
|
- if (openDiscussionDefault || !burnAfterReadingDefault) {
|
|
|
|
|
|
|
+ if (openDiscussionOption && (openDiscussionDefault || !burnAfterReadingDefault)) {
|
|
|
openDiscussionOption.classList.remove('buttondisabled');
|
|
openDiscussionOption.classList.remove('buttondisabled');
|
|
|
}
|
|
}
|
|
|
if (burnAfterReadingDefault || !openDiscussionDefault) {
|
|
if (burnAfterReadingDefault || !openDiscussionDefault) {
|
|
@@ -4720,6 +4772,7 @@ window.PrivateBin = (function () {
|
|
|
}
|
|
}
|
|
|
if (sendButton) {
|
|
if (sendButton) {
|
|
|
sendButton.addEventListener('click', PasteEncrypter.sendPaste);
|
|
sendButton.addEventListener('click', PasteEncrypter.sendPaste);
|
|
|
|
|
+ document.addEventListener('keydown', sendOnShortcut);
|
|
|
}
|
|
}
|
|
|
if (cloneButton) {
|
|
if (cloneButton) {
|
|
|
cloneButton.addEventListener('click', Controller.clonePaste);
|
|
cloneButton.addEventListener('click', Controller.clonePaste);
|
|
@@ -5501,16 +5554,6 @@ window.PrivateBin = (function () {
|
|
|
plaintexts[i][1]
|
|
plaintexts[i][1]
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- document.addEventListener(I18n.languageLoadedEvent, function () {
|
|
|
|
|
- const commentContainer = document.getElementById('commentcontainer');
|
|
|
|
|
- if (!commentContainer) {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- commentContainer.querySelectorAll('img.vizhash')
|
|
|
|
|
- .forEach(img => img.setAttribute('title', I18n._('Avatar generated from IP address')));
|
|
|
|
|
- });
|
|
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -5567,7 +5610,10 @@ window.PrivateBin = (function () {
|
|
|
TopNav.hideBurnAfterReadingButtons();
|
|
TopNav.hideBurnAfterReadingButtons();
|
|
|
} else {
|
|
} else {
|
|
|
// we have to pass in remaining_time here
|
|
// we have to pass in remaining_time here
|
|
|
- TopNav.showEmailButton(paste.getTimeToLive());
|
|
|
|
|
|
|
+ TopNav.showEmailButton(
|
|
|
|
|
+ paste.getTimeToLive(),
|
|
|
|
|
+ paste.isBurnAfterReadingEnabled()
|
|
|
|
|
+ );
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// only offer adding comments, after document was successfully decrypted
|
|
// only offer adding comments, after document was successfully decrypted
|
|
@@ -6138,4 +6184,3 @@ if (typeof module === 'undefined' || !module.exports) {
|
|
|
window.PrivateBin.Controller.init();
|
|
window.PrivateBin.Controller.init();
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
-
|
|
|