Newer
Older
TriliumScripts / PerPageCrypto / PerPageCrypto.js
(() => {
    const MASTER_PASSWORD = '';  // leave empty to disable master password check, otherwise use sha256 hash
    const PASSWORD_SALT = 'mySecretSaltValue123!'; // CHANGE THIS
    
    const SimpleCrypto = require('SimpleCrypto.min.js');

    class PadlockWidget extends api.NoteContextAwareWidget {
        constructor() {
            super();

            this.$widget = $('<div class="padlock-action"></div>');

            // Container for countdown + input to keep them aligned
            this.$inputContainer = $('<div style="display:flex; align-items:center; left: -275px; position: absolute;"></div>');
            this.$widget.append(this.$inputContainer);

            // Countdown display (left of input)
            this.$countdown = $('<span class="crypto-countdown" style="margin-right:5px; cursor:pointer; width:120px;">0:00</span>');
            this.$inputContainer.append(this.$countdown);
            this.$countdown.on('click', () => this.addTimeToActiveNote(300)); // add 5 minutes on click

            // Password input
            this.$input = $('<input type="password" class="crypto-input" placeholder="password" />').css({
                display: 'inline-block',
                width: '120px',
                marginRight: '5px',
                verticalAlign: 'middle',
                padding: '3px 6px',
                fontSize: '14px',
                border: '1px solid #525252',
                borderRadius: '4px',
                boxSizing: 'border-box',
                outline: 'none',
                marginLeft:'20px'
            });
            this.$inputContainer.append(this.$input);

            // Padlock button
            this.$button = $('<button type="button" class="icon-action bx bx-lock-open-alt" title="CryptoRoM: Unlocked"></button>');
            this.$widget.append(this.$button);

            // Internal state
            this.isBusy = false;

            // Per-note storage
            this.notePasswords = {}; // noteId -> password
            this.noteTimers = {};    // noteId -> intervalId
            this.noteRemaining = {}; // noteId -> remaining seconds

            // Button click handler
            this.$button.on('click', () => this._onClick());

            // Inject CSS for widget styling
            this.injectCss();
        }

        get position() { return 100; }
        get parentWidget() { return 'note-detail-pane'; }
        get styleId() { return 'padlock-widget-styles'; }

        doRender() { return this.$widget; }

        refreshWithNote() {
            const toolbar = document.querySelector('.ribbon-button-container');
            if (!toolbar) return;

            const existing = toolbar.querySelector('.padlock-action');
            if (!existing) toolbar.prepend(this.$widget[0]);

            this.updatePadlockIcon();
            this.updateCountdownDisplayForActiveNote();
        }

        _onClick() {
            if (this.isBusy) return;

            const note = api.getActiveContextNote();
            if (!note || !note.noteId) {
                console.warn('[CryptoROM] No active note found');
                return;
            }

            this.isBusy = true;

            const isLocked = note.hasLabel && note.hasLabel('CryptoRoM');
            try {
                if (isLocked) {
                    this.decrypt(note);
                } else {
                    this.encrypt(note);
                }
            } catch (err) {
                console.error('[CryptoROM] Button click error:', err);
                this.isBusy = false;
                this.setLoading(false);
            }
}

        setLoading(isLoading) {
            this.isBusy = isLoading;
            if (isLoading) {
                this.$button.removeClass('bx-lock-alt bx-lock-open-alt')
                    .addClass('bx bx-loader-alt bx-spin')
                    .attr('title', 'Processing...');
            }
        }

        updateNoteContent(noteId, manipulateFn, lockFlag) {
            if (!noteId) return Promise.reject('No noteId');

            return api.runOnBackend((noteId) => {
                const note = api.getNote(noteId);
                if (!note) return null;
                return note.getContent();
            }, [noteId]).then((originalContent) => {
                if (originalContent == null) throw new Error('Failed to read note content');
                const newContent = manipulateFn(originalContent);

                return api.runOnBackend((noteId, content, lockFlag) => {
                    const note = api.getNote(noteId);
                    if (!note) return;
                    note.setContent(content);
                    if (lockFlag) {
                        if (!note.hasLabel('CryptoRoM')) note.addLabel('CryptoRoM');
                    } else {
                        if (note.hasLabel('CryptoRoM')) note.removeLabel('CryptoRoM');
                    }
                    note.save();
                }, [noteId, newContent, lockFlag]);
            });
        }

        encryptNoteId(noteId) {
            if (!noteId) {
                return;
            }

            const note = api.getNote(noteId);
            if (!note) {
                console.warn('[CryptoROM] Could not fetch note for encryption', noteId);
                return;
            }

            const inputPassword = this.$input.val();
            if (inputPassword) {
                this.notePasswords[noteId] = inputPassword; // store for future
            }

            const password = this.notePasswords[noteId];
            if (!password) {
                console.warn('[CryptoROM] No stored password for note', noteId);
                return;
            }

            this.setLoading(true);
            this.updateNoteContent(noteId, content => {

                const saltedPassword = password + PASSWORD_SALT;
                
                const encrypted = new SimpleCrypto(saltedPassword).encrypt(content);
                return encrypted;
            }, true)
            .then(() => {
                this.updatePadlockIcon();
                if (inputPassword) this.$input.val('');
            })
            .finally(() => this.setLoading(false));
        }

        encrypt(note) {
            const noteId = note.noteId;

            const inputPassword = this.$input.val();
            if (inputPassword) this.notePasswords[noteId] = inputPassword;

            const password = this.notePasswords[noteId];
            if (!password) {
                console.warn('[CryptoROM] No password provided for encryption');
                this.$input.val('');
                this.isBusy = false;
                return;
            }

            const flashError = () => {
                this.$input.addClass('crypto-input-error');
                setTimeout(() => this.$input.removeClass('crypto-input-error'), 600);
                this.$input.val('');
            };

            // Skip master check if empty
            if (!MASTER_PASSWORD) {
                this.performEncryption(noteId, password, inputPassword);
                return;
            }

            const parts = password.split(':');
            if (parts.length !== 2) {
                flashError();
                this.isBusy = false;
                return;
            }

            const [masterPart, userPart] = parts;
            const encoder = new TextEncoder();

            crypto.subtle.digest('SHA-256', encoder.encode(masterPart))
                .then(hashBuffer => {
                    const hashHex = Array.from(new Uint8Array(hashBuffer))
                        .map(b => b.toString(16).padStart(2, '0'))
                        .join('');

                    if (hashHex !== MASTER_PASSWORD) {
                        console.warn('[CryptoROM] Master password mismatch');
                        flashError();
                        this.isBusy = false;
                        throw new Error('Master password incorrect');
                    }

                    // Verified, perform encryption
                    this.performEncryption(noteId, userPart, inputPassword);
                })
                .catch(err => {
                    console.error('[CryptoROM] Encryption aborted:', err);
                    flashError();
                    this.isBusy = false;
                    this.setLoading(false);
                });
        }

        performEncryption(noteId, password, inputPassword) {
            const note = api.getNote(noteId);
            if (!note) {
                console.error('[CryptoROM] No note found for encryption');
                return;
            }

            this.clearTimer(noteId);
            this.setLoading(true);

            this.updateNoteContent(noteId, content => {
                const saltedPassword = password + PASSWORD_SALT;
                const encrypted = new SimpleCrypto(saltedPassword).encrypt(content);
                return encrypted;
            }, true)
            .then(() => {
                this.updatePadlockIcon();
                if (inputPassword) this.$input.val('');
                delete this.notePasswords[noteId];
            })
            .finally(() => this.setLoading(false));
        }



        decrypt(note) {
            const noteId = note.noteId;

            const password = this.$input.val();
            if (!password) {
                alert('Please enter a password for decryption.');
                this.isBusy = false;
                return;
            }

            // Store password for timer-based re-encryption
            this.notePasswords[noteId] = password;
            this.setLoading(true);

            this.updateNoteContent(noteId, content => {
                let decrypted;
                try {
                    const saltedPassword = password + PASSWORD_SALT;
                    decrypted = new SimpleCrypto(saltedPassword).decrypt(content);
                } catch (err) {
                    this.$input.val(''); // clear input
                    // Flash input red if decryption fails
                    this.$input.addClass('crypto-input-error');
                    setTimeout(() => this.$input.removeClass('crypto-input-error'), 600);
                    throw err;
                }
                return decrypted;
            }, false)
            .then(() => {
                this.updatePadlockIcon();
                this.$input.val(''); // clear input

                this.startTimer(noteId);
            })
            .catch(err => {
                this.updatePadlockIcon();
            })
            .finally(() => this.setLoading(false));
        }
        
        
        startTimer(noteId) {
            this.clearTimer(noteId);

            this.noteRemaining[noteId] = 600; // 10 minutes (in seconds)
            this.updateCountdownDisplayForActiveNote();

            this.noteTimers[noteId] = setInterval(() => {
                this.noteRemaining[noteId]--;
                this.updateCountdownDisplayForActiveNote();

                if (this.noteRemaining[noteId] <= 0) {
                    this.clearTimer(noteId);
                    this.encryptNoteId(noteId);
                }
            }, 1000);
        }
        
        clearTimer(noteId) {
            if (this.noteTimers[noteId]) {
                clearInterval(this.noteTimers[noteId]);
                delete this.noteTimers[noteId];
                delete this.noteRemaining[noteId];
                this.updateCountdownDisplayForActiveNote();
            }
        }


        addTimeToActiveNote(seconds) {
            const note = api.getActiveContextNote();
            if (!note || !note.noteId) return;

            const noteId = note.noteId;
            if (this.noteRemaining[noteId] != null) {
                this.noteRemaining[noteId] += seconds;
                this.updateCountdownDisplayForActiveNote();
            }
        }

        clearTimer(noteId) {
            if (this.noteTimers[noteId]) {
                clearInterval(this.noteTimers[noteId]);
                delete this.noteTimers[noteId];
                delete this.noteRemaining[noteId];
                this.updateCountdownDisplayForActiveNote();
            }
        }

        updateCountdownDisplayForActiveNote() {
            const note = api.getActiveContextNote();
            if (!note || !note.noteId) {
                this.$countdown.text('');
                return;
            }

            const remaining = this.noteRemaining[note.noteId];
            if (remaining != null) {
                const min = Math.floor(remaining / 60);
                const sec = remaining % 60;
                this.$countdown.text(`${min}:${sec.toString().padStart(2,'0')}`);
            } else {
                this.$countdown.text('');
            }
        }

        flashInputError() {
            const $input = this.$input;
            $input.addClass('crypto-input-error');
            setTimeout(() => $input.removeClass('crypto-input-error'), 600);
            $input.val('');
        }

        updatePadlockIcon() {
            const note = api.getActiveContextNote();
            if (!note) return;

            const isLocked = note.hasLabel && note.hasLabel('CryptoRoM');
            this.$button.removeClass('bx-lock-alt bx-lock-open-alt bx-loader-alt bx-spin')
                .addClass(isLocked ? 'bx-lock-alt' : 'bx-lock-open-alt')
                .attr('title', isLocked ? 'CryptoRoM: Locked' : 'CryptoRoM: Unlocked');

            const $noteDetail = $('.note-detail');
            if (isLocked) {
                $noteDetail.css('display', 'none');
                $('.note-detail.crypto-placeholder').remove();
                const $placeholder = $(`
                    <div class="note-detail component crypto-placeholder" style="padding:10px;margin-bottom:10px;text-align:center">
                        <strong>Encrypted Content</strong><br>
                        This note is encrypted. Unlock to view content.
                    </div>
                `);
                $noteDetail.before($placeholder);
            } else {
                $noteDetail.css('display', '');
                $('.note-detail.crypto-placeholder').remove();
            }
        }

        injectCss() {
            if ($('#' + this.styleId).length) return;
            $('<style>').attr('id', this.styleId).html(`
                .padlock-action { display:inline-flex; align-items:center; margin:0; padding:0; margin-right:20px; }
                .padlock-action .crypto-input { display:inline-block; width:120px; margin-right:5px; vertical-align:middle; padding:3px 6px; font-size:14px; border:1px solid #ccc; border-radius:4px; box-sizing:border-box; }
                .padlock-action .bx-lock-alt, .padlock-action .bx-lock-open-alt, .padlock-action .bx-loader-alt { font-size:18px; opacity:0.8; cursor:pointer; transition:opacity 0.2s ease; }
                .padlock-action .bx-lock-alt:hover, .padlock-action .bx-lock-open-alt:hover { opacity:1; }
                .bx-spin { animation:bx-spin 1s linear infinite; }
                @keyframes bx-spin { 100% { transform:rotate(360deg); } }
                .padlock-action .bx-lock-alt { color: rgb(102,204,102); }
                .padlock-action .bx-lock-open-alt { color: var(--main-text-color); }
                .padlock-action .bx-loader-alt { color: var(--accent-color,#999); }
                .crypto-input-error { border-color:red !important; animation:flash-border 0.6s ease-in-out; }
                @keyframes flash-border { 0%,100%{border-color:red;} 50%{border-color:#ccc;} }
                .crypto-countdown { font-size:12px; color:bbb; min-width:35px; text-align:right; display:inline-block; cursor:pointer; }
		 .icon-action:hover{ border: none; }
                .padlock-action button {
                    outline: none;
                    border: none;
                    padding: 0;
                    margin: 0;
                }
            `).appendTo('head');
        }

        onunload() {
            Object.keys(this.noteTimers).forEach(id => clearInterval(this.noteTimers[id]));
            if (this.$widget) this.$widget.remove();
            $('#' + this.styleId).remove();
        }
    }

    module.exports = new PadlockWidget();
})();

async function hashPassword(password) {
  const data = new TextEncoder().encode(password);
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}