Passed
Push — master ( 57754f...4ccb4b )
by El
03:38
created

js/privatebin.js (1 issue)

Severity
1
/**
2
 * PrivateBin
3
 *
4
 * a zero-knowledge paste bin
5
 *
6
 * @see       {@link https://github.com/PrivateBin/PrivateBin}
7
 * @copyright 2012 Sébastien SAUVAGE ({@link http://sebsauvage.net})
8
 * @license   {@link https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License}
9
 * @version   1.1.1
10
 * @name      PrivateBin
11
 * @namespace
12
 */
13
14
/** global: Base64 */
15
/** global: DOMPurify */
16
/** global: FileReader */
17
/** global: RawDeflate */
18
/** global: history */
19
/** global: navigator */
20
/** global: prettyPrint */
21
/** global: prettyPrintOne */
22
/** global: showdown */
23
/** global: sjcl */
24
/** global: kjua */
25
26
// Immediately start random number generator collector.
27
sjcl.random.startCollectors();
28
29
// main application start, called when DOM is fully loaded
30
jQuery(document).ready(function() {
31
    'use strict';
32
    // run main controller
33
    $.PrivateBin.Controller.init();
34
});
35
36
jQuery.PrivateBin = (function($, sjcl, Base64, RawDeflate) {
37
    'use strict';
38
39
    /**
40
     * static Helper methods
41
     *
42
     * @name Helper
43
     * @class
44
     */
45
    var Helper = (function () {
46
        var me = {};
47
48
        /**
49
         * cache for script location
50
         *
51
         * @name Helper.baseUri
52
         * @private
53
         * @enum   {string|null}
54
         */
55
        var baseUri = null;
56
57
        /**
58
         * converts a duration (in seconds) into human friendly approximation
59
         *
60
         * @name Helper.secondsToHuman
61
         * @function
62
         * @param  {number} seconds
63
         * @return {Array}
64
         */
65
        me.secondsToHuman = function(seconds)
66
        {
67
            var v;
68
            if (seconds < 60)
69
            {
70
                v = Math.floor(seconds);
71
                return [v, 'second'];
72
            }
73
            if (seconds < 60 * 60)
74
            {
75
                v = Math.floor(seconds / 60);
76
                return [v, 'minute'];
77
            }
78
            if (seconds < 60 * 60 * 24)
79
            {
80
                v = Math.floor(seconds / (60 * 60));
81
                return [v, 'hour'];
82
            }
83
            // If less than 2 months, display in days:
84
            if (seconds < 60 * 60 * 24 * 60)
85
            {
86
                v = Math.floor(seconds / (60 * 60 * 24));
87
                return [v, 'day'];
88
            }
89
            v = Math.floor(seconds / (60 * 60 * 24 * 30));
90
            return [v, 'month'];
91
        };
92
93
        /**
94
         * text range selection
95
         *
96
         * @see    {@link https://stackoverflow.com/questions/985272/jquery-selecting-text-in-an-element-akin-to-highlighting-with-your-mouse}
97
         * @name   Helper.selectText
98
         * @function
99
         * @param  {HTMLElement} element
100
         */
101
        me.selectText = function(element)
102
        {
103
            var range, selection;
104
105
            // MS
106
            if (document.body.createTextRange) {
107
                range = document.body.createTextRange();
108
                range.moveToElementText(element);
109
                range.select();
110
            } else if (window.getSelection) {
111
                selection = window.getSelection();
112
                range = document.createRange();
113
                range.selectNodeContents(element);
114
                selection.removeAllRanges();
115
                selection.addRange(range);
116
            }
117
        };
118
119
        /**
120
         * convert URLs to clickable links.
121
         * URLs to handle:
122
         * <pre>
123
         *     magnet:?xt.1=urn:sha1:YNCKHTQCWBTRNJIV4WNAE52SJUQCZO5C&xt.2=urn:sha1:TXGCZQTH26NL6OUQAJJPFALHG2LTGBC7
124
         *     http://example.com:8800/zero/?6f09182b8ea51997#WtLEUO5Epj9UHAV9JFs+6pUQZp13TuspAUjnF+iM+dM=
125
         *     http://user:[email protected]:8800/zero/?6f09182b8ea51997#WtLEUO5Epj9UHAV9JFs+6pUQZp13TuspAUjnF+iM+dM=
126
         * </pre>
127
         *
128
         * @name   Helper.urls2links
129
         * @function
130
         * @param  {string} html
131
         * @return {string}
132
         */
133
        me.urls2links = function(html)
134
        {
135
            return html.replace(
136
                /(((http|https|ftp):\/\/[\w?=&.\/-;#@~%+*-]+(?![\w\s?&.\/;#~%"=-]*>))|((magnet):[\w?=&.\/-;#@~%+*-]+))/ig,
137
                '<a href="$1" rel="nofollow">$1</a>'
138
            );
139
        };
140
141
        /**
142
         * minimal sprintf emulation for %s and %d formats
143
         *
144
         * Note that this function needs the parameters in the same order as the
145
         * format strings appear in the string, contrary to the original.
146
         *
147
         * @see    {@link https://stackoverflow.com/questions/610406/javascript-equivalent-to-printf-string-format#4795914}
148
         * @name   Helper.sprintf
149
         * @function
150
         * @param  {string} format
151
         * @param  {...*} args - one or multiple parameters injected into format string
152
         * @return {string}
153
         */
154
        me.sprintf = function()
155
        {
156
            var args = Array.prototype.slice.call(arguments);
157
            var format = args[0],
158
                i = 1;
159
            return format.replace(/%(s|d)/g, function (m) {
160
                // m is the matched format, e.g. %s, %d
161
                var val = args[i];
162
                // A switch statement so that the formatter can be extended.
163
                switch (m)
164
                {
165
                    case '%d':
166
                        val = parseFloat(val);
167
                        if (isNaN(val)) {
168
                            val = 0;
169
                        }
170
                        break;
171
                    default:
172
                        // Default is %s
173
                }
174
                ++i;
175
                return val;
176
            });
177
        };
178
179
        /**
180
         * get value of cookie, if it was set, empty string otherwise
181
         *
182
         * @see    {@link http://www.w3schools.com/js/js_cookies.asp}
183
         * @name   Helper.getCookie
184
         * @function
185
         * @param  {string} cname - may not be empty
186
         * @return {string}
187
         */
188
        me.getCookie = function(cname) {
189
            var name = cname + '=',
190
                ca = document.cookie.split(';');
191
            for (var i = 0; i < ca.length; ++i) {
192
                var c = ca[i];
193
                while (c.charAt(0) === ' ')
194
                {
195
                    c = c.substring(1);
196
                }
197
                if (c.indexOf(name) === 0)
198
                {
199
                    return c.substring(name.length, c.length);
200
                }
201
            }
202
            return '';
203
        };
204
205
        /**
206
         * get the current location (without search or hash part of the URL),
207
         * eg. http://example.com/path/?aaaa#bbbb --> http://example.com/path/
208
         *
209
         * @name   Helper.baseUri
210
         * @function
211
         * @return {string}
212
         */
213
        me.baseUri = function()
214
        {
215
            // check for cached version
216
            if (baseUri !== null) {
217
                return baseUri;
218
            }
219
220
            baseUri = window.location.origin + window.location.pathname;
221
            return baseUri;
222
        };
223
224
        /**
225
         * resets state, used for unit testing
226
         *
227
         * @name   Helper.reset
228
         * @function
229
         */
230
        me.reset = function()
231
        {
232
            baseUri = null;
233
        };
234
235
        return me;
236
    })();
237
238
    /**
239
     * internationalization module
240
     *
241
     * @name I18n
242
     * @class
243
     */
244
    var I18n = (function () {
245
        var me = {};
246
247
        /**
248
         * const for string of loaded language
249
         *
250
         * @name I18n.languageLoadedEvent
251
         * @private
252
         * @prop   {string}
253
         * @readonly
254
         */
255
        var languageLoadedEvent = 'languageLoaded';
256
257
        /**
258
         * supported languages, minus the built in 'en'
259
         *
260
         * @name I18n.supportedLanguages
261
         * @private
262
         * @prop   {string[]}
263
         * @readonly
264
         */
265
        var supportedLanguages = ['de', 'es', 'fr', 'it', 'no', 'nl', 'pl', 'pt', 'oc', 'ru', 'sl', 'zh'];
266
267
        /**
268
         * built in language
269
         *
270
         * @name I18n.language
271
         * @private
272
         * @prop   {string|null}
273
         */
274
        var language = null;
275
276
        /**
277
         * translation cache
278
         *
279
         * @name I18n.translations
280
         * @private
281
         * @enum   {Object}
282
         */
283
        var translations = {};
284
285
        /**
286
         * translate a string, alias for I18n.translate
287
         *
288
         * @name   I18n._
289
         * @function
290
         * @param  {jQuery} $element - optional
291
         * @param  {string} messageId
292
         * @param  {...*} args - one or multiple parameters injected into placeholders
293
         * @return {string}
294
         */
295
        me._ = function()
296
        {
297
            return me.translate.apply(this, arguments);
298
        };
299
300
        /**
301
         * translate a string
302
         *
303
         * Optionally pass a jQuery element as the first parameter, to automatically
304
         * let the text of this element be replaced. In case the (asynchronously
305
         * loaded) language is not downloadet yet, this will make sure the string
306
         * is replaced when it is actually loaded.
307
         * So for easy translations passing the jQuery object to apply it to is
308
         * more save, especially when they are loaded in the beginning.
309
         *
310
         * @name   I18n.translate
311
         * @function
312
         * @param  {jQuery} $element - optional
313
         * @param  {string} messageId
314
         * @param  {...*} args - one or multiple parameters injected into placeholders
315
         * @return {string}
316
         */
317
        me.translate = function()
318
        {
319
            // convert parameters to array
320
            var args = Array.prototype.slice.call(arguments),
321
                messageId,
322
                $element = null;
323
324
            // parse arguments
325
            if (args[0] instanceof jQuery) {
326
                // optional jQuery element as first parameter
327
                $element = args[0];
328
                args.shift();
329
            }
330
331
            // extract messageId from arguments
332
            var usesPlurals = $.isArray(args[0]);
333
            if (usesPlurals) {
334
                // use the first plural form as messageId, otherwise the singular
335
                messageId = args[0].length > 1 ? args[0][1] : args[0][0];
336
            } else {
337
                messageId = args[0];
338
            }
339
340
            if (messageId.length === 0) {
341
                return messageId;
342
            }
343
344
            // if no translation string cannot be found (in translations object)
345
            if (!translations.hasOwnProperty(messageId) || language === null) {
346
                // if language is still loading and we have an elemt assigned
347
                if (language === null && $element !== null) {
348
                    // handle the error by attaching the language loaded event
349
                    var orgArguments = arguments;
350
                    $(document).on(languageLoadedEvent, function () {
351
                        // log to show that the previous error could be mitigated
352
                        console.warn('Fix missing translation of \'' + messageId + '\' with now loaded language ' + language);
353
                        // re-execute this function
354
                        me.translate.apply(this, orgArguments);
355
                    });
356
357
                    // and fall back to English for now until the real language
358
                    // file is loaded
359
                }
360
361
                // for all other langauges than English for which this behaviour
362
                // is expected as it is built-in, log error
363
                if (language !== null && language !== 'en') {
364
                    console.error('Missing translation for: \'' + messageId + '\' in language ' + language);
365
                    // fallback to English
366
                }
367
368
                // save English translation (should be the same on both sides)
369
                translations[messageId] = args[0];
370
            }
371
372
            // lookup plural translation
373
            if (usesPlurals && $.isArray(translations[messageId])) {
374
                var n = parseInt(args[1] || 1, 10),
375
                    key = me.getPluralForm(n),
376
                    maxKey = translations[messageId].length - 1;
377
                if (key > maxKey) {
378
                    key = maxKey;
379
                }
380
                args[0] = translations[messageId][key];
381
                args[1] = n;
382
            } else {
383
                // lookup singular translation
384
                args[0] = translations[messageId];
385
            }
386
387
            // format string
388
            var output = Helper.sprintf.apply(this, args);
389
390
            // if $element is given, apply text to element
391
            if ($element !== null) {
392
                // get last text node of element
393
                var content = $element.contents();
394
                if (content.length > 1) {
395
                    content[content.length - 1].nodeValue = ' ' + output;
396
                } else {
397
                    $element.text(output);
398
                }
399
            }
400
401
            return output;
402
        };
403
404
        /**
405
         * per language functions to use to determine the plural form
406
         *
407
         * @see    {@link http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html}
408
         * @name   I18n.getPluralForm
409
         * @function
410
         * @param  {int} n
411
         * @return {int} array key
412
         */
413
        me.getPluralForm = function(n) {
414
            switch (language)
415
            {
416
                case 'fr':
417
                case 'oc':
418
                case 'zh':
419
                    return n > 1 ? 1 : 0;
420
                case 'pl':
421
                    return n === 1 ? 0 : (n % 10 >= 2 && n %10 <=4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
422
                case 'ru':
423
                    return n % 10 === 1 && n % 100 !== 11 ? 0 : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);
424
                case 'sl':
425
                    return n % 100 === 1 ? 1 : (n % 100 === 2 ? 2 : (n % 100 === 3 || n % 100 === 4 ? 3 : 0));
426
                // de, en, es, it, no, pt
427
                default:
428
                    return n !== 1 ? 1 : 0;
429
            }
430
        };
431
432
        /**
433
         * load translations into cache
434
         *
435
         * @name   I18n.loadTranslations
436
         * @function
437
         */
438
        me.loadTranslations = function()
439
        {
440
            var newLanguage = Helper.getCookie('lang');
441
442
            // auto-select language based on browser settings
443
            if (newLanguage.length === 0) {
444
                newLanguage = (navigator.language || navigator.userLanguage || 'en').substring(0, 2);
445
            }
446
447
            // if language is already used skip update
448
            if (newLanguage === language) {
449
                return;
450
            }
451
452
            // if language is built-in (English) skip update
453
            if (newLanguage === 'en') {
454
                language = 'en';
455
                return;
456
            }
457
458
            // if language is not supported, show error
459
            if (supportedLanguages.indexOf(newLanguage) === -1) {
460
                console.error('Language \'%s\' is not supported. Translation failed, fallback to English.', newLanguage);
461
                language = 'en';
462
                return;
463
            }
464
465
            // load strings from JSON
466
            $.getJSON('i18n/' + newLanguage + '.json', function(data) {
467
                language = newLanguage;
468
                translations = data;
469
                $(document).triggerHandler(languageLoadedEvent);
470
            }).fail(function (data, textStatus, errorMsg) {
471
                console.error('Language \'%s\' could not be loaded (%s: %s). Translation failed, fallback to English.', newLanguage, textStatus, errorMsg);
472
                language = 'en';
473
            });
474
        };
475
476
        /**
477
         * resets state, used for unit testing
478
         *
479
         * @name   I18n.reset
480
         * @function
481
         */
482
        me.reset = function(mockLanguage, mockTranslations)
483
        {
484
            language = mockLanguage || null;
485
            translations = mockTranslations || {};
486
        };
487
488
        return me;
489
    })();
490
491
    /**
492
     * handles everything related to en/decryption
493
     *
494
     * @name CryptTool
495
     * @class
496
     */
497
    var CryptTool = (function () {
498
        var me = {};
499
500
        /**
501
         * compress a message (deflate compression), returns base64 encoded data
502
         *
503
         * @name   CryptTool.compress
504
         * @function
505
         * @private
506
         * @param  {string} message
507
         * @return {string} base64 data
508
         */
509
        function compress(message)
510
        {
511
            return Base64.toBase64( RawDeflate.deflate( Base64.utob(message) ) );
512
        }
513
514
        /**
515
         * decompress a message compressed with cryptToolcompress()
516
         *
517
         * @name   CryptTool.decompress
518
         * @function
519
         * @private
520
         * @param  {string} data - base64 data
521
         * @return {string} message
522
         */
523
        function decompress(data)
524
        {
525
            return Base64.btou( RawDeflate.inflate( Base64.fromBase64(data) ) );
526
        }
527
528
        /**
529
         * compress, then encrypt message with given key and password
530
         *
531
         * @name   CryptTool.cipher
532
         * @function
533
         * @param  {string} key
534
         * @param  {string} password
535
         * @param  {string} message
536
         * @return {string} data - JSON with encrypted data
537
         */
538
        me.cipher = function(key, password, message)
539
        {
540
            // Galois Counter Mode, keysize 256 bit, authentication tag 128 bit
541
            var options = {
542
                mode: 'gcm',
543
                ks: 256,
544
                ts: 128
545
            };
546
547
            if ((password || '').trim().length === 0) {
548
                return sjcl.encrypt(key, compress(message), options);
549
            }
550
            return sjcl.encrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), compress(message), options);
551
        };
552
553
        /**
554
         * decrypt message with key, then decompress
555
         *
556
         * @name   CryptTool.decipher
557
         * @function
558
         * @param  {string} key
559
         * @param  {string} password
560
         * @param  {string} data - JSON with encrypted data
561
         * @return {string} decrypted message, empty if decryption failed
562
         */
563
        me.decipher = function(key, password, data)
564
        {
565
            if (data !== undefined) {
566
                try {
567
                    return decompress(sjcl.decrypt(key, data));
568
                } catch(err) {
569
                    try {
570
                        return decompress(sjcl.decrypt(key + sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(password)), data));
571
                    } catch(e) {
572
                        return '';
573
                    }
574
                }
575
            }
576
        };
577
578
        /**
579
         * checks whether the crypt tool has collected enough entropy
580
         *
581
         * @name   CryptTool.isEntropyReady
582
         * @function
583
         * @return {bool}
584
         */
585
        me.isEntropyReady = function()
586
        {
587
            return sjcl.random.isReady();
588
        };
589
590
        /**
591
         * add a listener function, triggered when enough entropy is available
592
         *
593
         * @name   CryptTool.addEntropySeedListener
594
         * @function
595
         * @param {function} func
596
         */
597
        me.addEntropySeedListener = function(func)
598
        {
599
            sjcl.random.addEventListener('seeded', func);
600
        };
601
602
        /**
603
         * returns a random symmetric key
604
         *
605
         * @name   CryptTool.getSymmetricKey
606
         * @function
607
         * @return {string} func
608
         */
609
        me.getSymmetricKey = function()
610
        {
611
            return sjcl.codec.base64.fromBits(sjcl.random.randomWords(8, 0), 0);
612
        };
613
614
        return me;
615
    })();
616
617
    /**
618
     * (Model) Data source (aka MVC)
619
     *
620
     * @name   Model
621
     * @class
622
     */
623
    var Model = (function () {
624
        var me = {};
625
626
        var $cipherData,
627
            $templates;
628
629
        var id = null, symmetricKey = null;
630
631
        /**
632
         * returns the expiration set in the HTML
633
         *
634
         * @name   Model.getExpirationDefault
635
         * @function
636
         * @return string
637
         */
638
        me.getExpirationDefault = function()
639
        {
640
            return $('#pasteExpiration').val();
641
        };
642
643
        /**
644
         * returns the format set in the HTML
645
         *
646
         * @name   Model.getFormatDefault
647
         * @function
648
         * @return string
649
         */
650
        me.getFormatDefault = function()
651
        {
652
            return $('#pasteFormatter').val();
653
        };
654
655
        /**
656
         * check if cipher data was supplied
657
         *
658
         * @name   Model.getCipherData
659
         * @function
660
         * @return boolean
661
         */
662
        me.hasCipherData = function()
663
        {
664
            return me.getCipherData().length > 0;
665
        };
666
667
        /**
668
         * returns the cipher data
669
         *
670
         * @name   Model.getCipherData
671
         * @function
672
         * @return string
673
         */
674
        me.getCipherData = function()
675
        {
676
            return $cipherData.text();
677
        };
678
679
        /**
680
         * get the pastes unique identifier from the URL,
681
         * eg. http://example.com/path/?c05354954c49a487#dfdsdgdgdfgdf returns c05354954c49a487
682
         *
683
         * @name   Model.getPasteId
684
         * @function
685
         * @return {string} unique identifier
686
         * @throws {string}
687
         */
688
        me.getPasteId = function()
689
        {
690
            if (id === null) {
691
                id = window.location.search.substring(1);
692
693
                if (id === '') {
694
                    throw 'no paste id given';
695
                }
696
            }
697
698
            return id;
699
        };
700
701
        /**
702
         * return the deciphering key stored in anchor part of the URL
703
         *
704
         * @name   Model.getPasteKey
705
         * @function
706
         * @return {string|null} key
707
         * @throws {string}
708
         */
709
        me.getPasteKey = function()
710
        {
711
            if (symmetricKey === null) {
712
                symmetricKey = window.location.hash.substring(1);
713
714
                if (symmetricKey === '') {
715
                    throw 'no encryption key given';
716
                }
717
718
                // Some web 2.0 services and redirectors add data AFTER the anchor
719
                // (such as &utm_source=...). We will strip any additional data.
720
                var ampersandPos = symmetricKey.indexOf('&');
721
                if (ampersandPos > -1)
722
                {
723
                    symmetricKey = symmetricKey.substring(0, ampersandPos);
724
                }
725
            }
726
727
            return symmetricKey;
728
        };
729
730
        /**
731
         * returns a jQuery copy of the HTML template
732
         *
733
         * @name Model.getTemplate
734
         * @function
735
         * @param  {string} name - the name of the template
736
         * @return {jQuery}
737
         */
738
        me.getTemplate = function(name)
739
        {
740
            // find template
741
            var $element = $templates.find('#' + name + 'template').clone(true);
742
            // change ID to avoid collisions (one ID should really be unique)
743
            return $element.prop('id', name);
744
        };
745
746
        /**
747
         * resets state, used for unit testing
748
         *
749
         * @name   Model.reset
750
         * @function
751
         */
752
        me.reset = function()
753
        {
754
            $cipherData = $templates = id = symmetricKey = null;
755
        };
756
757
        /**
758
         * init navigation manager
759
         *
760
         * preloads jQuery elements
761
         *
762
         * @name   Model.init
763
         * @function
764
         */
765
        me.init = function()
766
        {
767
            $cipherData = $('#cipherdata');
768
            $templates = $('#templates');
769
        };
770
771
        return me;
772
    })();
773
774
    /**
775
     * Helper functions for user interface
776
     *
777
     * everything directly UI-related, which fits nowhere else
778
     *
779
     * @name   UiHelper
780
     * @class
781
     */
782
    var UiHelper = (function () {
783
        var me = {};
784
785
        /**
786
         * handle history (pop) state changes
787
         *
788
         * currently this does only handle redirects to the home page.
789
         *
790
         * @name   UiHelper.historyChange
791
         * @private
792
         * @function
793
         * @param  {Event} event
794
         */
795
        function historyChange(event)
796
        {
797
            var currentLocation = Helper.baseUri();
798
            if (event.originalEvent.state === null && // no state object passed
799
                event.target.location.href === currentLocation && // target location is home page
800
                window.location.href === currentLocation // and we are not already on the home page
801
            ) {
802
                // redirect to home page
803
                window.location.href = currentLocation;
804
            }
805
        }
806
807
        /**
808
         * reload the page
809
         *
810
         * This takes the user to the PrivateBin homepage.
811
         *
812
         * @name   UiHelper.reloadHome
813
         * @function
814
         */
815
        me.reloadHome = function()
816
        {
817
            window.location.href = Helper.baseUri();
818
        };
819
820
        /**
821
         * checks whether the element is currently visible in the viewport (so
822
         * the user can actually see it)
823
         *
824
         * @see    {@link https://stackoverflow.com/a/40658647}
825
         * @name   UiHelper.isVisible
826
         * @function
827
         * @param  {jQuery} $element The link hash to move to.
828
         */
829
        me.isVisible = function($element)
830
        {
831
            var elementTop = $element.offset().top;
832
            var viewportTop = $(window).scrollTop();
833
            var viewportBottom = viewportTop + $(window).height();
834
835
            return elementTop > viewportTop && elementTop < viewportBottom;
836
        };
837
838
        /**
839
         * scrolls to a specific element
840
         *
841
         * @see    {@link https://stackoverflow.com/questions/4198041/jquery-smooth-scroll-to-an-anchor#answer-12714767}
842
         * @name   UiHelper.scrollTo
843
         * @function
844
         * @param  {jQuery}           $element        The link hash to move to.
845
         * @param  {(number|string)}  animationDuration passed to jQuery .animate, when set to 0 the animation is skipped
846
         * @param  {string}           animationEffect   passed to jQuery .animate
847
         * @param  {function}         finishedCallback  function to call after animation finished
848
         */
849
        me.scrollTo = function($element, animationDuration, animationEffect, finishedCallback)
850
        {
851
            var $body = $('html, body'),
852
                margin = 50,
853
                callbackCalled = false;
854
855
            //calculate destination place
856
            var dest = 0;
857
            // if it would scroll out of the screen at the bottom only scroll it as
858
            // far as the screen can go
859
            if ($element.offset().top > $(document).height() - $(window).height()) {
860
                dest = $(document).height() - $(window).height();
861
            } else {
862
                dest = $element.offset().top - margin;
863
            }
864
            // skip animation if duration is set to 0
865
            if (animationDuration === 0) {
866
                window.scrollTo(0, dest);
867
            } else {
868
                // stop previous animation
869
                $body.stop();
870
                // scroll to destination
871
                $body.animate({
872
                    scrollTop: dest
873
                }, animationDuration, animationEffect);
874
            }
875
876
            // as we have finished we can enable scrolling again
877
            $body.queue(function (next) {
878
                if (!callbackCalled) {
879
                    // call user function if needed
880
                    if (typeof finishedCallback !== 'undefined') {
881
                        finishedCallback();
882
                    }
883
884
                    // prevent calling this function twice
885
                    callbackCalled = true;
886
                }
887
                next();
888
            });
889
        };
890
891
        /**
892
         * trigger a history (pop) state change
893
         *
894
         * used to test the UiHelper.historyChange private function
895
         *
896
         * @name   UiHelper.mockHistoryChange
897
         * @function
898
         * @param  {string} state   (optional) state to mock
899
         */
900
        me.mockHistoryChange = function(state)
901
        {
902
            if (typeof state === 'undefined') {
903
                state = null;
904
            }
905
            historyChange($.Event('popstate', {originalEvent: new PopStateEvent('popstate', {state: state}), target: window}));
906
        };
907
908
        /**
909
         * initialize
910
         *
911
         * @name   UiHelper.init
912
         * @function
913
         */
914
        me.init = function()
915
        {
916
            // update link to home page
917
            $('.reloadlink').prop('href', Helper.baseUri());
918
919
            $(window).on('popstate', historyChange);
920
        };
921
922
        return me;
923
    })();
924
925
    /**
926
     * Alert/error manager
927
     *
928
     * @name   Alert
929
     * @class
930
     */
931
    var Alert = (function () {
932
        var me = {};
933
934
        var $errorMessage,
935
            $loadingIndicator,
936
            $statusMessage,
937
            $remainingTime;
938
939
        var currentIcon;
940
941
        var alertType = [
942
            'loading', // not in bootstrap, but using a good value here
943
            'info', // status icon
944
            'warning', // not used yet
945
            'danger' // error icon
946
        ];
947
948
        var customHandler;
949
950
        /**
951
         * forwards a request to the i18n module and shows the element
952
         *
953
         * @name   Alert.handleNotification
954
         * @private
955
         * @function
956
         * @param  {int} id - id of notification
957
         * @param  {jQuery} $element - jQuery object
958
         * @param  {string|array} args
959
         * @param  {string|null} icon - optional, icon
960
         */
961
        function handleNotification(id, $element, args, icon)
962
        {
963
            // basic parsing/conversion of parameters
964
            if (typeof icon === 'undefined') {
965
                icon = null;
966
            }
967
            if (typeof args === 'undefined') {
968
                args = null;
969
            } else if (typeof args === 'string') {
970
                // convert string to array if needed
971
                args = [args];
972
            }
973
974
            // pass to custom handler if defined
975
            if (typeof customHandler === 'function') {
976
                var handlerResult = customHandler(alertType[id], $element, args, icon);
977
                if (handlerResult === true) {
978
                    // if it returns true, skip own handler
979
                    return;
980
                }
981
                if (handlerResult instanceof jQuery) {
982
                    // continue processing with new element
983
                    $element = handlerResult;
984
                    icon = null; // icons not supported in this case
985
                }
986
            }
987
988
            // handle icon
989
            if (icon !== null && // icon was passed
990
                icon !== currentIcon[id] // and it differs from current icon
991
            ) {
992
                var $glyphIcon = $element.find(':first');
993
994
                // remove (previous) icon
995
                $glyphIcon.removeClass(currentIcon[id]);
996
997
                // any other thing as a string (e.g. 'null') (only) removes the icon
998
                if (typeof icon === 'string') {
999
                    // set new icon
1000
                    currentIcon[id] = 'glyphicon-' + icon;
1001
                    $glyphIcon.addClass(currentIcon[id]);
1002
                }
1003
            }
1004
1005
            // show text
1006
            if (args !== null) {
1007
                // add jQuery object to it as first parameter
1008
                args.unshift($element);
1009
                // pass it to I18n
1010
                I18n._.apply(this, args);
1011
            }
1012
1013
            // show notification
1014
            $element.removeClass('hidden');
1015
        }
1016
1017
        /**
1018
         * display a status message
1019
         *
1020
         * This automatically passes the text to I18n for translation.
1021
         *
1022
         * @name   Alert.showStatus
1023
         * @function
1024
         * @param  {string|array} message     string, use an array for %s/%d options
1025
         * @param  {string|null}  icon        optional, the icon to show,
1026
         *                                    default: leave previous icon
1027
         */
1028
        me.showStatus = function(message, icon)
1029
        {
1030
            console.info('status shown: ', message);
1031
            handleNotification(1, $statusMessage, message, icon);
1032
        };
1033
1034
        /**
1035
         * display an error message
1036
         *
1037
         * This automatically passes the text to I18n for translation.
1038
         *
1039
         * @name   Alert.showError
1040
         * @function
1041
         * @param  {string|array} message     string, use an array for %s/%d options
1042
         * @param  {string|null}  icon        optional, the icon to show, default:
1043
         *                                    leave previous icon
1044
         */
1045
        me.showError = function(message, icon)
1046
        {
1047
            console.error('error message shown: ', message);
1048
            handleNotification(3, $errorMessage, message, icon);
1049
        };
1050
1051
        /**
1052
         * display remaining message
1053
         *
1054
         * This automatically passes the text to I18n for translation.
1055
         *
1056
         * @name   Alert.showRemaining
1057
         * @function
1058
         * @param  {string|array} message     string, use an array for %s/%d options
1059
         */
1060
        me.showRemaining = function(message)
1061
        {
1062
            console.info('remaining message shown: ', message);
1063
            handleNotification(1, $remainingTime, message);
1064
        };
1065
1066
        /**
1067
         * shows a loading message, optionally with a percentage
1068
         *
1069
         * This automatically passes all texts to the i10s module.
1070
         *
1071
         * @name   Alert.showLoading
1072
         * @function
1073
         * @param  {string|array|null} message      optional, use an array for %s/%d options, default: 'Loading…'
1074
         * @param  {string|null}       icon         optional, the icon to show, default: leave previous icon
1075
         */
1076
        me.showLoading = function(message, icon)
1077
        {
1078
            if (typeof message !== 'undefined' && message !== null) {
1079
                console.info('status changed: ', message);
1080
            }
1081
1082
            // default message text
1083
            if (typeof message === 'undefined') {
1084
                message = 'Loading…';
1085
            }
1086
1087
            handleNotification(0, $loadingIndicator, message, icon);
1088
1089
            // show loading status (cursor)
1090
            $('body').addClass('loading');
1091
        };
1092
1093
        /**
1094
         * hides the loading message
1095
         *
1096
         * @name   Alert.hideLoading
1097
         * @function
1098
         */
1099
        me.hideLoading = function()
1100
        {
1101
            $loadingIndicator.addClass('hidden');
1102
1103
            // hide loading cursor
1104
            $('body').removeClass('loading');
1105
        };
1106
1107
        /**
1108
         * hides any status/error messages
1109
         *
1110
         * This does not include the loading message.
1111
         *
1112
         * @name   Alert.hideMessages
1113
         * @function
1114
         */
1115
        me.hideMessages = function()
1116
        {
1117
            // also possible: $('.statusmessage').addClass('hidden');
1118
            $statusMessage.addClass('hidden');
1119
            $errorMessage.addClass('hidden');
1120
        };
1121
1122
        /**
1123
         * set a custom handler, which gets all notifications.
1124
         *
1125
         * This handler gets the following arguments:
1126
         * alertType (see array), $element, args, icon
1127
         * If it returns true, the own processing will be stopped so the message
1128
         * will not be displayed. Otherwise it will continue.
1129
         * As an aditional feature it can return q jQuery element, which will
1130
         * then be used to add the message there. Icons are not supported in
1131
         * that case and will be ignored.
1132
         * Pass 'null' to reset/delete the custom handler.
1133
         * Note that there is no notification when a message is supposed to get
1134
         * hidden.
1135
         *
1136
         * @name   Alert.setCustomHandler
1137
         * @function
1138
         * @param {function|null} newHandler
1139
         */
1140
        me.setCustomHandler = function(newHandler)
1141
        {
1142
            customHandler = newHandler;
1143
        };
1144
1145
        /**
1146
         * init status manager
1147
         *
1148
         * preloads jQuery elements
1149
         *
1150
         * @name   Alert.init
1151
         * @function
1152
         */
1153
        me.init = function()
1154
        {
1155
            // hide "no javascript" error message
1156
            $('#noscript').hide();
1157
1158
            // not a reset, but first set of the elements
1159
            $errorMessage = $('#errormessage');
1160
            $loadingIndicator = $('#loadingindicator');
1161
            $statusMessage = $('#status');
1162
            $remainingTime = $('#remainingtime');
1163
1164
            currentIcon = [
1165
                'glyphicon-time', // loading icon
1166
                'glyphicon-info-sign', // status icon
1167
                '', // reserved for warning, not used yet
1168
                'glyphicon-alert' // error icon
1169
            ];
1170
        };
1171
1172
        return me;
1173
    })();
1174
1175
    /**
1176
     * handles paste status/result
1177
     *
1178
     * @name   PasteStatus
1179
     * @class
1180
     */
1181
    var PasteStatus = (function () {
1182
        var me = {};
1183
1184
        var $pasteSuccess,
1185
            $pasteUrl,
1186
            $remainingTime,
1187
            $shortenButton;
1188
1189
        /**
1190
         * forward to URL shortener
1191
         *
1192
         * @name   PasteStatus.sendToShortener
1193
         * @private
1194
         * @function
1195
         */
1196
        function sendToShortener()
1197
        {
1198
            window.location.href = $shortenButton.data('shortener') +
1199
                                   encodeURIComponent($pasteUrl.attr('href'));
1200
        }
1201