Browse Source

Initial commit

Thomas Dy 9 years ago
commit
493685e510
12 changed files with 653 additions and 0 deletions
  1. 3 0
      .gitignore
  2. 10 0
      index.html
  3. 65 0
      lib/app.jsx
  4. 38 0
      lib/cloudflare.js
  5. 11 0
      lib/index.jsx
  6. 41 0
      lib/ui/DomainList.jsx
  7. 214 0
      lib/ui/RecordList.jsx
  8. 77 0
      lib/ui/Settings.jsx
  9. 55 0
      main.css
  10. 33 0
      package.json
  11. 84 0
      server.js
  12. 22 0
      webpack.config.js

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+assets
+config.json
+node_modules

+ 10 - 0
index.html

@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Cloudflare WebUI</title>
+  </head>
+  <body>
+    <div id="content" class="container"></div>
+    <script src="/assets/bundle.js"></script>
+  </body>
+</html>

+ 65 - 0
lib/app.jsx

@@ -0,0 +1,65 @@
+var cloudflare = require('./cloudflare');
+var React = require('react');
+var ReactMiniRouter = require('react-mini-router');
+
+var DomainList = require('./ui/DomainList');
+var RecordList = require('./ui/RecordList');
+var Settings = require('./ui/Settings');
+
+var App = React.createClass({
+  mixins: [ReactMiniRouter.RouterMixin],
+  routes: {
+    '/': 'home',
+    '/:domain': 'domain',
+    '/:domain/settings': 'settings'
+  },
+  render: function() {
+    return this.renderCurrentRoute();
+  },
+  main: function(domain, settings) {
+    var title;
+    var content = [];
+    if(domain) {
+      title = domain;
+      content.push(
+        <ul key="nav" className="nav nav-tabs">
+          <li role="presentation" className={!settings ? 'active' : ''}><a href={'/'+domain}>DNS</a></li>
+          <li role="presentation" className={settings ? 'active' : ''}><a href={'/'+domain+'/settings'}>Settings</a></li>
+        </ul>
+      );
+      if(settings) {
+        content.push(<Settings key="settings" domain={domain} />);
+      }
+      else {
+        content.push(<RecordList key="records" domain={domain} />);
+      }
+    }
+    else {
+      title = "CloudFlare WebUI";
+      content.push(<p key="content">Select a domain from the sidebar</p>);
+    }
+
+    return (
+      <div className="row">
+        <div id="domains" className="col-md-3">
+          <DomainList currentDomain={domain} />
+        </div>
+        <div className="col-md-9">
+          <h1>{title}</h1>
+          {content}
+        </div>
+      </div>
+    );
+  },
+  home: function() {
+    return this.main();
+  },
+  domain: function(domain) {
+    return this.main(domain, false);
+  },
+  settings: function(domain) {
+    return this.main(domain, true);
+  }
+});
+
+module.exports = App;

+ 38 - 0
lib/cloudflare.js

@@ -0,0 +1,38 @@
+var reqwest = require('reqwest');
+var assign = require('react/lib/Object.assign');
+
+function makeCall(path) {
+  return function(options) {
+    return reqwest({
+      url: '/api',
+      data: assign({a: path}, options),
+      method: 'POST'
+    });
+  };
+}
+
+module.exports = {
+  domains: makeCall('zone_load_multi'),
+  settings: function(domain) {
+    return makeCall('zone_settings')({z: domain});
+  },
+  set_devmode: function(domain, toggle) {
+    return makeCall('devmode')({z: domain, v: toggle ? 1 : 0});
+  },
+  purge_cache: function(domain) {
+    return makeCall('fpurge_ts')({z: domain, v: 1});
+  },
+  records: function(domain) {
+    return makeCall('rec_load_all')({z: domain});
+  },
+  record_add: function(domain, options) {
+    return makeCall('rec_new')(assign({z: domain, ttl: 1}, options));
+  },
+  record_edit: function(domain, options) {
+    return makeCall('rec_edit')(assign({z: domain, ttl: 1}, options));
+  },
+  record_delete: function(domain, id) {
+    return makeCall('rec_delete')({z: domain, id: id});
+  }
+};
+

+ 11 - 0
lib/index.jsx

