<commit_before>/*    Copyright 2020 Firewalla INC
 *
 *    This program is free software: you can redistribute it and/or  modify
 *    it under the terms of the GNU Affero General Public License, version 3,
 *    as published by the Free Software Foundation.
 *
 *    This program is distributed in the hope that it will be useful,
 *    but WITHOUT ANY WARRANTY; without even the implied warranty of
 *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *    GNU Affero General Public License for more details.
 *
 *    You should have received a copy of the GNU Affero General Public License
 *    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

'use strict';

const log = require('../net2/logger.js')(__filename);
const Sensor = require('./Sensor.js').Sensor;
const exec = require('child-process-promise').exec;
const { date } = require('later');
const extensionManager = require('./ExtensionManager.js')
const sysManager = require('../net2/SysManager.js');

const fs = require('fs');
const Promise = require('bluebird');
Promise.promisifyAll(fs);

const f = require('../net2/Firewalla.js');
const fc = require('../net2/config.js');
const platform = require('../platform/PlatformLoader.js').getPlatform();
const COLLECTOR_DIR = f.getFirewallaHome()+"/scripts/event_collectors";
const FEATURE_EVENT = "event_collect";
const era = require('../event/EventRequestApi.js');
const ea = require('../event/EventApi.js');
const um = require('../net2/UpgradeManager.js');

class EventSensor extends Sensor {

    constructor() {
        super();
        this.scheduledJobs = {};
    }

    async apiRun() {

        if ( ! platform.isEventsSupported() ) {
            log.warn(`${FEATURE_EVENT} NOT supported in this platform`);
            return;
        }

        extensionManager.onGet("events", async (msg, data) => {
            try {
                log.info(`processing onGet events with data(${JSON.stringify(data)})`);
                let result = await ea.listEvents(data.min,data.max,data.withscores,data.limit_offset,data.limit_count,data.reverse);
                return result;
            } catch (err) {
                log.error(`failed to list events with ${JSON.stringify(data)}, ${err}`);
            }
        });

        extensionManager.onGet("latestAllStateEvents", async (msg, data) => {
            try {
                log.info(`processing onGet latest events with data(${JSON.stringify(data)})`);
                let result = await ea.listLatestStateEventsAll();
                return result;
            } catch (err) {
                log.error(`failed to list latest all events with ${JSON.stringify(data)}, ${err}`);
            }
        });

        extensionManager.onGet("latestErrorStateEvents", async (msg, data) => {
            try {
                log.info(`processing onGet latest error events with data(${JSON.stringify(data)})`);
                let result = await ea.listLatestStateEventsError();
                return result;
            } catch (err) {
                log.error(`failed to list latest error events with ${JSON.stringify(data)}, ${err}`);
            }
        });
    }

    async run() {
        if ( ! platform.isEventsSupported() ) {
            log.warn(`${FEATURE_EVENT} NOT supported in this platform`);
            return;
        }

        /*
         * IMPORTANT
         * Only initialize EventRequestHandler in run() for FireMain
         * If done in class level, it will cause a duplicate handler of redis channel
         */
        const erh = require('../event/EventRequestHandler');
        log.info("Run EventSensor")
        if ( fc.isFeatureOn(FEATURE_EVENT) ) {
            this.startCollectEvents();
        } else {
            this.stopCollectEvents();
        }
        fc.onFeature(FEATURE_EVENT, (feature, status) =>{
            if (feature != FEATURE_EVENT) return
            if (status) {
                this.startCollectEvents();
            } else {
                this.stopCollectEvents();
            }
        })
    }

    getConfiguredInterval(name) {
        return (name in this.config.intervals) ?
            this.config.intervals[name] : this.config.intervals.default;
    }

    async checkReboot() {
       const REBOOT_FLAG_FILE = '/dev/shm/system_reboot.touch';
       try {
           log.info("check system reboot ...");
           if (fs.existsSync(REBOOT_FLAG_FILE)) {
               log.debug("system reboot processed before, NO more action event needed");
           } else {
               log.debug("system reboot not processed yet, sending action event");
               era.addActionEvent("system_reboot",1);
               await fs.writeFileAsync(REBOOT_FLAG_FILE,'');
           }
       } catch (err) {
           log.error("failed to check reboot:",err);
       }
    }

    async startCollectEvents() {
        try {
            log.info("start collect events...");
            await this.checkReboot();
            this.scheduledJSJobs();
            await this.scheduleScriptCollectors();
        } catch (err) {
            log.error("failed to start collect events:", err);
        }
    }

    async stopCollectEvents() {
        try {
            log.info("Stop collecting events...");
            for (const job in this.scheduledJobs) {
                log.info(`Stop collecting ${job}`);
                clearInterval(this.scheduledJobs[job]);
            }
        } catch (err) {
            log.error("failed to start collect events:", err);
        }
    }

    scheduledJSJobs() {
        const JS_JOBS = ['cleanEventsByTime', 'cleanEventsByCount'];
        for (const jsjob of JS_JOBS) {
            log.info(`Scheduling ${jsjob} every ${this.getConfiguredInterval(jsjob)} seconds`);
            this.scheduledJobs[jsjob] = setInterval( async() => {
                await this[jsjob]();
            }, 1000*this.getConfiguredInterval(jsjob));
        }
    }

    scheduleScriptCollector(collector) {
        let scheduledJob = null;
        try {
            const collectorInterval = this.getConfiguredInterval(collector);
            if ( collectorInterval < 1) {
                log.warn(`${collector} is NOT scheduled with interval of ${collectorInterval}`);
            } else {
                log.info(`Scheduling ${collector} every ${collectorInterval} seconds`);
                scheduledJob = setInterval(async () => {
                    await this.collectEvent(collector);
                }, 1000*collectorInterval);
            }
        } catch (err) {
            log.error(`failed to schedule ${collector}:`, err);
        }
        return scheduledJob;
    }

    async scheduleScriptCollectors() {
        log.info("Scheduling all script collectors in ", COLLECTOR_DIR);
        try {
            const collectors = await fs.readdirAsync(COLLECTOR_DIR);
            for (const collector of collectors) {
                const scheduledJob = this.scheduleScriptCollector(collector);
                if (scheduledJob) this.scheduledJobs[collector] = scheduledJob;
            }
        } catch (err) {
            log.error(`failed to schedule collectors under ${COLLECTOR_DIR}: ${err}`);
        }
    }

    async cleanEventsByTime() {
        try {
            log.info(`clean events before ${this.config.eventsExpire} seconds`);
            await era.cleanEventsByTime(0, Date.now()-1000*this.config.eventsExpire );
        } catch (err) {
            log.error(`failed to clean events before ${this.config.eventsExpire} seconds: ${err}`);
        }
    }

    async cleanEventsByCount() {
        try {
            log.info("Start cleaning events by count...");
            const currentCount = await ea.getEventsCount();
            log.info("currentCount:", currentCount);
            const cleanCount = currentCount - this.config.eventsLimit;
            if ( cleanCount > 0 ) {
                log.info(`clean oldest ${cleanCount} events`);
                await era.cleanEventsByCount(cleanCount);
            } else {
                log.debug(`current_events_count(${currentCount}) <= clean_limit(${this.config.eventsLimit}), NO need to clean`)
            }
        } catch (err) {
            log.error(`failed to clean events over count of ${this.config.eventsLimit}: ${err}`);
        }
    }

    /*
     * Supported collector output:
     * 1) JSON output
     *   STATE:
     *     { "event_type"  : "state",
     *       "state_type"  : <state_type>,
     *       "state_key"   : <state_key>,
     *       "state_value" : <state_value>,
     *       "labels"      : {...}
     *     }
     *   ACTION:
     *     { "event_type"  : "action",
     *       "action_type"  : <action_type>,
     *       "action_value" : <action_value>,
     *       "labels"      : {...}
     *     }
     * 2) Simple output
     *   STATE:
     *     state <state_type> <state_key> <state_value> [<label1>=<label1_value> [<label2>=<label2_value> ...]]
     *   ACTION:
     *     action <action_type> <state_value> [<label1>=<label1_value> [<label2>=<label2_value> ...]]
     */
    async collectEvent(collector) {
        try{
            log.info(`Collect event with ${collector}`);
            // get collector output
            const result = await exec(`${COLLECTOR_DIR}/${collector}`);

            // try to parse as JSON if possible
            let result_obj = null
            try {
                result_obj = JSON.parse(result.stdout);
                this.processJSONOutput(result_obj);
            } catch (err) {
                if (result_obj === null) {
                    this.processSimpleOutput(result.stdout);
                }
            }
        } catch (err) {
            log.error(`failed to collect event with ${collector},${err}`);
        }
    }

    processJSONOutput(result_obj) {
        switch (result_obj.event_type) {
            case 'state':
                era.addStateEvent(result_obj.state_type,result_obj.state_key,parseFloat(result_obj.state_value),result_obj.labels);
                break;
            case 'action':
                era.addActionEvent(result_obj.action_type,parseFloat(result_obj.action_value),result_obj.labels);
                break;
        }

    }

    processSimpleOutput(output) {
        // trigger events API with parameters line by line
        output.split("\n").forEach( (line) => {
            log.debug("output line:", line);
            let eventLabels = null;
            const words = line.split(/\s+/);
            switch (words[0]) {
                case 'state':
                    eventLabels = this.getEventLabels(words.slice(4));
                    era.addStateEvent(words[1],words[2],parseFloat(words[3]),eventLabels);
                    break;
                case 'action':
                    eventLabels = this.getEventLabels(words.slice(3));
                    era.addActionEvent(words[1],parseFloat(words[2]),eventLabels);
                    break;
            }
        })
    }

    getEventLabels(words) {
        if (words.length === 0) {
            return null;
        }
        return words.reduce(
            (acc,cur) => {
                const kv = cur.split('=');
                acc[kv[0]] = kv[1];
                return acc;
            },
            {}
        );
    }

    async pingGateway() {
        log.info(`try to ping gateways...`);
        const PACKET_COUNT = 8;
        const stateType = "ping";
        const labels = {"packet_count": PACKET_COUNT};
        for (const gw of sysManager.myGateways() ) {
            try {
                log.debug(`ping ${gw}`);
                const result = await exec(`ping -n -c ${PACKET_COUNT} -W 3 ${gw}`);
                for (const line of result.stdout.split("\n")) {
                    const found = line.match(/ ([0-9]+)% packet loss/);
                    if (found) {
                        labels.loss_rate = found[1];
                        break
                    }
                }
                era.addStateEvent(stateType,gw,0,labels);
            } catch (err) {
                log.error(`failed to ping ${gw}: ${err}`);
                era.addStateEvent(stateType,gw,1,labels);
            }
        }
    }

    async digDNS() {
        log.info(`try to dig DNS...`);
        const stateType = "dns";
        for (const dns of sysManager.myDnses() ) {
            try {
                await exec(`dig @${dns} google.com +short`);
                era.addStateEvent(stateType,dns,0);
            } catch (err) {
                log.error(`failed to dig ${dns}: ${err}`);
                era.addStateEvent(stateType,dns,1);
            }
        }
    }

}

