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