@@ -0,0 +1,11 @@
+require("bootstrap/dist/css/bootstrap.css");
+require("../main.css");
+
+var React = require('react');
+var App = require('./App');
+
+React.render(
+  <App history={true} />,
+  document.getElementById('content')
+);
+

+ 41 - 0
lib/ui/DomainList.jsx

@@ -0,0 +1,41 @@
+var cloudflare = require('../cloudflare');
+var React = require('react');
+
+var Domain = React.createClass({
+  render: function() {
+    var className = this.props.active ? 'active' : '';
+    return (
+      <li role="presentation" className={className}>
+        <a href={'/'+this.props.data.zone_name}>{this.props.data.zone_name}</a>
+      </li>
+    );
+  }
+});
+
+var DomainList = React.createClass({
+  getInitialState: function() {
+    return {domains: []};
+  },
+  componentDidMount: function() {
+    cloudflare.domains().then(function(data) {
+      this.setState({domains: data.response.zones.objs});
+    }.bind(this));
+  },
+  render: function() {
+    var currDomain = this.props.currentDomain;
+    var domains = this.state.domains.map(function(domain) {
+      var active = currDomain === domain.zone_name;
+      return <Domain key={domain.zone_id} data={domain} active={active} />
+    });
+    return (
+      <div>
+        <h1>Domains</h1>
+        <ul className="nav nav-pills nav-stacked">
+          {domains}
+        </ul>
+      </div>
+    );
+  }
+});
+
+module.exports = DomainList;

+ 214 - 0
lib/ui/RecordList.jsx