module.exports = EventSensor;
<commit_msg>support parse JSON in result with "parse_json":true for events APIs<commit_after>/*    Copyright 2020 Firewalla INC
 *
 *    This program is free software: you can redistribute it and/or  modify
 *    it under the terms of the GNU Affero General Public License, version 3,
 *    as published by the Free Software Foundation.
 *
 *    This program is distributed in the hope that it will be useful,
 *    but WITHOUT ANY WARRANTY; without even the implied warranty of
 *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *    GNU Affero General Public License for more details.
 *
 *    You should have received a copy of the GNU Affero General Public License
 *    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

'use strict';

const log = require('../net2/logger.js')(__filename);
const Sensor = require('./Sensor.js').Sensor;
const exec = require('child-process-promise').exec;
const { date } = require('later');
const extensionManager = require('./ExtensionManager.js')
const sysManager = require('../net2/SysManager.js');

const fs = require('fs');
const Promise = require('bluebird');
Promise.promisifyAll(fs);

const f = require('../net2/Firewalla.js');
const fc = require('../net2/config.js');
const platform = require('../platform/PlatformLoader.js').getPlatform();
const COLLECTOR_DIR = f.getFirewallaHome()+"/scripts/event_collectors";
const FEATURE_EVENT = "event_collect";
const era = require('../event/EventRequestApi.js');
const ea = require('../event/EventApi.js');
const um = require('../net2/UpgradeManager.js');

class EventSensor extends Sensor {

    constructor() {
        super();
        this.scheduledJobs = {};
    }

    async apiRun() {

        if ( ! platform.isEventsSupported() ) {
            log.warn(`${FEATURE_EVENT} NOT supported in this platform`);
            return;
        }

        extensionManager.onGet("events", async (msg, data) => {
            try {
                log.info(`processing onGet events with data(${JSON.stringify(data)})`);
                let result = await ea.listEvents(data.min,data.max,data.withscores,data.limit_offset,data.limit_count,data.reverse);
                if (data.parse_json) {
                    result = result.map( x=> JSON.parse(x) );
                }
                return result;
            } catch (err) {
                log.error(`failed to list events with ${JSON.stringify(data)}, ${err}`);
            }
        });

        extensionManager.onGet("latestAllStateEvents", async (msg, data) => {
            try {
                log.info(`processing onGet latest events with data(${JSON.stringify(data)})`);
                let result = await ea.listLatestStateEventsAll();
                if (data.parse_json) {
                    Object.keys(result).forEach( (k)=>{result[k] = JSON.parse(result[k]) });
                }
                return result;
            } catch (err) {
                log.error(`failed to list latest all events with ${JSON.stringify(data)}, ${err}`);
            }
        });

        extensionManager.onGet("latestErrorStateEvents", async (msg, data) => {
            try {
                log.info(`processing onGet latest error events with data(${JSON.stringify(data)})`);
                let result = await ea.listLatestStateEventsError();
                if (data.parse_json) {
                    Object.keys(result).forEach( (k)=>{result[k] = JSON.parse(result[k]) });
                }
                return result;
            } catch (err) {
                log.error(`failed to list latest error events with ${JSON.stringify(data)}, ${err}`);
            }
        });
    }

    async run() {
        if ( ! platform.isEventsSupported() ) {
            log.warn(`${FEATURE_EVENT} NOT supported in this platform`);
            return;
        }

        /*
         * IMPORTANT
         * Only initialize EventRequestHandler in run() for FireMain
         * If done in class level, it will cause a duplicate handler of redis channel
         */
        const erh = require('../event/EventRequestHandler');
        log.info("Run EventSensor")
        if ( fc.isFeatureOn(FEATURE_EVENT) ) {
            this.startCollectEvents();
        } else {
            this.stopCollectEvents();
        }
        fc.onFeature(FEATURE_EVENT, (feature, status) =>{
            if (feature != FEATURE_EVENT) return
            if (status) {
                this.startCollectEvents();
            } else {
                this.stopCollectEvents();
            }
        })
    }

    getConfiguredInterval(name) {
        return (name in this.config.intervals) ?
            this.config.intervals[name] : this.config.intervals.default;
    }

    async checkReboot() {
       const REBOOT_FLAG_FILE = '/dev/shm/system_reboot.touch';
       try {
           log.info("check system reboot ...");
           if (fs.existsSync(REBOOT_FLAG_FILE)) {
               log.debug("system reboot processed before, NO more action event needed");
           } else {
               log.debug("system reboot not processed yet, sending action event");
               era.addActionEvent("system_reboot",1);
               await fs.writeFileAsync(REBOOT_FLAG_FILE,'');
           }
       } catch (err) {
           log.error("failed to check reboot:",err);
       }
    }

    async startCollectEvents() {
        try {
            log.info("start collect events...");
            await this.checkReboot();
            this.scheduledJSJobs();
            await this.scheduleScriptCollectors();
        } catch (err) {
            log.error("failed to start collect events:", err);
        }
    }

    async stopCollectEvents() {
        try {
            log.info("Stop collecting events...");
            for (const job in this.scheduledJobs) {
                log.info(`Stop collecting ${job}`);
                clearInterval(this.scheduledJobs[job]);
            }
        } catch (err) {
            log.error("failed to start collect events:", err);
        }
    }

    scheduledJSJobs() {
        const JS_JOBS = ['cleanEventsByTime', 'cleanEventsByCount'];
        for (const jsjob of JS_JOBS) {
            log.info(`Scheduling ${jsjob} every ${this.getConfiguredInterval(jsjob)} seconds`);
            this.scheduledJobs[jsjob] = setInterval( async() => {
                await this[jsjob]();
            }, 1000*this.getConfiguredInterval(jsjob));
        }
    }

    scheduleScriptCollector(collector) {
        let scheduledJob = null;
        try {
            const collectorInterval = this.getConfiguredInterval(collector);
            if ( collectorInterval < 1) {
                log.warn(`${collector} is NOT scheduled with interval of ${collectorInterval}`);
            } else {
                log.info(`Scheduling ${collector} every ${collectorInterval} seconds`);
                scheduledJob = setInterval(async () => {
                    await this.collectEvent(collector);
                }, 1000*collectorInterval);
            }
        } catch (err) {
            log.error(`failed to schedule ${collector}:`, err);
        }
        return scheduledJob;
    }

    async scheduleScriptCollectors() {
        log.info("Scheduling all script collectors in ", COLLECTOR_DIR);
        try {
            const collectors = await fs.readdirAsync(COLLECTOR_DIR);
            for (const collector of collectors) {
                const scheduledJob = this.scheduleScriptCollector(collector);
                if (scheduledJob) this.scheduledJobs[collector] = scheduledJob;
            }
        } catch (err) {
            log.error(`failed to schedule collectors under ${COLLECTOR_DIR}: ${err}`);
        }
    }

    async cleanEventsByTime() {
        try {
            log.info(`clean events before ${this.config.eventsExpire} seconds`);
            await era.cleanEventsByTime(0, Date.now()-1000*this.config.eventsExpire );
        } catch (err) {
            log.error(`failed to clean events before ${this.config.eventsExpire} seconds: ${err}`);
        }
    }

    async cleanEventsByCount() {
        try {
            log.info("Start cleaning events by count...");
            const currentCount = await ea.getEventsCount();
            log.info("currentCount:", currentCount);
            const cleanCount = currentCount - this.config.eventsLimit;
            if ( cleanCount > 0 ) {
                log.info(`clean oldest ${cleanCount} events`);
                await era.cleanEventsByCount(cleanCount);
            } else {
                log.debug(`current_events_count(${currentCount}) <= clean_limit(${this.config.eventsLimit}), NO need to clean`)
            }
        } catch (err) {
            log.error(`failed to clean events over count of ${this.config.eventsLimit}: ${err}`);
        }
    }

    /*
     * Supported collector output:
     * 1) JSON output
     *   STATE:
     *     { "event_type"  : "state",
     *       "state_type"  : <state_type>,
     *       "state_key"   : <state_key>,
     *       "state_value" : <state_value>,
     *       "labels"      : {...}
     *     }
     *   ACTION:
     *     { "event_type"  : "action",
     *       "action_type"  : <action_type>,
     *       "action_value" : <action_value>,
     *       "labels"      : {...}
     *     }
     * 2) Simple output
     *   STATE:
     *     state <state_type> <state_key> <state_value> [<label1>=<label1_value> [<label2>=<label2_value> ...]]
     *   ACTION:
     *     action <action_type> <state_value> [<label1>=<label1_value> [<label2>=<label2_value> ...]]
     */
    async collectEvent(collector) {
        try{
            log.info(`Collect event with ${collector}`);
            // get collector output
            const result = await exec(`${COLLECTOR_DIR}/${collector}`);

            // try to parse as JSON if possible
            let result_obj = null
            try {
                result_obj = JSON.parse(result.stdout);
                this.processJSONOutput(result_obj);
            } catch (err) {
                if (result_obj === null) {
                    this.processSimpleOutput(result.stdout);
                }
            }
        } catch (err) {
            log.error(`failed to collect event with ${collector},${err}`);
        }
    }

    processJSONOutput(result_obj) {
        switch (result_obj.event_type) {
            case 'state':
                era.addStateEvent(result_obj.state_type,result_obj.state_key,parseFloat(result_obj.state_value),result_obj.labels);
                break;
            case 'action':
                era.addActionEvent(result_obj.action_type,parseFloat(result_obj.action_value),result_obj.labels);
                break;
        }

    }

    processSimpleOutput(output) {
        // trigger events API with parameters line by line
        output.split("\n").forEach( (line) => {
            log.debug("output line:", line);
            let eventLabels = null;
            const words = line.split(/\s+/);
            switch (words[0]) {
                case 'state':
                    eventLabels = this.getEventLabels(words.slice(4));
                    era.addStateEvent(words[1],words[2],parseFloat(words[3]),eventLabels);
                    break;
                case 'action':
                    eventLabels = this.getEventLabels(words.slice(3));
                    era.addActionEvent(words[1],parseFloat(words[2]),eventLabels);
                    break;
            }
        })
    }

    getEventLabels(words) {
        if (words.length === 0) {
            return null;
        }
        return words.reduce(
            (acc,cur) => {
                const kv = cur.split('=');
                acc[kv[0]] = kv[1];
                return acc;
            },
            {}
        );
    }

    async pingGateway() {
        log.info(`try to ping gateways...`);
        const PACKET_COUNT = 8;
        const stateType = "ping";
        const labels = {"packet_count": PACKET_COUNT};
        for (const gw of sysManager.myGateways() ) {
            try {
                log.debug(`ping ${gw}`);
                const result = await exec(`ping -n -c ${PACKET_COUNT} -W 3 ${gw}`);
                for (const line of result.stdout.split("\n")) {
                    const found = line.match(/ ([0-9]+)% packet loss/);
                    if (found) {
                        labels.loss_rate = found[1];
                        break
                    }
                }
                era.addStateEvent(stateType,gw,0,labels);
            } catch (err) {
                log.error(`failed to ping ${gw}: ${err}`);
                era.addStateEvent(stateType,gw,1,labels);
            }
        }
    }

    async digDNS() {
        log.info(`try to dig DNS...`);
        const stateType = "dns";
        for (const dns of sysManager.myDnses() ) {
            try {
                await exec(`dig @${dns} google.com +short`);
                era.addStateEvent(stateType,dns,0);
            } catch (err) {
                log.error(`failed to dig ${dns}: ${err}`);
                era.addStateEvent(stateType,dns,1);
            }
        }
    }

}

