1// Copyright (c) 2011 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @fileoverview This file implements the ProxyFormController class, which
7 * wraps a form element with logic that enables implementation of proxy
8 * settings.
9 *
10 * @author mkwst@google.com (Mike West)
11 */
12
13/**
14 * Wraps the proxy configuration form, binding proper handlers to its various
15 * `change`, `click`, etc. events in order to take appropriate action in
16 * response to user events.
17 *
18 * @param {string} id The form's DOM ID.
19 * @constructor
20 */
21var ProxyFormController = function(id) {
22  /**
23   * The wrapped form element
24   * @type {Node}
25   * @private
26   */
27  this.form_ = document.getElementById(id);
28
29  // Throw an error if the element either doesn't exist, or isn't a form.
30  if (!this.form_)
31    throw chrome.i18n.getMessage('errorIdNotFound', id);
32  else if (this.form_.nodeName !== 'FORM')
33    throw chrome.i18n.getMessage('errorIdNotForm', id);
34
35  /**
36   * Cached references to the `fieldset` groups that define the configuration
37   * options presented to the user.
38   *
39   * @type {NodeList}
40   * @private
41   */
42  this.configGroups_ = document.querySelectorAll('#' + id + ' > fieldset');
43
44  this.bindEventHandlers_();
45  this.readCurrentState_();
46
47  // Handle errors
48  this.handleProxyErrors_();
49};
50
51///////////////////////////////////////////////////////////////////////////////
52
53/**
54 * The proxy types we're capable of handling.
55 * @enum {string}
56 */
57ProxyFormController.ProxyTypes = {
58  AUTO: 'auto_detect',
59  PAC: 'pac_script',
60  DIRECT: 'direct',
61  FIXED: 'fixed_servers',
62  SYSTEM: 'system'
63};
64
65/**
66 * The window types we're capable of handling.
67 * @enum {int}
68 */
69ProxyFormController.WindowTypes = {
70  REGULAR: 1,
71  INCOGNITO: 2
72};
73
74/**
75 * The extension's level of control of Chrome's roxy setting
76 * @enum {string}
77 */
78ProxyFormController.LevelOfControl = {
79  NOT_CONTROLLABLE: 'not_controllable',
80  OTHER_EXTENSION: 'controlled_by_other_extension',
81  AVAILABLE: 'controllable_by_this_extension',
82  CONTROLLING: 'controlled_by_this_extension'
83};
84
85/**
86 * The response type from 'proxy.settings.get'
87 *
88 * @typedef {{value: ProxyConfig,
89 *     levelOfControl: ProxyFormController.LevelOfControl}}
90 */
91ProxyFormController.WrappedProxyConfig;
92
93///////////////////////////////////////////////////////////////////////////////
94
95/**
96 * Retrieves proxy settings that have been persisted across restarts.
97 *
98 * @return {?ProxyConfig} The persisted proxy configuration, or null if no
99 *     value has been persisted.
100 * @static
101 */
102ProxyFormController.getPersistedSettings = function() {
103  var result = null;
104  if (window.localStorage['proxyConfig'] !== undefined)
105    result = JSON.parse(window.localStorage['proxyConfig']);
106  return result ? result : null;
107};
108
109
110/**
111 * Persists proxy settings across restarts.
112 *
113 * @param {!ProxyConfig} config The proxy config to persist.
114 * @static
115 */
116ProxyFormController.setPersistedSettings = function(config) {
117  window.localStorage['proxyConfig'] = JSON.stringify(config);
118};
119
120///////////////////////////////////////////////////////////////////////////////
121
122ProxyFormController.prototype = {
123  /**
124   * The form's current state.
125   * @type {regular: ?ProxyConfig, incognito: ?ProxyConfig}
126   * @private
127   */
128  config_: {regular: null, incognito: null},
129
130  /**
131   * Do we have access to incognito mode?
132   * @type {boolean}
133   * @private
134   */
135  isAllowedIncognitoAccess_: false,
136
137  /**
138   * @return {string} The PAC file URL (or an empty string).
139   */
140  get pacURL() {
141    return document.getElementById('autoconfigURL').value;
142  },
143
144
145  /**
146   * @param {!string} value The PAC file URL.
147   */
148  set pacURL(value) {
149    document.getElementById('autoconfigURL').value = value;
150  },
151
152
153  /**
154   * @return {string} The PAC file data (or an empty string).
155   */
156  get manualPac() {
157    return document.getElementById('autoconfigData').value;
158  },
159
160
161  /**
162   * @param {!string} value The PAC file data.
163   */
164  set manualPac(value) {
165    document.getElementById('autoconfigData').value = value;
166  },
167
168
169  /**
170   * @return {Array.<string>} A list of hostnames that should bypass the proxy.
171   */
172  get bypassList() {
173    return document.getElementById('bypassList').value.split(/\s*(?:,|^)\s*/m);
174  },
175
176
177  /**
178   * @param {?Array.<string>} data A list of hostnames that should bypass
179   *     the proxy. If empty, the bypass list is emptied.
180   */
181  set bypassList(data) {
182    if (!data)
183      data = [];
184    document.getElementById('bypassList').value = data.join(', ');
185  },
186
187
188  /**
189   * @see http://code.google.com/chrome/extensions/trunk/proxy.html
190   * @return {?ProxyServer} An object containing the proxy server host, port,
191   *     and scheme. If null, there is no single proxy.
192   */
193  get singleProxy() {
194    var checkbox = document.getElementById('singleProxyForEverything');
195    return checkbox.checked ? this.httpProxy : null;
196  },
197
198
199  /**
200   * @see http://code.google.com/chrome/extensions/trunk/proxy.html
201   * @param {?ProxyServer} data An object containing the proxy server host,
202   *     port, and scheme. If null, the single proxy checkbox will be unchecked.
203   */
204  set singleProxy(data) {
205    var checkbox = document.getElementById('singleProxyForEverything');
206    checkbox.checked = !!data;
207
208    if (data)
209      this.httpProxy = data;
210
211    if (checkbox.checked)
212      checkbox.parentNode.parentNode.classList.add('single');
213    else
214      checkbox.parentNode.parentNode.classList.remove('single');
215  },
216
217  /**
218   * @return {?ProxyServer} An object containing the proxy server host, port
219   *     and scheme.
220   */
221  get httpProxy() {
222    return this.getProxyImpl_('Http');
223  },
224
225
226  /**
227   * @param {?ProxyServer} data An object containing the proxy server host,
228   *     port, and scheme. If empty, empties the proxy setting.
229   */
230  set httpProxy(data) {
231    this.setProxyImpl_('Http', data);
232  },
233
234
235  /**
236   * @return {?ProxyServer} An object containing the proxy server host, port
237   *     and scheme.
238   */
239  get httpsProxy() {
240    return this.getProxyImpl_('Https');
241  },
242
243
244  /**
245   * @param {?ProxyServer} data An object containing the proxy server host,
246   *     port, and scheme. If empty, empties the proxy setting.
247   */
248  set httpsProxy(data) {
249    this.setProxyImpl_('Https', data);
250  },
251
252
253  /**
254   * @return {?ProxyServer} An object containing the proxy server host, port
255   *     and scheme.
256   */
257  get ftpProxy() {
258    return this.getProxyImpl_('Ftp');
259  },
260
261
262  /**
263   * @param {?ProxyServer} data An object containing the proxy server host,
264   *     port, and scheme. If empty, empties the proxy setting.
265   */
266  set ftpProxy(data) {
267    this.setProxyImpl_('Ftp', data);
268  },
269
270
271  /**
272   * @return {?ProxyServer} An object containing the proxy server host, port
273   *     and scheme.
274   */
275  get fallbackProxy() {
276    return this.getProxyImpl_('Fallback');
277  },
278
279
280  /**
281   * @param {?ProxyServer} data An object containing the proxy server host,
282   *     port, and scheme. If empty, empties the proxy setting.
283   */
284  set fallbackProxy(data) {
285    this.setProxyImpl_('Fallback', data);
286  },
287
288
289  /**
290   * @param {string} type The type of proxy that's being set ("Http",
291   *     "Https", etc.).
292   * @return {?ProxyServer} An object containing the proxy server host,
293   *     port, and scheme.
294   * @private
295   */
296  getProxyImpl_: function(type) {
297    var result = {
298      scheme: document.getElementById('proxyScheme' + type).value,
299      host: document.getElementById('proxyHost' + type).value,
300      port: parseInt(document.getElementById('proxyPort' + type).value, 10)
301    };
302    return (result.scheme && result.host && result.port) ? result : undefined;
303  },
304
305
306  /**
307   * A generic mechanism for setting proxy data.
308   *
309   * @see http://code.google.com/chrome/extensions/trunk/proxy.html
310   * @param {string} type The type of proxy that's being set ("Http",
311   *     "Https", etc.).
312   * @param {?ProxyServer} data An object containing the proxy server host,
313   *     port, and scheme. If empty, empties the proxy setting.
314   * @private
315   */
316  setProxyImpl_: function(type, data) {
317    if (!data)
318      data = {scheme: 'http', host: '', port: ''};
319
320    document.getElementById('proxyScheme' + type).value = data.scheme;
321    document.getElementById('proxyHost' + type).value = data.host;
322    document.getElementById('proxyPort' + type).value = data.port;
323  },
324
325///////////////////////////////////////////////////////////////////////////////
326
327  /**
328   * Calls the proxy API to read the current settings, and populates the form
329   * accordingly.
330   *
331   * @private
332   */
333  readCurrentState_: function() {
334    chrome.extension.isAllowedIncognitoAccess(
335        this.handleIncognitoAccessResponse_.bind(this));
336  },
337
338  /**
339   * Handles the respnse from `chrome.extension.isAllowedIncognitoAccess`
340   * We can't render the form until we know what our access level is, so
341   * we wait until we have confirmed incognito access levels before
342   * asking for the proxy state.
343   *
344   * @param {boolean} state The state of incognito access.
345   * @private
346   */
347  handleIncognitoAccessResponse_: function(state) {
348    this.isAllowedIncognitoAccess_ = state;
349    chrome.proxy.settings.get({incognito: false},
350        this.handleRegularState_.bind(this));
351    if (this.isAllowedIncognitoAccess_) {
352      chrome.proxy.settings.get({incognito: true},
353          this.handleIncognitoState_.bind(this));
354    }
355  },
356
357  /**
358   * Handles the response from 'proxy.settings.get' for regular
359   * settings.
360   *
361   * @param {ProxyFormController.WrappedProxyConfig} c The proxy data and
362   *     extension's level of control thereof.
363   * @private
364   */
365  handleRegularState_: function(c) {
366    if (c.levelOfControl === ProxyFormController.LevelOfControl.AVAILABLE ||
367        c.levelOfControl === ProxyFormController.LevelOfControl.CONTROLLING) {
368      this.recalcFormValues_(c.value);
369      this.config_.regular = c.value;
370    } else {
371      this.handleLackOfControl_(c.levelOfControl);
372    }
373  },
374
375  /**
376   * Handles the response from 'proxy.settings.get' for incognito
377   * settings.
378   *
379   * @param {ProxyFormController.WrappedProxyConfig} c The proxy data and
380   *     extension's level of control thereof.
381   * @private
382   */
383  handleIncognitoState_: function(c) {
384    if (c.levelOfControl === ProxyFormController.LevelOfControl.AVAILABLE ||
385        c.levelOfControl === ProxyFormController.LevelOfControl.CONTROLLING) {
386      if (this.isIncognitoMode_())
387        this.recalcFormValues_(c.value);
388
389      this.config_.incognito = c.value;
390    } else {
391      this.handleLackOfControl_(c.levelOfControl);
392    }
393  },
394
395  /**
396   * Binds event handlers for the various bits and pieces of the form that
397   * are interesting to the controller.
398   *
399   * @private
400   */
401  bindEventHandlers_: function() {
402    this.form_.addEventListener('click', this.dispatchFormClick_.bind(this));
403  },
404
405
406  /**
407   * When a `click` event is triggered on the form, this function handles it by
408   * analyzing the context, and dispatching the click to the correct handler.
409   *
410   * @param {Event} e The event to be handled.
411   * @private
412   * @return {boolean} True if the event should bubble, false otherwise.
413   */
414  dispatchFormClick_: function(e) {
415    var t = e.target;
416
417    // Case 1: "Apply"
418    if (t.nodeName === 'INPUT' && t.getAttribute('type') === 'submit') {
419      return this.applyChanges_(e);
420
421    // Case 2: "Use the same proxy for all protocols" in an active section
422    } else if (t.nodeName === 'INPUT' &&
423               t.getAttribute('type') === 'checkbox' &&
424               t.parentNode.parentNode.parentNode.classList.contains('active')
425              ) {
426      return this.toggleSingleProxyConfig_(e);
427
428    // Case 3: "Flip to incognito mode."
429    } else if (t.nodeName === 'BUTTON') {
430      return this.toggleIncognitoMode_(e);
431
432    // Case 4: Click on something random: maybe changing active config group?
433    } else {
434      // Walk up the tree until we hit `form > fieldset` or fall off the top
435      while (t && (t.nodeName !== 'FIELDSET' ||
436             t.parentNode.nodeName !== 'FORM')) {
437        t = t.parentNode;
438      }
439      if (t) {
440        this.changeActive_(t);
441        return false;
442      }
443    }
444    return true;
445  },
446
447
448  /**
449   * Sets the form's active config group.
450   *
451   * @param {DOMElement} fieldset The configuration group to activate.
452   * @private
453   */
454  changeActive_: function(fieldset) {
455    for (var i = 0; i < this.configGroups_.length; i++) {
456      var el = this.configGroups_[i];
457      var radio = el.querySelector("input[type='radio']");
458      if (el === fieldset) {
459        el.classList.add('active');
460        radio.checked = true;
461      } else {
462        el.classList.remove('active');
463      }
464    }
465    this.recalcDisabledInputs_();
466  },
467
468
469  /**
470   * Recalculates the `disabled` state of the form's input elements, based
471   * on the currently active group, and that group's contents.
472   *
473   * @private
474   */
475  recalcDisabledInputs_: function() {
476    var i, j;
477    for (i = 0; i < this.configGroups_.length; i++) {
478      var el = this.configGroups_[i];
479      var inputs = el.querySelectorAll(
480          "input:not([type='radio']), select, textarea");
481      if (el.classList.contains('active')) {
482        for (j = 0; j < inputs.length; j++) {
483          inputs[j].removeAttribute('disabled');
484        }
485      } else {
486        for (j = 0; j < inputs.length; j++) {
487          inputs[j].setAttribute('disabled', 'disabled');
488        }
489      }
490    }
491  },
492
493
494  /**
495   * Handler called in response to click on form's submission button. Generates
496   * the proxy configuration and passes it to `useCustomProxySettings`, or
497   * handles errors in user input.
498   *
499   * Proxy errors (and the browser action's badge) are cleared upon setting new
500   * values.
501   *
502   * @param {Event} e DOM event generated by the user's click.
503   * @private
504   */
505  applyChanges_: function(e) {
506    e.preventDefault();
507    e.stopPropagation();
508
509    if (this.isIncognitoMode_())
510      this.config_.incognito = this.generateProxyConfig_();
511    else
512      this.config_.regular = this.generateProxyConfig_();
513
514    chrome.proxy.settings.set(
515        {value: this.config_.regular, scope: 'regular'},
516        this.callbackForRegularSettings_.bind(this));
517    chrome.extension.sendRequest({type: 'clearError'});
518  },
519
520  /**
521   * Called in response to setting a regular window's proxy settings: checks
522   * for `lastError`, and then sets incognito settings (if they exist).
523   *
524   * @private
525   */
526  callbackForRegularSettings_: function() {
527    if (chrome.runtime.lastError) {
528      this.generateAlert_(chrome.i18n.getMessage('errorSettingRegularProxy'));
529      return;
530    }
531    if (this.config_.incognito) {
532      chrome.proxy.settings.set(
533          {value: this.config_.incognito, scope: 'incognito_persistent'},
534          this.callbackForIncognitoSettings_.bind(this));
535    } else {
536      ProxyFormController.setPersistedSettings(this.config_);
537      this.generateAlert_(chrome.i18n.getMessage('successfullySetProxy'));
538    }
539  },
540
541  /**
542   * Called in response to setting an incognito window's proxy settings: checks
543   * for `lastError` and sets a success message.
544   *
545   * @private
546   */
547  callbackForIncognitoSettings_: function() {
548    if (chrome.runtime.lastError) {
549      this.generateAlert_(chrome.i18n.getMessage('errorSettingIncognitoProxy'));
550      return;
551    }
552    ProxyFormController.setPersistedSettings(this.config_);
553    this.generateAlert_(
554        chrome.i18n.getMessage('successfullySetProxy'));
555  },
556
557  /**
558   * Generates an alert overlay inside the proxy's popup, then closes the popup
559   * after a short delay.
560   *
561   * @param {string} msg The message to be displayed in the overlay.
562   * @param {?boolean} close Should the window be closed?  Defaults to true.
563   * @private
564   */
565  generateAlert_: function(msg, close) {
566    var success = document.createElement('div');
567    success.classList.add('overlay');
568    success.setAttribute('role', 'alert');
569    success.textContent = msg;
570    document.body.appendChild(success);
571
572    setTimeout(function() { success.classList.add('visible'); }, 10);
573    setTimeout(function() {
574      if (close === false)
575        success.classList.remove('visible');
576      else
577        window.close();
578    }, 4000);
579  },
580
581
582  /**
583   * Parses the proxy configuration form, and generates a ProxyConfig object
584   * that can be passed to `useCustomProxyConfig`.
585   *
586   * @see http://code.google.com/chrome/extensions/trunk/proxy.html
587   * @return {ProxyConfig} The proxy configuration represented by the form.
588   * @private
589   */
590  generateProxyConfig_: function() {
591    var active = document.getElementsByClassName('active')[0];
592    switch (active.id) {
593      case ProxyFormController.ProxyTypes.SYSTEM:
594        return {mode: 'system'};
595      case ProxyFormController.ProxyTypes.DIRECT:
596        return {mode: 'direct'};
597      case ProxyFormController.ProxyTypes.PAC:
598        var pacScriptURL = this.pacURL;
599        var pacManual = this.manualPac;
600        if (pacScriptURL)
601          return {mode: 'pac_script',
602                  pacScript: {url: pacScriptURL, mandatory: true}};
603        else if (pacManual)
604          return {mode: 'pac_script',
605                  pacScript: {data: pacManual, mandatory: true}};
606        else
607          return {mode: 'auto_detect'};
608      case ProxyFormController.ProxyTypes.FIXED:
609        var config = {mode: 'fixed_servers'};
610        if (this.singleProxy) {
611          config.rules = {
612            singleProxy: this.singleProxy,
613            bypassList: this.bypassList
614          };
615        } else {
616          config.rules = {
617            proxyForHttp: this.httpProxy,
618            proxyForHttps: this.httpsProxy,
619            proxyForFtp: this.ftpProxy,
620            fallbackProxy: this.fallbackProxy,
621            bypassList: this.bypassList
622          };
623        }
624        return config;
625    }
626  },
627
628
629  /**
630   * Sets the proper display classes based on the "Use the same proxy server
631   * for all protocols" checkbox. Expects to be called as an event handler
632   * when that field is clicked.
633   *
634   * @param {Event} e The `click` event to respond to.
635   * @private
636   */
637  toggleSingleProxyConfig_: function(e) {
638    var checkbox = e.target;
639    if (checkbox.nodeName === 'INPUT' &&
640        checkbox.getAttribute('type') === 'checkbox') {
641      if (checkbox.checked)
642        checkbox.parentNode.parentNode.classList.add('single');
643      else
644        checkbox.parentNode.parentNode.classList.remove('single');
645    }
646  },
647
648
649  /**
650   * Returns the form's current incognito status.
651   *
652   * @return {boolean} True if the form is in incognito mode, false otherwise.
653   * @private
654   */
655  isIncognitoMode_: function(e) {
656    return this.form_.parentNode.classList.contains('incognito');
657  },
658
659
660  /**
661   * Toggles the form's incognito mode. Saves the current state to an object
662   * property for later use, clears the form, and toggles the appropriate state.
663   *
664   * @param {Event} e The `click` event to respond to.
665   * @private
666   */
667  toggleIncognitoMode_: function(e) {
668    var div = this.form_.parentNode;
669    var button = document.getElementsByTagName('button')[0];
670
671    // Cancel the button click.
672    e.preventDefault();
673    e.stopPropagation();
674
675    // If we can't access Incognito settings, throw a message and return.
676    if (!this.isAllowedIncognitoAccess_) {
677      var msg = "I'm sorry, Dave, I'm afraid I can't do that. Give me access " +
678                "to Incognito settings by checking the checkbox labeled " +
679                "'Allow in Incognito mode', which is visible at " +
680                "chrome://extensions.";
681      this.generateAlert_(msg, false);
682      return;
683    }
684
685    if (this.isIncognitoMode_()) {
686      // In incognito mode, switching to cognito.
687      this.config_.incognito = this.generateProxyConfig_();
688      div.classList.remove('incognito');
689      this.recalcFormValues_(this.config_.regular);
690      button.innerText = 'Configure incognito window settings.';
691    } else {
692      // In cognito mode, switching to incognito.
693      this.config_.regular = this.generateProxyConfig_();
694      div.classList.add('incognito');
695      this.recalcFormValues_(this.config_.incognito);
696      button.innerText = 'Configure regular window settings.';
697    }
698  },
699
700
701  /**
702   * Sets the form's values based on a ProxyConfig.
703   *
704   * @param {!ProxyConfig} c The ProxyConfig object.
705   * @private
706   */
707  recalcFormValues_: function(c) {
708    // Normalize `auto_detect`
709    if (c.mode === 'auto_detect')
710      c.mode = 'pac_script';
711    // Activate one of the groups, based on `mode`.
712    this.changeActive_(document.getElementById(c.mode));
713    // Populate the PAC script
714    if (c.pacScript) {
715      if (c.pacScript.url)
716        this.pacURL = c.pacScript.url;
717    } else {
718      this.pacURL = '';
719    }
720    // Evaluate the `rules`
721    if (c.rules) {
722      var rules = c.rules;
723      if (rules.singleProxy) {
724        this.singleProxy = rules.singleProxy;
725      } else {
726        this.singleProxy = null;
727        this.httpProxy = rules.proxyForHttp;
728        this.httpsProxy = rules.proxyForHttps;
729        this.ftpProxy = rules.proxyForFtp;
730        this.fallbackProxy = rules.fallbackProxy;
731      }
732      this.bypassList = rules.bypassList;
733    } else {
734      this.singleProxy = null;
735      this.httpProxy = null;
736      this.httpsProxy = null;
737      this.ftpProxy = null;
738      this.fallbackProxy = null;
739      this.bypassList = '';
740    }
741  },
742
743
744  /**
745   * Handles the case in which this extension doesn't have the ability to
746   * control the Proxy settings, either because of an overriding policy
747   * or an extension with higher priority.
748   *
749   * @param {ProxyFormController.LevelOfControl} l The level of control this
750   *     extension has over the proxy settings.
751   * @private
752   */
753  handleLackOfControl_: function(l) {
754    var msg;
755    if (l === ProxyFormController.LevelOfControl.NO_ACCESS)
756      msg = chrome.i18n.getMessage('errorNoExtensionAccess');
757    else if (l === ProxyFormController.LevelOfControl.OTHER_EXTENSION)
758      msg = chrome.i18n.getMessage('errorOtherExtensionControls');
759    this.generateAlert_(msg);
760  },
761
762
763  /**
764   * Handle the case in which errors have been generated outside the context
765   * of this popup.
766   *
767   * @private
768   */
769  handleProxyErrors_: function() {
770    chrome.extension.sendRequest(
771        {type: 'getError'},
772        this.handleProxyErrorHandlerResponse_.bind(this));
773  },
774
775  /**
776   * Handles response from ProxyErrorHandler
777   *
778   * @param {{result: !string}} response The message sent in response to this
779   *     popup's request.
780   */
781  handleProxyErrorHandlerResponse_: function(response) {
782    if (response.result !== null) {
783      var error = JSON.parse(response.result);
784      console.error(error);
785      // TODO(mkwst): Do something more interesting
786      this.generateAlert_(
787          chrome.i18n.getMessage(
788              error.details ? 'errorProxyDetailedError' : 'errorProxyError',
789              [error.error, error.details]),
790          false);
791    }
792  }
793};
794