@@ -0,0 +1,214 @@
+var cloudflare = require('../cloudflare');
+var React = require('react');
+
+var CloudActive = React.createClass({
+  render: function() {
+    var record = this.props.record;
+    if(record.type === 'A' || record.type === 'AAAA' || record.type === 'CNAME') {
+      var active = record.service_mode === '1';
+      if(active) {
+        return <button className='btn btn-warning' onClick={this.props.onClick}>On</button>
+      }
+      else {
+        return <button className='btn btn-default' onClick={this.props.onClick}>Off</button>
+      }
+    }
+    else {
+      return <span></span>;
+    }
+  }
+});
+var RecordCreate = React.createClass({
+  getInitialState: function() {
+    return {saving: false};
+  },
+  types: ['A', 'AAAA', 'CNAME'],
+  finishSave: function(promise) {
+    promise.then(this.props.onEdit).then(function() {
+      this.setState({saving: false});
+      this.reset();
+    }.bind(this));
+  },
+  reset: function() {
+    this.refs.type.getDOMNode().value = this.types[0];
+    this.refs.name.getDOMNode().value = "";
+    this.refs.value.getDOMNode().value = "";
+  },
+  commitAdd: function() {
+    this.setState({saving: true});
+    var newRecord = {
+      type: this.refs.type.getDOMNode().value,
+      name: this.refs.name.getDOMNode().value.trim(),
+      content: this.refs.value.getDOMNode().value.trim()
+    };
+    this.finishSave(cloudflare.record_add(this.props.domain, newRecord));
+  },
+  render: function() {
+    var className = this.state.saving ? 'saving' : '';
+    var options = this.types.map(function(type) {
+      return <option key={type} value={type}>{type}</option>
+    });
+    return (
+      <tr className={className}>
+        <td>
+          <select ref="type">
+            {options}
+          </select>
+        </td>
+        <td><input type="text" ref="name" /></td>
+        <td><input type="text" ref="value" /></td>
+        <td></td>
+        <td>
+          <button className="btn btn-success" onClick={this.commitAdd}>Add</button>
+        </td>
+      </tr>
+    )
+  }
+});
+var Record = React.createClass({
+  getInitialState: function() {
+    return {state: 'view', saving: false};
+  },
+  setDeleting: function() {
+    this.setState({state: 'delete'});
+  },
+  setEditing: function() {
+    this.setState({state: 'edit'});
+  },
+  cancelEdit: function() {
+    this.setState({state: 'view'});
+  },
+  finishSave: function(promise) {
+    promise.then(this.props.onEdit).then(function() {
+      this.setState({state: 'view', saving: false});
+    }.bind(this));
+  },
+  commitDelete: function() {
+    this.setState({saving: true});
+    var record = this.props.record;
+    this.finishSave(cloudflare.record_delete(record.zone_name, record.rec_id));
+  },
+  commitEdit: function() {
+    this.setState({saving: true});
+    var record = this.props.record;
+    var newRecord = {
+      id: record.rec_id,
+      type: record.type,
+      name: this.refs.name.getDOMNode().value.trim(),
+      content: this.refs.value.getDOMNode().value.trim()
+    };
+    if(record.service_mode) {
+      newRecord.service_mode = record.service_mode;
+    }
+    this.finishSave(cloudflare.record_edit(record.zone_name, newRecord));
+  },
+  toggleProxy: function() {
+    this.setState({saving: true});
+    var record = this.props.record;
+    var newRecord = {
+      id: record.rec_id,
+      type: record.type,
+      name: record.name,
+      content: record.content,
+      service_mode: record.service_mode === "1" ? "0" : "1"
+    };
+    this.finishSave(cloudflare.record_edit(record.zone_name, newRecord));
+  },
+  render: function() {
+    var record = this.props.record;
+    var className = this.state.saving ? 'saving' : '';
+    if(this.state.state === 'edit') {
+      return (
+        <tr className={className}>
+          <td className="record-type"><span className={record.type}>{record.type}</span></td>
+          <td><input type="text" ref="name" defaultValue={record.display_name} /></td>
+          <td><input type="text" ref="value" defaultValue={record.display_content} /></td>
+          <td>
+            <a onClick={this.cancelEdit}>Cancel</a>
+          </td>
+          <td>
+            <button className="btn btn-success" onClick={this.commitEdit}>Save</button>
+          </td>
+        </tr>
+      );
+    }
+    else if(this.state.state === 'delete') {
+      return (
+        <tr className={className}>
+          <td className="record-type"><span className={record.type}>{record.type}</span></td>
+          <td><strong>{record.display_name}</strong></td>
+          <td>{record.display_content}</td>
+          <td>
+            <a onClick={this.cancelEdit}>Cancel</a>
+          </td>
+          <td>
+            <button className="btn btn-danger" onClick={this.commitDelete}>Delete</button>
+          </td>
+        </tr>
+      );
+    }
+    else {
+      return (
+        <tr className={className}>
+          <td className="record-type"><span className={record.type}>{record.type}</span></td>
+          <td><strong>{record.display_name}</strong></td>
+          <td className="value">{record.display_content}</td>
+          <td><CloudActive record={record} onClick={this.toggleProxy} /></td>
+          <td className="actions">
+            <button className="btn btn-primary" onClick={this.setEditing}>Edit</button>
+            <span> </span>
+            <button className="btn btn-danger" onClick={this.setDeleting}>Delete</button>
+          </td>
+        </tr>
+      );
+    }
+  }
+});
+var RecordList = React.createClass({
+  getInitialState: function() {
+    return {records: []};
+  },
+  componentDidMount: function() {
+    this.reload();
+  },
+  componentWillReceiveProps: function(nextProps) {
+    if(nextProps.domain != this.props.domain) {
+      this.setState({records: []});
+      this.reload(nextProps);
+    }
+  },
+  reload: function(props) {
+    if(!props) {
+      props = this.props;
+    }
+    return cloudflare.records(props.domain).then(function(data) {
+      this.setState({records: data.response.recs.objs});
+    }.bind(this));
+  },
+  render: function() {
+    var records = this.state.records.map(function(record) {
+      return <Record key={record.rec_id} record={record} onEdit={this.reload} />
+    }.bind(this));
+    return (
+      <div id="records">
+        <table className="table">
+          <thead>
+            <tr>
+              <th className="type">Type</th>
+              <th className="name">Name</th>
+              <th className="value">Value</th>
+              <th className="proxy">Proxy</th>
+              <th className="actions">Actions</th>
+            </tr>
+          </thead>
+          <tbody>
+            <RecordCreate domain={this.props.domain} onEdit={this.reload} />
+            {records}
+          </tbody>
+        </table>
+      </div>
+    );
+  }
+});
+
+module.exports = RecordList;

+ 77 - 0
lib/ui/Settings.jsx