module.exports = EventSensor;
<|endoftext|>


<commit_before>/**
 * This file is part of LYME (Low Key Markup Editor).
 *
 * (c) Philippe Gerber <philippe@bigwhoop.ch>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
$.fn.lyme = function(userOptions) {
    var lyme = this;

    var defaultOptions = {
        text           : '',
        onMarkupChange : null,
        renderer       : new $.fn.lyme.renderers.JSMarkdownExtra(),
        plugins        : [ new $.fn.lyme.plugins.ScrollTo() ],
        hotKeys        : $.fn.lyme.hotKeys
    };

    var options = $.extend(defaultOptions, userOptions);

    // If the 'text' option is a jQuery object, we retrieve the value
    // from this object and setup a plugin to update the underlying
    // element whenever the text changes.
    if (options.text instanceof $) {
        var $e = options.text;
        options.text = $e.val();
        options.plugins.push(new $.fn.lyme.plugins.ValueUpdater($e, false));
    }

    // Wrapper function to put 'onMarkupChange' option into a plugin.
    if ($.isFunction(options.onMarkupChange)) {
        options.plugins.push({
            onMarkupChange : options.onMarkupChange
        });
    }

    /**
     * @param {String} event
     * @param {Array} data
     */
    function informPlugins(event, data) {
        if (!$.isArray(data)) {
            data = [];
        }

        options.plugins.forEach(function(plugin) {
            if ($.isFunction(plugin[event])) {
                plugin[event].apply(null, data);
            }
        });
    }

    /**
     * Creates blocks by splitting up text by "paragraphs" (two linebreaks).
     *
     * @param {String} text
     * @returns {Array}
     */
    function splitText(text) {
        text = text.replace(/\r/g, '');
        text = text.replace(/\n\n\n/g, "\n\n");
        return text.split("\n\n");
    }

    /**
     * Generate a random ID to assign to each block
     *
     * @returns {Number}
     */
    function generateRandomBlockId() {
        return Math.floor(Math.random() * 999999);
    }

    /**
     * Hide edit controls
     */
    lyme.hideEditor = function() {
        $('.lyme-block .markup:visible textarea').blur();
        $('.lyme-block .markup:visible').hide();
        $('.lyme-block .preview').show();
    };

    /**
     * Render a block
     *
     * @param {String} blockText
     * @param {String} blockHTML        The HTML representation of blockText (optional)
     * @returns {$.fn.lyme.Block}
     */
    lyme.createBlock = function(blockText, blockHTML) {
        if (!blockHTML) {
            blockHTML = options.renderer.render(blockText);
        }

        var blockId   = generateRandomBlockId(),
            $block    = $('
'),
            $markup   = $('

'),
            $textarea = $('<textarea></textarea>'),
            $preview  = $('

');

        $textarea.val(blockText);
        $preview.html(blockHTML);

        $markup.hide().append($textarea);
        $block.append($markup).append($preview);

        var block = new $.fn.lyme.Block($block);

        // Helper function to update the preview text of the current block.
        function updatePreviewText() {
            var text           = $textarea.val(),
                newBlocks      = splitText(text),
                $appendToBlock = $block;

            $.each(newBlocks, function(idx, newBlockText) {
                var newBlockHTML = options.renderer.render(newBlockText);

                // Replace current block
                if (idx == 0) {
                    $textarea.val(newBlockText);
                    $preview.html(newBlockHTML);
                }

                // Append new blocks
                else {
                    var newBlock = lyme.createBlock(newBlockText, newBlockHTML);
                    $appendToBlock.after(newBlock.getElement());
                    $appendToBlock = newBlock.getElement();
                }
            });

            informPlugins('onMarkupChange', [getFullMarkup(), getFullHTML()]);
        }

        // We update the preview text every time the focus of the textarea is lost.
        $textarea.on('focusout', function() {
            informPlugins('onPreStopEditing', [block]);
            if ($textarea.val() == '') {
                $block.remove();
            }
            updatePreviewText();
            informPlugins('onPostStopEditing', [block]);
        });

        // If a key is pressed while editing a block, we check all the registered hot keys.
        $textarea.on('keydown', function(e) {
            for (var hotKeyName in options.hotKeys) {
                var hotKey = options.hotKeys[hotKeyName];
                if (hotKey.if(e)) {
                    e.preventDefault();
                    hotKey.do(lyme, block);
                }
            }
        });

        // If the preview box is clicked, we show the textarea to make the block editable.
        $preview.on('click', function() {
            lyme.hideEditor();
            informPlugins('onPreStartEditing', [block]);
            $preview.hide();
            $markup.show();
            $textarea.focus();
            informPlugins('onPostStartEditing', [block]);
        });

        return block;
    };

    /**
     * Return the Markup from all blocks.
     *
     * @returns {string}
     */
    function getFullMarkup() {
        var fullText = [];
        $('.lyme-block .markup textarea').each(function() {
            fullText.push($(this).val());
        });
        return fullText.join("\r\n\r\n");
    }

    /**
     * Render the Markup from all blocks.
     *
     * @returns {string}
     */
    function getFullHTML() {
        return options.renderer.render(getFullMarkup());
    }

    // Click events normally won't bubble down to the document.
    // So if we hit it, then hide the editor.
    $(document).on('click', function() {
        lyme.hideEditor();
    });

    return this.each(function() {
        var $container = $(this),
            blocks     = splitText(options.text);

        // Stop click events from reaching the document level
        $container.on('click', function(e) {
            e.stopPropagation();
        });

        $.each(blocks, function(idx, blockText) {
            var block = lyme.createBlock(blockText);
            $container.append(block.getElement());
        });
    });
};

/**
 * Wrapper object for a block.
 *
 * @constructor
 * @param {jQuery} $block
 */
$.fn.lyme.Block = function($block) {
    /**
     * Start editing this block.
     */
    this.edit = function() {
        $block.find('.preview').trigger('click');
    };

    /**
     * Return the previous block.
     *
     * @returns {$.fn.lyme.Block|Boolean}
     */
    this.getPreviousBlock = function() {
        var $prev = $block.prev();
        return $prev.length ? new $.fn.lyme.Block($prev) : false;
    };

    /**
     * Return the next block.
     *
     * @returns {$.fn.lyme.Block|Boolean}
     */
    this.getNextBlock = function() {
        var $next = $block.next();
        return $next.length ? new $.fn.lyme.Block($next) : false;
    };

    /**
     * @returns {String}
     */
    this.getMarkup = function() {
        return $block.find('textarea').val();
    };

    /**
     * @param {String} markup
     */
    this.setMarkup = function(markup) {
        $block.find('textarea').val(markup);
    };

    /**
     * @returns {jQuery}
     */
    this.getElement = function() {
        return $block;
    };
};

/**
 * Renders transform text into HTML.
 *
 * @type {Object}
 */
$.fn.lyme.renderers = {
    /**
     * Markdown Extra conversion using JS Markdown Extra.
     *
     * @constructor
     * @returns {Object}
     */
    JSMarkdownExtra: function() {
        var converter;
        this.render = function(markup) {
            if (!converter) {
                converter = new MarkdownExtra_Parser();
                converter.init();
            }
            return converter.transform(markup);
        }
    },

    /**
     * Markdown conversion using Showdown.
     *
     * @constructor
     * @returns {Object}
     */
    Showdown: function() {
        var converter;
        this.render = function(markup) {
            if (!converter) {
                converter = new Showdown.converter();
            }
            return converter.makeHtml(markup);
        }
    }
};

/**
 * Some base plugins
 *
 * @type {Object}
 */
$.fn.lyme.plugins = {
    /**
     *
     * @param {String} storage      Either 'memory' (default) or 'localStorage'.
     * @param {Number} numEntries   The number of entries to keep in the back log (default: 50).
     * @constructor
     */
    UndoRedo: function(storage, numEntries) {
        if (!$.isNumeric(numEntries)) {
            numEntries = 50;
        }

        var store;
        switch (storage)
        {
            case 'localStorage':
                store = {
                    version: 0,
                    prefix: 'rev-',
                    push: function(markup) {
                        window.localStorage.setItem(this.prefix + this.version++, markup);
                    }
                };
                break;

            case 'memory':
            default:
                store = {
                    entries : [],
                    push: function(markup) {
                        this.entries.push(markup);
                    }
                };
                break;
        }

        this.onMarkupChange = function(markup, html) {
            store.push(markup);
        };
    },

    /**
     * Update an element every time the Markup changes.
     *
     * @constructor
     * @param {String|Object} elementId     String for the element's ID or a jQuery object.
     * @param {Boolean} useHTML             Update the element value with the HTML, instead of the Markup. Default: false.
     */
    ValueUpdater: function(elementId, useHTML) {
        var $e = $(elementId);
        this.onMarkupChange = function(markup, html) {
            $e.val(useHTML ? html : markup);
        };
    },

    /**
     * Scroll to active block using jQuery scrollTo plugin
     *
     * @constructor
     * @param {Number} delay
     */
    ScrollTo: function(delay) {
        if (!$.isFunction($.scrollTo)) {
            return;
        }

        if (!$.isNumeric(delay)) {
            delay = 200;
        }

        var scrollTimeout;
        this.onPreStartEditing = function(block) {
            window.clearTimeout(scrollTimeout);
            scrollTimeout = window.setTimeout(
                function() {
                    $.scrollTo(
                        block.getElement(),
                        {
                            duration : 750,
                            offset   : { top : -100, left : 0 }
                        }
                    );
                },
                delay
            );
        };
    }
};

/**
 * Hot keys are invoked when the user on each keydown event of a block's textarea.
 *
 * @type {Object}
 */
$.fn.lyme.hotKeys = {
    // TAB - Add 4 spaces and then regain focus to the textarea
    'tabbing': {
        indentation: '    ',
        'if': function(e) { return e.keyCode == 9; },
        'do': function(lyme, block) {
            var textarea  = block.getElement().find('textarea').get(0),
                oldCursor = textarea.selectionStart;

            // Insert 4 spaces
            textarea.value = textarea.value.substring(0, textarea.selectionStart)
                           + this.indentation
                           + textarea.value.substring(textarea.selectionEnd, textarea.value.length);

            // Regain focus and set cursor
            setTimeout(function() {
                textarea.focus();
                textarea.setSelectionRange(oldCursor + 4, oldCursor + 4);
            }, 0);
        }
    },

    // ESCAPE - hide current editor
    'escape': {
        'if': function(e) { return e.keyCode == 27; },
        'do': function(lyme, block) {
            lyme.hideEditor();
        }
    },

    // ALT + CTRL + SHIFT + ARROW UP - place before previous block
    'place-before': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.shiftKey && e.keyCode == 38; },
        'do': function(lyme, block) {
            var prev = block.getPreviousBlock();
            if (prev) {
                var thisCode = block.getMarkup(),
                    prevCode = prev.getMarkup();

                block.setMarkup(prevCode);
                prev.setMarkup(thisCode);
                prev.edit();
            }
        }
    },

    // ALT + CTRL + ARROW DOWN - place after next block
    'place-after': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.shiftKey && e.keyCode == 40; },
        'do': function(lyme, block) {
            var next = block.getNextBlock();
            if (next) {
                var thisCode = block.getMarkup(),
                    nextCode = next.getMarkup();

                block.setMarkup(nextCode);
                next.setMarkup(thisCode);
                next.edit();
            }
        }
    },

    // ALT + CTRL + ARROW UP - go to previous block
    'move-up': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.keyCode == 38; },
        'do': function(lyme, block) {
            var prev = block.getPreviousBlock();
            if (prev) {
                prev.edit();
            }
        }
    },

    // ALT + CTRL + ARROW DOWN - go to next block
    'move-down': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.keyCode == 40; },
        'do': function(lyme, block) {
            var next = block.getNextBlock();
            if (next) {
                next.edit();
            }
        }
    },

    // ALT + CTRL + RETURN - append new block to current block
    'append-empty-block': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.keyCode == 13; },
        'do': function(lyme, block) {
            var newBlock = lyme.createBlock('', '');
            block.getElement().after(newBlock.getElement());
            newBlock.edit();
        }
    },

    // ALT + CTRL + BACKSPACE - remove block
    'remove-block': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.keyCode == 8; },
        'do': function(lyme, block) {
            block.setMarkup('');

            var prev = block.getPreviousBlock();
            var next = block.getNextBlock();
            if (prev) {
                prev.edit();
            } else if (next) {
                next.edit();
            } else {
                lyme.hideEditor();
            }
        }
    }
};<commit_msg>ContentGuard plugin, that saves your ass from leaving the page with unsaved changes<commit_after>/**
 * This file is part of LYME (Low Key Markup Editor).
 *
 * (c) Philippe Gerber <philippe@bigwhoop.ch>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
$.fn.lyme = function(userOptions) {
    var lyme = this;

    var defaultOptions = {
        text           : '',
        onMarkupChange : null,
        renderer       : new $.fn.lyme.renderers.JSMarkdownExtra(),
        plugins        : [ new $.fn.lyme.plugins.ScrollTo() ],
        hotKeys        : $.fn.lyme.hotKeys
    };

    var options = $.extend(defaultOptions, userOptions);

    // If the 'text' option is a jQuery object, we retrieve the value
    // from this object and setup a plugin to update the underlying
    // element whenever the text changes.
    if (options.text instanceof $) {
        var $e = options.text;
        options.text = $e.val();
        options.plugins.push(new $.fn.lyme.plugins.ValueUpdater($e, false));
    }

    // Wrapper function to put 'onMarkupChange' option into a plugin.
    if ($.isFunction(options.onMarkupChange)) {
        options.plugins.push({
            onMarkupChange : options.onMarkupChange
        });
    }

    /**
     * @param {String} event
     * @param {Array} data
     */
    function informPlugins(event, data) {
        if (!$.isArray(data)) {
            data = [];
        }

        options.plugins.forEach(function(plugin) {
            if ($.isFunction(plugin[event])) {
                plugin[event].apply(null, data);
            }
        });
    }

    /**
     * Creates blocks by splitting up text by "paragraphs" (two linebreaks).
     *
     * @param {String} text
     * @returns {Array}
     */
    function splitText(text) {
        text = text.replace(/\r/g, '');
        text = text.replace(/\n\n\n/g, "\n\n");
        return text.split("\n\n");
    }

    /**
     * Generate a random ID to assign to each block
     *
     * @returns {Number}
     */
    function generateRandomBlockId() {
        return Math.floor(Math.random() * 999999);
    }

    /**
     * Hide edit controls
     */
    lyme.hideEditor = function() {
        $('.lyme-block .markup:visible textarea').blur();
        $('.lyme-block .markup:visible').hide();
        $('.lyme-block .preview').show();
    };

    /**
     * Render a block
     *
     * @param {String} blockText
     * @param {String} blockHTML        The HTML representation of blockText (optional)
     * @returns {$.fn.lyme.Block}
     */
    lyme.createBlock = function(blockText, blockHTML) {
        if (!blockHTML) {
            blockHTML = options.renderer.render(blockText);
        }

        var blockId   = generateRandomBlockId(),
            $block    = $('
'),
            $markup   = $('

'),
            $textarea = $('<textarea></textarea>'),
            $preview  = $('

');

        $textarea.val(blockText);
        $preview.html(blockHTML);

        $markup.hide().append($textarea);
        $block.append($markup).append($preview);

        var block = new $.fn.lyme.Block($block);

        // Helper function to update the preview text of the current block.
        function updatePreviewText() {
            var text           = $textarea.val(),
                newBlocks      = splitText(text),
                $appendToBlock = $block;

            $.each(newBlocks, function(idx, newBlockText) {
                var newBlockHTML = options.renderer.render(newBlockText);

                // Replace current block
                if (idx == 0) {
                    $textarea.val(newBlockText);
                    $preview.html(newBlockHTML);
                }

                // Append new blocks
                else {
                    var newBlock = lyme.createBlock(newBlockText, newBlockHTML);
                    $appendToBlock.after(newBlock.getElement());
                    $appendToBlock = newBlock.getElement();
                }
            });

            informPlugins('onMarkupChange', [getFullMarkup(), getFullHTML()]);
        }

        // We update the preview text every time the focus of the textarea is lost.
        $textarea.on('focusout', function() {
            informPlugins('onPreStopEditing', [block]);
            if ($textarea.val() == '') {
                $block.remove();
            }
            updatePreviewText();
            informPlugins('onPostStopEditing', [block]);
        });

        // If a key is pressed while editing a block, we check all the registered hot keys.
        $textarea.on('keydown', function(e) {
            for (var hotKeyName in options.hotKeys) {
                var hotKey = options.hotKeys[hotKeyName];
                if (hotKey.if(e)) {
                    e.preventDefault();
                    hotKey.do(lyme, block);
                }
            }
        });

        // If the preview box is clicked, we show the textarea to make the block editable.
        $preview.on('click', function() {
            lyme.hideEditor();
            informPlugins('onPreStartEditing', [block]);
            $preview.hide();
            $markup.show();
            $textarea.focus();
            informPlugins('onPostStartEditing', [block]);
        });

        return block;
    };

    /**
     * Return the Markup from all blocks.
     *
     * @returns {string}
     */
    function getFullMarkup() {
        var fullText = [];
        $('.lyme-block .markup textarea').each(function() {
            fullText.push($(this).val());
        });
        return fullText.join("\r\n\r\n");
    }

    /**
     * Render the Markup from all blocks.
     *
     * @returns {string}
     */
    function getFullHTML() {
        return options.renderer.render(getFullMarkup());
    }

    // Click events normally won't bubble down to the document.
    // So if we hit it, then hide the editor.
    $(document).on('click', function() {
        lyme.hideEditor();
    });

    return this.each(function() {
        var $container = $(this),
            blocks     = splitText(options.text);

        // Stop click events from reaching the document level
        $container.on('click', function(e) {
            e.stopPropagation();
        });

        $.each(blocks, function(idx, blockText) {
            var block = lyme.createBlock(blockText);
            $container.append(block.getElement());
        });
    });
};

/**
 * Wrapper object for a block.
 *
 * @constructor
 * @param {jQuery} $block
 */
$.fn.lyme.Block = function($block) {
    /**
     * Start editing this block.
     */
    this.edit = function() {
        $block.find('.preview').trigger('click');
    };

    /**
     * Return the previous block.
     *
     * @returns {$.fn.lyme.Block|Boolean}
     */
    this.getPreviousBlock = function() {
        var $prev = $block.prev();
        return $prev.length ? new $.fn.lyme.Block($prev) : false;
    };

    /**
     * Return the next block.
     *
     * @returns {$.fn.lyme.Block|Boolean}
     */
    this.getNextBlock = function() {
        var $next = $block.next();
        return $next.length ? new $.fn.lyme.Block($next) : false;
    };

    /**
     * @returns {String}
     */
    this.getMarkup = function() {
        return $block.find('textarea').val();
    };

    /**
     * @param {String} markup
     */
    this.setMarkup = function(markup) {
        $block.find('textarea').val(markup);
    };

    /**
     * @returns {jQuery}
     */
    this.getElement = function() {
        return $block;
    };
};

/**
 * Renders transform text into HTML.
 *
 * @type {Object}
 */
$.fn.lyme.renderers = {
    /**
     * Markdown Extra conversion using JS Markdown Extra.
     *
     * @constructor
     * @returns {Object}
     */
    JSMarkdownExtra: function() {
        var converter;
        this.render = function(markup) {
            if (!converter) {
                converter = new MarkdownExtra_Parser();
                converter.init();
            }
            return converter.transform(markup);
        }
    },

    /**
     * Markdown conversion using Showdown.
     *
     * @constructor
     * @returns {Object}
     */
    Showdown: function() {
        var converter;
        this.render = function(markup) {
            if (!converter) {
                converter = new Showdown.converter();
            }
            return converter.makeHtml(markup);
        }
    }
};

/**
 * Some base plugins
 *
 * @type {Object}
 */
$.fn.lyme.plugins = {
    /**
     *
     * @param {String} storage      Either 'memory' (default) or 'localStorage'.
     * @param {Number} numEntries   The number of entries to keep in the back log (default: 50).
     * @constructor
     */
    UndoRedo: function(storage, numEntries) {
        if (!$.isNumeric(numEntries)) {
            numEntries = 50;
        }

        var store;
        switch (storage)
        {
            case 'localStorage':
                store = {
                    version: 0,
                    prefix: 'rev-',
                    push: function(markup) {
                        window.localStorage.setItem(this.prefix + this.version++, markup);
                    }
                };
                break;

            case 'memory':
            default:
                store = {
                    entries : [],
                    push: function(markup) {
                        this.entries.push(markup);
                    }
                };
                break;
        }

        this.onMarkupChange = function(markup, html) {
            store.push(markup);
        };
    },

    /**
     * @param {Boolean} isEnabled       Whether the door guard is enabled by default.
     * @constructor
     */
    ContentGuard: function(isEnabled) {
        var hasChanged = false,
            isEnabled  = typeof isEnabled === 'boolean' ? isEnabled : false;

        this.enable = function() {
            isEnabled = true;
        };

        this.disable = function() {
            isEnabled = false;
        };

        this.onMarkupChange = function() {
            hasChanged = true;
        };

        $(window).bind('beforeunload', function() {
            if (isEnabled && hasChanged) {
                return 'Are you sure you want to leave this page?';
            }
        });
    },

    /**
     * Update an element every time the Markup changes.
     *
     * @constructor
     * @param {String|Object} elementId     String for the element's ID or a jQuery object.
     * @param {Boolean} useHTML             Update the element value with the HTML, instead of the Markup. Default: false.
     */
    ValueUpdater: function(elementId, useHTML) {
        var $e = $(elementId);
        this.onMarkupChange = function(markup, html) {
            $e.val(useHTML ? html : markup);
        };
    },

    /**
     * Scroll to active block using jQuery scrollTo plugin
     *
     * @constructor
     * @param {Number} delay
     */
    ScrollTo: function(delay) {
        if (!$.isFunction($.scrollTo)) {
            return;
        }

        if (!$.isNumeric(delay)) {
            delay = 200;
        }

        var scrollTimeout;
        this.onPreStartEditing = function(block) {
            window.clearTimeout(scrollTimeout);
            scrollTimeout = window.setTimeout(
                function() {
                    $.scrollTo(
                        block.getElement(),
                        {
                            duration : 750,
                            offset   : { top : -100, left : 0 }
                        }
                    );
                },
                delay
            );
        };
    }
};

/**
 * Hot keys are invoked when the user on each keydown event of a block's textarea.
 *
 * @type {Object}
 */
$.fn.lyme.hotKeys = {
    // TAB - Add 4 spaces and then regain focus to the textarea
    'tabbing': {
        indentation: '    ',
        'if': function(e) { return e.keyCode == 9; },
        'do': function(lyme, block) {
            var textarea  = block.getElement().find('textarea').get(0),
                oldCursor = textarea.selectionStart;

            // Insert 4 spaces
            textarea.value = textarea.value.substring(0, textarea.selectionStart)
                           + this.indentation
                           + textarea.value.substring(textarea.selectionEnd, textarea.value.length);

            // Regain focus and set cursor
            setTimeout(function() {
                textarea.focus();
                textarea.setSelectionRange(oldCursor + 4, oldCursor + 4);
            }, 0);
        }
    },

    // ESCAPE - hide current editor
    'escape': {
        'if': function(e) { return e.keyCode == 27; },
        'do': function(lyme, block) {
            lyme.hideEditor();
        }
    },

    // ALT + CTRL + SHIFT + ARROW UP - place before previous block
    'place-before': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.shiftKey && e.keyCode == 38; },
        'do': function(lyme, block) {
            var prev = block.getPreviousBlock();
            if (prev) {
                var thisCode = block.getMarkup(),
                    prevCode = prev.getMarkup();

                block.setMarkup(prevCode);
                prev.setMarkup(thisCode);
                prev.edit();
            }
        }
    },

    // ALT + CTRL + ARROW DOWN - place after next block
    'place-after': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.shiftKey && e.keyCode == 40; },
        'do': function(lyme, block) {
            var next = block.getNextBlock();
            if (next) {
                var thisCode = block.getMarkup(),
                    nextCode = next.getMarkup();

                block.setMarkup(nextCode);
                next.setMarkup(thisCode);
                next.edit();
            }
        }
    },

    // ALT + CTRL + ARROW UP - go to previous block
    'move-up': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.keyCode == 38; },
        'do': function(lyme, block) {
            var prev = block.getPreviousBlock();
            if (prev) {
                prev.edit();
            }
        }
    },

    // ALT + CTRL + ARROW DOWN - go to next block
    'move-down': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.keyCode == 40; },
        'do': function(lyme, block) {
            var next = block.getNextBlock();
            if (next) {
                next.edit();
            }
        }
    },

    // ALT + CTRL + RETURN - append new block to current block
    'append-empty-block': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.keyCode == 13; },
        'do': function(lyme, block) {
            var newBlock = lyme.createBlock('', '');
            block.getElement().after(newBlock.getElement());
            newBlock.edit();
        }
    },

    // ALT + CTRL + BACKSPACE - remove block
    'remove-block': {
        'if': function(e) { return e.altKey && e.ctrlKey && e.keyCode == 8; },
        'do': function(lyme, block) {
            block.setMarkup('');

            var prev = block.getPreviousBlock();
            var next = block.getNextBlock();
            if (prev) {
                prev.edit();
            } else if (next) {
                next.edit();
            } else {
                lyme.hideEditor();
            }
        }
    }
};<|endoftext|>


