Browse Source

Implement the JSON URL validator. The form is now working \o/

Gu1 11 years ago
parent
commit
07d7193b4a

+ 3 - 0
ffdnispdb/static/js/site.js

@@ -40,6 +40,9 @@ function init_map() {
         maxZoom: 11
     });
 
+    if(!$('#map').length)
+        return;
+
     var map = L.map('map', {
         center: new L.LatLng(46.603354, 10),
         zoom: 4,

+ 4 - 0
ffdnispdb/templates/layout.html

@@ -4,6 +4,7 @@
 <!doctype html>
 <html lang="fr">
   <head>
+    {% block head -%}
     <meta charset="utf-8">
     <title>FFDN ISP Database</title>
     <!-- meta -->
@@ -16,6 +17,7 @@
     <link rel=stylesheet type=text/css href="{{ url_for('static', filename='css/bootstrap-select.min.css') }}">
     <link rel=stylesheet type=text/css href="{{ url_for('static', filename='css/leaflet.css') }}">
     <link rel=stylesheet type=text/css href="{{ url_for('static', filename='css/style.css') }}">
+    {%- endblock %}
   </head>
 <body>
 
@@ -68,11 +70,13 @@
 {% endblock container %}
 </div>
 
+{% block script -%}
 <script type="text/javascript" src="{{ url_for('static', filename='js/jquery.js') }}"></script>
 <script type="text/javascript" src="{{ url_for('static', filename='js/bootstrap.js') }}"></script>
 <script type="text/javascript" src="{{ url_for('static', filename='js/bootstrap-select.min.js') }}"></script>
 <script type="text/javascript" src="{{ url_for('static', filename='js/leaflet.js') }}"></script>
 <script type="text/javascript" src="{{ url_for('static', filename='js/site.js') }}"></script>
+{%- endblock %}
 </body>
 
 </html>

+ 87 - 0
ffdnispdb/templates/project_json_validator.html

@@ -0,0 +1,87 @@
+{% extends "layout.html" %}
+{% block head %}
+{{ super() }}
+    <style type="text/css">
+    .cursor {
+        display: inline-block;
+        background: #dfdfdf;
+        margin-left: 1px;
+        margin-top: 2px;
+        vertical-align: sub;
+        height: 16px;
+        animation: blink 2s linear 0s infinite;
+    }
+    @keyframes blink {
+        0% { background: #fff }
+        49% { background: #fff }
+        50% { background: #dfdfdf }
+        99% { background: #dfdfdf }
+        100% { background: #fff }
+    }
+    </style>
+{%- endblock %}
+{% block script %}
+{{ super() }}
+<script type="text/javascript">
+$(function() {
+    var evt;
+    function validate() {
+        evt = new EventSource("{{ url_for('json_url_validator') }}");
+        evt.onmessage = function(e) {
+            $('#status').append(e.data+'\n');
+            var pre=$("#status").parent();
+            pre.stop();
+            pre.animate({
+                'scrollTop': pre[0].scrollHeight
+            }, 500);
+        }
+        evt.onerror = function(e) {
+            $('#status').append('<span style="color: red;">/!\\ Error with the validation API, connection was closed. Retry in a few seconds\n</span>')
+            evt.close();
+            setTimeout(function() { $('#retry').removeAttr('disabled') }, 5000);
+        }
+        evt.addEventListener('control', function(e) {
+            var msg=$.parseJSON(e.data);
+            if(!!msg.passed)
+                $('input[type="submit"]').removeAttr('disabled');
+                evt.close();
+
+            if(!!msg.closed) {
+                evt.close();
+                setTimeout(function() { $('#retry').removeAttr('disabled') }, 5000);
+            }
+        });
+    }
+    $('#retry').click(function() {
+        $("#status").html('');
+        validate();
+        $('#retry').attr('disabled', 'disabled')
+        return false;
+    });
+    validate();
+});
+</script>
+{%- endblock %}
+{% block container %}
+<div class="row">
+  <div class="span11 well">
+    <form method="post" action="{{ url_for('create_project_json_confirm') }}" class="form-horizontal">
+      <fieldset>
+        <legend>{{ _("Validating the JSON URL") }}</legend>
+        <pre style="height: 500px; background-color: white; overflow-x: auto;">
+<div style="line-height: normal"> _______ _______ ______  _______ 
+|_______|_______|_____ \|  ___  \
+ ______  ______  _    \ | |   \ |   ____  _____ 
+|   ___)|   ___)| |   | | |   | |  |    \| __  |
+|  |    |  |    | |__/ /| |   | |  |  |  | __ -|
+|__|    |__|    |_____/ |_|   |_|  |____/|_____|
+</div>
+<div id="status"></div><div class="cursor"> </div></pre>
+        <div class="form-actions" style="text-align: right;">
+          <button id="retry" class="btn" disabled="disabled">{{ _("Retry") }}</button>
+          <input type="submit" disabled="disabled" class="btn btn-primary" value="{{ _("Confirm") }}" />
+        </div>
+      </fieldset>
+  </div>
+</div>
+{% endblock %}

+ 199 - 3
ffdnispdb/views.py

@@ -1,18 +1,22 @@
 # -*- coding: utf-8 -*-
 
 from flask import request, g, redirect, url_for, abort, \
-    render_template, flash, jsonify 
+    render_template, flash, json, session, Response, escape
 from flask.ext.babel import gettext as _
+import requests
 from datetime import date, time, timedelta, datetime
 from urlparse import urlunsplit
 import locale
 locale.setlocale(locale.LC_ALL, '')
 import string
+import io
+from time import time
 
 from . import forms
 from .constants import *
-from . import app
+from . import app, db
 from .models import ISP
+from .schemavalidator import validate_isp
 
 
 @app.route('/')
@@ -72,6 +76,181 @@ def create_project_form():
     return render_template('project_form.html', form=form)
 
 
+@app.route('/create/json-url/validator', methods=['GET'])
+def json_url_validator():
+    if 'form_json' not in session or \
+       session['form_json'].get('validated', False):
+        abort(403)
+
+    v=session['form_json'].get('validator')
+
+    if v is not None:
+        if v > time()-5:
+            abort(429)
+    else:
+        session['form_json']['validator']=time()
+
+    validator=ValidateJSONURL(session=session._get_current_object())
+    return Response(validator(session['form_json']['url']),
+                    mimetype="text/event-stream")
+
+
+class ValidateJSONURL(object):
+
+    MAX_JSON_SIZE=1*1024*1024
+
+    def __init__(self, **kwargs):
+        self.__dict__.update(kwargs)
+
+    def m(self, msg, evt=None):
+        return '%sdata: %s\n\n'%('event: %s\n'%evt if evt else '', msg)
+
+    def err(self, msg, *args):
+        return self.m('<strong style="color: crimson">!</strong> %s'%msg, *args)
+
+    def warn(self, msg):
+        return self.m('<strong style="color: dodgerblue">@</strong> %s'%msg)
+
+    def info(self, msg):
+        return self.m('&ndash; %s'%msg)
+
+    def abort(self, msg):
+        return (self.m('<br />== <span style="color: crimson">%s</span>'%msg)+
+                self.m(json.dumps({'closed': 1}), 'control'))
+
+    def done_cb(self):
+        self.session['form_json']['validated']=True
+        self.session['form_json']['jdict']=self.jdict
+        self.session.save()
+
+    def __call__(self, url):
+        yield self.m('Starting the validation process...')
+        r=None
+        try:
+            yield self.m('* Attempting to retreive <strong>%s</strong>'%url)
+            r=requests.get(url, verify='/etc/ssl/certs/ca-certificates.crt',
+                           headers={'User-Agent': 'FFDN DB validator'},
+                           stream=True, timeout=10)
+        except requests.exceptions.SSLError as e:
+            yield self.err('Unable to connect, SSL Error: <code style="color: #dd1144;">%s</code>'%escape(e))
+        except requests.exceptions.ConnectionError as e:
+            yield self.err('Unable to connect: <code style="color: #dd1144;">%s</code>'%e)
+        except requests.exceptions.Timeout as e:
+            yield self.err('Connection timeout')
+        except requests.exceptions.TooManyRedirects as e:
+            yield self.err('Too many redirects')
+        except requests.exceptions.RequestException as e:
+            yield self.err('Internal request exception')
+        except Exception as e:
+            yield self.err('Unexpected request exception')
+
+        if r is None:
+            yield self.abort('Connection could not be established, aborting')
+            return
+
+        yield self.info('Connection established')
+
+        yield self.info('Response code: <strong>%s %s</strong>'%(escape(r.status_code), escape(r.reason)))
+        try:
+            r.raise_for_status()
+        except requests.exceptions.HTTPError as e:
+            yield cls.err('Response code indicates an error')
+            yield cls.abort('Invalid response code')
+            return
+
+        yield self.info('Content type: <strong>%s</strong>'%(escape(r.headers.get('content-type', 'not defined'))))
+        if not r.headers.get('content-type'):
+            yield self.error('Content-type <strong>MUST</strong> be defined')
+            yield self.abort('The file must have a proper content-type to continue')
+        elif r.headers.get('content-type').lower() != 'application/json':
+            yield self.warn('Content-type <em>SHOULD</em> be application/json')
+
+        if not r.encoding:
+            yield self.warn('Encoding not set. Assuming it\'s unicode, as per RFC4627 section 3')
+
+        yield self.info('Content length: <strong>%s</strong>'%(escape(r.headers.get('content-length', 'not set'))))
+
+        cl=r.headers.get('content-length')
+        if not cl:
+            yield self.warn('No content-length. Note that we will not process a file whose size exceed 1MiB')
+        elif int(cl) > self.MAX_JSON_SIZE:
+            yield self.abort('File too big ! File size must be less then 1MiB')
+
+        yield self.info('Reading response into memory...')
+        b=io.BytesIO()
+        for d in r.iter_content(requests.models.CONTENT_CHUNK_SIZE):
+            b.write(d)
+            if b.tell() > self.MAX_JSON_SIZE:
+                yield self.abort('File too big ! File size must be less then 1MiB')
+                return
+        r._content=b.getvalue()
+        del b
+        yield self.info('Successfully read %d bytes'%len(r.content))
+
+        yield self.m('<br>* Parsing the JSON file')
+        if not r.encoding:
+            charset=requests.utils.guess_json_utf(r.content)
+            if not charset:
+                yield self.err('Unable to guess unicode charset')
+                yield self.abort('The file MUST be unicode-encoded when no explicit charset is in the content-type')
+                return
+
+            yield self.info('Guessed charset: <strong>%s</strong>'%charset)
+
+        try:
+            txt=r.content.decode(r.encoding or charset)
+            yield self.info('Successfully decoded file as %s'%escape(r.encoding or charset))
+        except LookupError as e:
+            yield self.err('Invalid/unknown charset: %s'%escape(e))
+            yield self.abort('Charset error, Cannot continue')
+            return
+        except UnicodeDecodeError as e:
+            yield self.err('Unicode decode error: %s'%e)
+            yield self.abort('Charset error, cannot continue')
+            return
+        except Exception:
+            yield self.abort('Unexpected charset error')
+            return
+
+        jdict=None
+        try:
+            jdict=json.loads(txt)
+        except ValueError as e:
+            yield self.err('Error while parsing JSON: %s'%escape(e))
+        except Exception as e:
+            yield self.err('Unexpected error while parsing JSON: %s'%escape(e))
+
+        if not jdict:
+            yield self.abort('Could not parse JSON')
+            return
+
+        yield self.info('JSON parsed successfully')
+
+        yield self.m('<br />* Validating the JSON against the schema')
+
+        v=list(validate_isp(jdict))
+        if v:
+            yield self.err('Errors: %s'%escape(str(v)))
+            yield self.abort('Your JSON file does not follow the schema, please fix it')
+        else:
+            yield self.info('Done. No errors encountered \o')
+
+        # check name uniqueness
+        where = (ISP.name == jdict['name'])
+        if 'shortname' in jdict and jdict['shortname']:
+            where |= (ISP.shortname == jdict.get('shortname'))
+        if ISP.query.filter(where).count() > 1:
+            yield self.info('An ISP named %s already exist'%escape(
+                jdict['name']+(' ('+jdict['shortname']+')' if jdict.get('shortname') else '')
+            ))
+
+        yield (self.m('<br />== <span style="color: forestgreen">All good ! You can click on Confirm now</span>')+
+               self.m(json.dumps({'passed': 1}), 'control'))
+
+        self.jdict=jdict
+        self.done_cb()
+
+
 @app.route('/create/json-url', methods=['GET', 'POST'])
 def create_project_json():
     form = forms.ProjectJSONForm()
@@ -79,13 +258,30 @@ def create_project_json():
         u=list(form.url.data)
         u[2]='/isp.json' # new path
         url=urlunsplit(u)
+        session['form_json'] = {'url': url}
+        return render_template('project_json_validator.html')
+    return render_template('project_json_form.html', form=form)
+
 
+@app.route('/create/json-url/confirm', methods=['POST'])
+def create_project_json_confirm():
+    if 'form_json' in session and session['form_json'].get('validated', False):
+        if not forms.is_url_unique(session['form_json']['url']):
+            abort(409)
+        jdict=session['form_json']['jdict']
         isp=ISP()
+        isp.name=jdict['name']
+        isp.shotname=jdict['shortname']
+        isp.url=session['form_json']['url']
+        isp.json=jdict
+        del session['form_json']
+
         db.session.add(isp)
         db.session.commit()
         flash(_(u'Project created'), 'info')
         return redirect(url_for('project', projectid=isp.id))
-    return render_template('project_json_form.html', form=form)
+    else:
+        return redirect(url_for('create_project_json'))
 
 
 @app.route('/search', methods=['GET', 'POST'])

+ 4 - 0
requirements.txt

@@ -9,3 +9,7 @@ argparse==1.2.1
 itsdangerous==0.23
 wsgiref==0.1.2
 jsonschema==2.0.0
+requests==2.0.0
+# ndg & pyasn required for SNI
+ndg-httpsclient==0.3.2
+pyasn1==0.1.7

+ 1 - 1
run.py

@@ -1,2 +1,2 @@
 from ffdnispdb import app
-app.run(debug=True)
+app.run(debug=True, threaded=True)