@@ -0,0 +1,77 @@
+var cloudflare = require('../cloudflare');
+var React = require('react');
+
+var DevModeToggle = React.createClass({
+  render: function() {
+    if(this.props.devMode > 0) {
+      var date = new Date(this.props.devMode*1000);
+      var dateString = date.getFullYear()+'/'+(1+date.getMonth())+'/'+date.getDay()+' '+date.getHours()+':'+date.getMinutes();
+      return (
+        <div>
+          <button className='btn btn-success' onClick={this.props.onClick}>On</button>
+          <span> Active until {dateString}</span>
+        </div>
+      );
+    }
+    else {
+      return <button className='btn btn-default' onClick={this.props.onClick}>Off</button>
+    }
+  }
+});
+var PurgeButton = React.createClass({
+  getInitialState: function() {
+    return {purging: false};
+  },
+  purgeCache: function() {
+    this.setState({purging: true});
+    var reset = function() {
+      this.setState({purging: false});
+    }.bind(this);
+    cloudflare.purge_cache(this.props.domain).then(function(data) {
+      var timeout = data.attributes.cooldown;
+      setTimeout(reset, timeout*1000);
+    });
+  },
+  render: function() {
+    if(this.state.purging) {
+      return <button className="btn btn-warning" disabled>Purging...</button>
+    }
+    else {
+      return <button className="btn btn-success" onClick={this.purgeCache}>Purge</button>
+    }
+  }
+});
+var Settings = React.createClass({
+  getInitialState: function() {
+    return {settings: {}};
+  },
+  componentDidMount: function() {
+    this.reload();
+  },
+  reload: function() {
+    return cloudflare.settings(this.props.domain).then(function(data) {
+      this.setState({settings: data.response.result.objs[0]});
+    }.bind(this));
+  },
+  toggleDevMode: function() {
+    cloudflare.set_devmode(this.props.domain, this.state.settings.dev_mode == 0).then(this.reload);
+  },
+  render: function() {
+    return (
+      <div>
+        <table className="table">
+          <tr>
+            <td>Development Mode</td>
+            <td><DevModeToggle devMode={this.state.settings.dev_mode} onClick={this.toggleDevMode} /></td>
+          </tr>
+          <tr>
+            <td>Purge Cache</td>
+            <td><PurgeButton domain={this.props.domain} /></td>
+          </tr>
+        </table>
+      </div>
+    );
+  }
+});
+
+module.exports = Settings;

+ 55 - 0
main.css

@@ -0,0 +1,55 @@
+.table tbody>tr>td{
+	vertical-align: middle;
+}
+.record-type span {
+	margin: 0px 2px;
+	padding: 5px 10px;
+	display: block;
+	background: black;
+	color: white;
+	font-weight: bold;
+}
+.record-type span.CNAME {
+	background: orange;
+}
+.record-type span.MX {
+	background: magenta;
+}
+.record-type span.TXT {
+	background: limegreen;
+}
+.record-type span.SPF {
+	background: teal;
+}
+.record-type span.SRV {
+	background: olive;
+}
+.table {
+	table-layout: fixed;
+}
+.table .type {
+	width: 80px;
+}
+.table .name {
+	width: 200px;
+}
+.table .actions {
+	width: 120px;
+}
+.table .proxy {
+	width: 60px;
+}
+.table .value {
+	overflow: hidden;
+	white-space: nowrap;
+	width: 280px;
+}
+#records .table input {
+	width: 100%;
+}
+#records .table tr.saving {
+	background: lightyellow;
+}
+.nav.nav-tabs {
+	margin-bottom: 10px;
+}

+ 33 - 0
package.json

@@ -0,0 +1,33 @@
+{
+  "name": "cloudflare-webui",
+  "version": "0.0.1",
+  "description": "Rudimentary CloudFlare WebUI",
+  "main": "server.js",
+  "dependencies": {
+    "body-parser": "^1.10.0",
+    "connect": "^3.3.3",
+    "request": "^2.51.0",
+    "serve-static": "^1.7.1"
+  },
+  "devDependencies": {
+    "bootstrap": "^3.3.1",
+    "reqwest": "^1.1.5",
+    "react": "^0.12.2",
+    "react-mini-router": "^1.0.0",
+    "webpack": "^1.4.15",
+    "webpack-dev-server": "^1.7.0",
+    "css-loader": "^0.9.0",
+    "file-loader": "^0.8.1",
+    "jsx-loader": "^0.12.2",
+    "react-hot-loader": "^1.0.6",
+    "style-loader": "^0.8.2",
+    "url-loader": "^0.5.5"
+  },
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1",
+    "prestart": "webpack",
+    "start": "node server.js"
+  },
+  "author": "Thomas Dy <thatsmydoing@gmail.com>",
+  "license": "MIT"
+}