<commit_before>import $ from 'jquery';
import Controller, {inject as controller} from '@ember/controller';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import {alias} from '@ember/object/computed';
import {isArray as isEmberArray} from '@ember/array';
import {isVersionMismatchError} from 'ghost-admin/services/ajax';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';

export default Controller.extend(ValidationEngine, {
    application: controller(),
    ajax: service(),
    config: service(),
    ghostPaths: service(),
    notifications: service(),
    session: service(),
    settings: service(),

    submitting: false,
    loggingIn: false,
    authProperties: null,

    flowErrors: '',
    passwordResetEmailSent: false,

    // ValidationEngine settings
    validationType: 'signin',

    init() {
        this._super(...arguments);
        this.authProperties = ['identification', 'password'];
    },

    signin: alias('model'),

    actions: {
        authenticate() {
            return this.validateAndAuthenticate.perform();
        }
    },

    authenticate: task(function* (authStrategy, authentication) {
        try {
            return yield this.session
                .authenticate(authStrategy, ...authentication)
                .then(() => true); // ensure task button transitions to "success" state
        } catch (error) {
            if (isVersionMismatchError(error)) {
                return this.notifications.showAPIError(error);
            }

            if (error && error.payload && error.payload.errors) {
                let [mainError] = error.payload.errors;

                mainError.message = (mainError.message || '').htmlSafe();
                mainError.context = (mainError.context || '').htmlSafe();

                this.set('flowErrors', (mainError.context.string || mainError.message.string));

                if (mainError.type === 'PasswordResetRequiredError') {
                    this.set('passwordResetEmailSent', true);
                }

                if (mainError.context.string.match(/user with that email/i)) {
                    this.get('signin.errors').add('identification', '');
                }

                if (mainError.context.string.match(/password is incorrect/i)) {
                    this.get('signin.errors').add('password', '');
                }
            } else {
                console.error(error); // eslint-disable-line no-console
                // Connection errors don't return proper status message, only req.body
                this.notifications.showAlert(
                    'There was a problem on the server.',
                    {type: 'error', key: 'session.authenticate.failed'}
                );
            }
        }
    }).drop(),

    validateAndAuthenticate: task(function* () {
        let signin = this.signin;
        let authStrategy = 'authenticator:cookie';

        this.set('flowErrors', '');
        // Manually trigger events for input fields, ensuring legacy compatibility with
        // browsers and password managers that don't send proper events on autofill
        $('#login').find('input').trigger('change');

        // This is a bit dirty, but there's no other way to ensure the properties are set as well as 'signin'
        this.hasValidated.addObjects(this.authProperties);

        try {
            yield this.validate({property: 'signin'});
            return yield this.authenticate
                .perform(authStrategy, [signin.get('identification'), signin.get('password')])
                .then(() => true);
        } catch (error) {
            this.set('flowErrors', 'Please fill out the form to sign in.');
        }
    }).drop(),

    forgotten: task(function* () {
        let email = this.get('signin.identification');
        let forgottenUrl = this.get('ghostPaths.url').api('authentication', 'passwordreset');
        let notifications = this.notifications;

        this.set('flowErrors', '');
        // This is a bit dirty, but there's no other way to ensure the properties are set as well as 'forgotPassword'
        this.hasValidated.addObject('identification');

        try {
            yield this.validate({property: 'forgotPassword'});
            yield this.ajax.post(forgottenUrl, {data: {passwordreset: [{email}]}});
            notifications.showAlert(
                'Please check your email for instructions.',
                {type: 'info', key: 'forgot-password.send.success'}
            );
            return true;
        } catch (error) {
            // ValidationEngine throws "undefined" for failed validation
            if (!error) {
                return this.set('flowErrors', 'We need your email address to reset your password!');
            }

            if (isVersionMismatchError(error)) {
                return notifications.showAPIError(error);
            }

            if (error && error.payload && error.payload.errors && isEmberArray(error.payload.errors)) {
                let [{message}] = error.payload.errors;

                this.set('flowErrors', message);

                if (message.match(/no user|not found/)) {
                    this.get('signin.errors').add('identification', '');
                }
            } else {
                notifications.showAPIError(error, {defaultErrorText: 'There was a problem with the reset, please try again.', key: 'forgot-password.send'});
            }
        }
    })
});
<commit_msg>🐛 Fixed infinite spinner when admin login attempt fails (#1755)<commit_after>import $ from 'jquery';
import Controller, {inject as controller} from '@ember/controller';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import {alias} from '@ember/object/computed';
import {isArray as isEmberArray} from '@ember/array';
import {isVersionMismatchError} from 'ghost-admin/services/ajax';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';

export default Controller.extend(ValidationEngine, {
    application: controller(),
    ajax: service(),
    config: service(),
    ghostPaths: service(),
    notifications: service(),
    session: service(),
    settings: service(),

    submitting: false,
    loggingIn: false,
    authProperties: null,

    flowErrors: '',
    passwordResetEmailSent: false,

    // ValidationEngine settings
    validationType: 'signin',

    init() {
        this._super(...arguments);
        this.authProperties = ['identification', 'password'];
    },

    signin: alias('model'),

    actions: {
        authenticate() {
            return this.validateAndAuthenticate.perform();
        }
    },

    authenticate: task(function* (authStrategy, authentication) {
        try {
            return yield this.session
                .authenticate(authStrategy, ...authentication)
                .then(() => true); // ensure task button transitions to "success" state
        } catch (error) {
            if (isVersionMismatchError(error)) {
                return this.notifications.showAPIError(error);
            }

            if (error && error.payload && error.payload.errors) {
                let [mainError] = error.payload.errors;

                mainError.message = (mainError.message || '').htmlSafe();
                mainError.context = (mainError.context || '').htmlSafe();

                this.set('flowErrors', (mainError.context.string || mainError.message.string));

                if (mainError.type === 'PasswordResetRequiredError') {
                    this.set('passwordResetEmailSent', true);
                }

                if (mainError.context.string.match(/user with that email/i)) {
                    this.get('signin.errors').add('identification', '');
                }

                if (mainError.context.string.match(/password is incorrect/i)) {
                    this.get('signin.errors').add('password', '');
                }
            } else {
                console.error(error); // eslint-disable-line no-console
                // Connection errors don't return proper status message, only req.body
                this.notifications.showAlert(
                    'There was a problem on the server.',
                    {type: 'error', key: 'session.authenticate.failed'}
                );
            }

            return false;
        }
    }).drop(),

    validateAndAuthenticate: task(function* () {
        let signin = this.signin;
        let authStrategy = 'authenticator:cookie';

        this.set('flowErrors', '');
        // Manually trigger events for input fields, ensuring legacy compatibility with
        // browsers and password managers that don't send proper events on autofill
        $('#login').find('input').trigger('change');

        // This is a bit dirty, but there's no other way to ensure the properties are set as well as 'signin'
        this.hasValidated.addObjects(this.authProperties);

        try {
            yield this.validate({property: 'signin'});
            return yield this.authenticate
                .perform(authStrategy, [signin.get('identification'), signin.get('password')]);
        } catch (error) {
            this.set('flowErrors', 'Please fill out the form to sign in.');
        }
    }).drop(),

    forgotten: task(function* () {
        let email = this.get('signin.identification');
        let forgottenUrl = this.get('ghostPaths.url').api('authentication', 'passwordreset');
        let notifications = this.notifications;

        this.set('flowErrors', '');
        // This is a bit dirty, but there's no other way to ensure the properties are set as well as 'forgotPassword'
        this.hasValidated.addObject('identification');

        try {
            yield this.validate({property: 'forgotPassword'});
            yield this.ajax.post(forgottenUrl, {data: {passwordreset: [{email}]}});
            notifications.showAlert(
                'Please check your email for instructions.',
                {type: 'info', key: 'forgot-password.send.success'}
            );
            return true;
        } catch (error) {
            // ValidationEngine throws "undefined" for failed validation
            if (!error) {
                return this.set('flowErrors', 'We need your email address to reset your password!');
            }

            if (isVersionMismatchError(error)) {
                return notifications.showAPIError(error);
            }

            if (error && error.payload && error.payload.errors && isEmberArray(error.payload.errors)) {
                let [{message}] = error.payload.errors;

                this.set('flowErrors', message);

                if (message.match(/no user|not found/)) {
                    this.get('signin.errors').add('identification', '');
                }
            } else {
                notifications.showAPIError(error, {defaultErrorText: 'There was a problem with the reset, please try again.', key: 'forgot-password.send'});
            }
        }
    })
});
<|endoftext|>


<commit_before>/*
 * Copyright (C) 2012 Google Inc. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *     * Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above
 * copyright notice, this list of conditions and the following disclaimer
 * in the documentation and/or other materials provided with the
 * distribution.
 *     * Neither the name of Google Inc. nor the names of its
 * contributors may be used to endorse or promote products derived from
 * this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

UI.Dialog = class extends UI.GlassPane {
  constructor() {
    super();
    this.registerRequiredCSS('ui/dialog.css');
    this.contentElement.tabIndex = 0;
    this.contentElement.addEventListener('focus', () => this.widget().focus(), false);
    this.contentElement.addEventListener('keydown', this._onKeyDown.bind(this), false);
    this.widget().setDefaultFocusedElement(this.contentElement);
    this.setPointerEventsBehavior(UI.GlassPane.PointerEventsBehavior.BlockedByGlassPane);
    this.setOutsideClickCallback(event => {
      this.hide();
      event.consume(true);
    });
    /** @type {!Map<!HTMLElement, number>} */
    this._tabIndexMap = new Map();
    /** @type {?UI.WidgetFocusRestorer} */
    this._focusRestorer = null;
    this._closeOnEscape = true;
  }

  /**
   * @return {boolean}
   */
  static hasInstance() {
    return !!UI.Dialog._instance;
  }

  /**
   * @override
   * @param {!Document|!Element=} where
   */
  show(where) {
    const document = /** @type {!Document} */ (
        where instanceof Document ? where : (where || UI.inspectorView.element).ownerDocument);
    if (UI.Dialog._instance)
      UI.Dialog._instance.hide();
    UI.Dialog._instance = this;
    this._disableTabIndexOnElements(document);
    super.show(document);
    this._focusRestorer = new UI.WidgetFocusRestorer(this.widget());
  }

  /**
   * @override
   */
  hide() {
    this._focusRestorer.restore();
    super.hide();
    this._restoreTabIndexOnElements();
    delete UI.Dialog._instance;
  }

  /**
   * @param {boolean} close
   */
  setCloseOnEscape(close) {
    this._closeOnEscape = close;
  }

  addCloseButton() {
    const closeButton = this.contentElement.createChild('div', 'dialog-close-button', 'dt-close-button');
    closeButton.gray = true;
    closeButton.addEventListener('click', () => this.hide(), false);
  }

  /**
   * @param {!Document} document
   */
  _disableTabIndexOnElements(document) {
    this._tabIndexMap.clear();
    for (let node = document; node; node = node.traverseNextNode(document)) {
      if (node instanceof HTMLElement) {
        const element = /** @type {!HTMLElement} */ (node);
        const tabIndex = element.tabIndex;
        if (tabIndex >= 0) {
          this._tabIndexMap.set(element, tabIndex);
          element.tabIndex = -1;
        }
      }
    }
  }

  _restoreTabIndexOnElements() {
    for (const element of this._tabIndexMap.keys())
      element.tabIndex = /** @type {number} */ (this._tabIndexMap.get(element));
    this._tabIndexMap.clear();
  }

  /**
   * @param {!Event} event
   */
  _onKeyDown(event) {
    if (this._closeOnEscape && event.keyCode === UI.KeyboardShortcut.Keys.Esc.code) {
      event.consume(true);
      this.hide();
    }
  }
};
<commit_msg>DevTools: do not hide dialog on Esc with modifiers<commit_after>/*
 * Copyright (C) 2012 Google Inc. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *     * Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above
 * copyright notice, this list of conditions and the following disclaimer
 * in the documentation and/or other materials provided with the
 * distribution.
 *     * Neither the name of Google Inc. nor the names of its
 * contributors may be used to endorse or promote products derived from
 * this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

UI.Dialog = class extends UI.GlassPane {
  constructor() {
    super();
    this.registerRequiredCSS('ui/dialog.css');
    this.contentElement.tabIndex = 0;
    this.contentElement.addEventListener('focus', () => this.widget().focus(), false);
    this.contentElement.addEventListener('keydown', this._onKeyDown.bind(this), false);
    this.widget().setDefaultFocusedElement(this.contentElement);
    this.setPointerEventsBehavior(UI.GlassPane.PointerEventsBehavior.BlockedByGlassPane);
    this.setOutsideClickCallback(event => {
      this.hide();
      event.consume(true);
    });
    /** @type {!Map<!HTMLElement, number>} */
    this._tabIndexMap = new Map();
    /** @type {?UI.WidgetFocusRestorer} */
    this._focusRestorer = null;
    this._closeOnEscape = true;
  }

  /**
   * @return {boolean}
   */
  static hasInstance() {
    return !!UI.Dialog._instance;
  }

  /**
   * @override
   * @param {!Document|!Element=} where
   */
  show(where) {
    const document = /** @type {!Document} */ (
        where instanceof Document ? where : (where || UI.inspectorView.element).ownerDocument);
    if (UI.Dialog._instance)
      UI.Dialog._instance.hide();
    UI.Dialog._instance = this;
    this._disableTabIndexOnElements(document);
    super.show(document);
    this._focusRestorer = new UI.WidgetFocusRestorer(this.widget());
  }

  /**
   * @override
   */
  hide() {
    this._focusRestorer.restore();
    super.hide();
    this._restoreTabIndexOnElements();
    delete UI.Dialog._instance;
  }

  /**
   * @param {boolean} close
   */
  setCloseOnEscape(close) {
    this._closeOnEscape = close;
  }

  addCloseButton() {
    const closeButton = this.contentElement.createChild('div', 'dialog-close-button', 'dt-close-button');
    closeButton.gray = true;
    closeButton.addEventListener('click', () => this.hide(), false);
  }

  /**
   * @param {!Document} document
   */
  _disableTabIndexOnElements(document) {
    this._tabIndexMap.clear();
    for (let node = document; node; node = node.traverseNextNode(document)) {
      if (node instanceof HTMLElement) {
        const element = /** @type {!HTMLElement} */ (node);
        const tabIndex = element.tabIndex;
        if (tabIndex >= 0) {
          this._tabIndexMap.set(element, tabIndex);
          element.tabIndex = -1;
        }
      }
    }
  }

  _restoreTabIndexOnElements() {
    for (const element of this._tabIndexMap.keys())
      element.tabIndex = /** @type {number} */ (this._tabIndexMap.get(element));
    this._tabIndexMap.clear();
  }

  /**
   * @param {!Event} event
   */
  _onKeyDown(event) {
    if (this._closeOnEscape && event.keyCode === UI.KeyboardShortcut.Keys.Esc.code &&
        UI.KeyboardShortcut.hasNoModifiers(event)) {
      event.consume(true);
      this.hide();
    }
  }
};
<|endoftext|>