+ 84 - 0
server.js

@@ -0,0 +1,84 @@
+var fs = require('fs');
+var request = require('request');
+var connect = require('connect');
+var serveStatic = require('serve-static');
+var bodyParser = require('body-parser');
+var config = require('./config.json');
+
+var apiEndpoint = 'https://www.cloudflare.com/api_json.html';
+var isProd = config.isProd === undefined || config.isProd;
+
+var app = connect();
+var serve = serveStatic('.', {'index': []});
+var serveIndex = function(req, res, next) {
+    if(isProd) {
+        serve(req, res, next);
+    }
+    else {
+        var html = fs.readFileSync('./index.html', {encoding: 'utf8'});
+        res.setHeader('Content-Type', 'text/html; charset=utf8');
+        res.end(html.replace('/assets/bundle.js', 'http://localhost:8001/assets/bundle.js'));
+    }
+};
+
+app.use(bodyParser.urlencoded({extended: false}));
+app.use(function(req, res, next) {
+    if(req.url === '/api') {
+        req.body.email = config.email;
+        req.body.tkn = config.token;
+
+        // filter out only zones in the whitelist
+        if(req.body.a === 'zone_load_multi') {
+            request.post({uri: apiEndpoint, form: req.body, json: true}, function(err, inc, body) {
+                var filtered = body.response.zones.objs.filter(function(zone) {
+                    return config.whitelist.indexOf(zone.zone_name) >= 0;
+                });
+                body.response.zones.objs = filtered;
+                body.response.zones.count = filtered.length;
+                res.setHeader('Content-Type', 'application/json');
+                res.end(JSON.stringify(body));
+            });
+        }
+
+        // allow any requests for zones in whitelist
+        else if(config.whitelist.indexOf(req.body.z) >= 0) {
+            request.post(apiEndpoint).form(req.body).pipe(res);
+        }
+
+        // deny otherwise
+        else {
+            next();
+        }
+    }
+    else {
+        next();
+    }
+});
+app.use(serve);
+app.use(serveIndex);
+
+if(!isProd) {
+    var WebpackDevServer = require('webpack-dev-server');
+    var HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');
+
+    var webpack = require('webpack');
+    var webpackConfig = require('./webpack.config.js');
+    webpackConfig.entry = [
+        "webpack-dev-server/client?http://localhost:8001",
+        "webpack/hot/dev-server",
+        webpackConfig.entry
+    ];
+    webpackConfig.output.path = '/';
+    webpackConfig.output.publicPath = 'http://localhost:8001/assets/';
+    webpackConfig.plugins = webpackConfig.plugins || [];
+    webpackConfig.plugins.push(new HotModuleReplacementPlugin());
+    webpackConfig.devtool = 'eval';
+
+    var devServer = new WebpackDevServer(webpack(webpackConfig), {
+        contentBase: 'http://localhost:8000',
+        publicPath: webpackConfig.output.publicPath,
+        hot: true
+    })
+    devServer.listen(8001);
+}
+app.listen(8000);

+ 22 - 0
webpack.config.js

@@ -0,0 +1,22 @@
+module.exports = {
+    entry: './lib/index',
+    output: {
+        path: 'assets',
+        filename: 'bundle.js'
+    },
+    resolve: {
+        extensions: ['', '.js', '.jsx']
+    },
+    module: {
+        loaders: [
+            { test: /\.css$/, loader: "style!css" },
+            { test: /\.jsx$/, loaders: ["react-hot", "jsx"] },
+
+            // for bootstrap stuff
+            { test: /\.woff(\?v=\d+\.\d+\.\d+)?$/,   loader: "url?limit=10000&mimetype=application/font-woff" },
+            { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/,    loader: "url?limit=10000&mimetype=application/octet-stream" },
+            { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/,    loader: "file" },
+            { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,    loader: "url?limit=10000&mimetype=image/svg+xml" }
+        ]
+    }
+};