From 258b4a9852d8a7c5e16daf71fecd209d68df7026 Mon Sep 17 00:00:00 2001
From: Andreas <andreasv@fsmpi.rwth-aachen.de>
Date: Tue, 30 Jan 2018 15:07:56 +0100
Subject: [PATCH] added module support and a basic api module

---
 modules/__init__.py                           |   4 +
 modules/mod_api/__init__.py                   |  13 ++
 modules/mod_api/auth.py                       |  26 +++
 modules/mod_api/autodoc.py                    | 194 ++++++++++++++++++
 modules/mod_api/autodoc_LICENSE               |  21 ++
 modules/mod_api/errorhandlers.py              |  15 ++
 modules/mod_api/help.py                       |   8 +
 modules/mod_api/public.py                     |  36 ++++
 .../mod_api/templates/autodoc_default.html    |  82 ++++++++
 server.py                                     |  24 ++-
 10 files changed, 415 insertions(+), 8 deletions(-)
 create mode 100644 modules/__init__.py
 create mode 100644 modules/mod_api/__init__.py
 create mode 100644 modules/mod_api/auth.py
 create mode 100644 modules/mod_api/autodoc.py
 create mode 100644 modules/mod_api/autodoc_LICENSE
 create mode 100644 modules/mod_api/errorhandlers.py
 create mode 100644 modules/mod_api/help.py
 create mode 100644 modules/mod_api/public.py
 create mode 100644 modules/mod_api/templates/autodoc_default.html

diff --git a/modules/__init__.py b/modules/__init__.py
new file mode 100644
index 0000000..faa8dc9
--- /dev/null
+++ b/modules/__init__.py
@@ -0,0 +1,4 @@
+from server import app
+
+from modules.mod_api import api
+app.register_blueprint(api)
diff --git a/modules/mod_api/__init__.py b/modules/mod_api/__init__.py
new file mode 100644
index 0000000..4b2da78
--- /dev/null
+++ b/modules/mod_api/__init__.py
@@ -0,0 +1,13 @@
+from flask import Blueprint, jsonify
+from server import app, query
+import json
+
+api = Blueprint('api', __name__, url_prefix='/api')
+
+from . import autodoc
+doc = autodoc.Autodoc(app)
+
+from . import  errorhandlers
+from . import  help
+from . import  auth
+from . import  public
diff --git a/modules/mod_api/auth.py b/modules/mod_api/auth.py
new file mode 100644
index 0000000..4371ecc
--- /dev/null
+++ b/modules/mod_api/auth.py
@@ -0,0 +1,26 @@
+from . import *
+
+from server import dologin, ismod as session_ismod
+from flask import make_response, request, session
+
+def ismod():
+	return session_ismod()
+
+@api.route('/moderator/login', methods=['POST', 'GET'])
+@doc.doc()
+def modlogin():
+	"""Returns a access token in a cookie used for all other moderator functions. Accepts *user* and *password* as post data"""
+	return jsonify(dologin(request.values['user'], request.values['password']))
+
+@api.route('/moderator/logout', methods=['POST', 'GET'])
+@doc.doc()
+def modlogout():
+	"""Removes a moderator token in a cookie"""
+	session.pop('user', None)
+	return jsonify(True)
+
+@api.route('/moderator/test')
+@doc.doc()
+def modtest():
+	"""Returns if you are logged in as moderator"""
+	return jsonify(ismod())
diff --git a/modules/mod_api/autodoc.py b/modules/mod_api/autodoc.py
new file mode 100644
index 0000000..bb8f23b
--- /dev/null
+++ b/modules/mod_api/autodoc.py
@@ -0,0 +1,194 @@
+from operator import attrgetter, itemgetter
+import os
+import re
+from collections import defaultdict
+import sys
+import inspect
+
+from flask import current_app, render_template, render_template_string
+from jinja2 import evalcontextfilter
+
+
+try:
+    from flask import _app_ctx_stack as stack
+except ImportError:
+    from flask import _request_ctx_stack as stack
+
+
+if sys.version < '3':
+    get_function_code = attrgetter('func_code')
+else:
+    get_function_code = attrgetter('__code__')
+
+
+class Autodoc(object):
+
+    def __init__(self, app=None):
+        self.app = app
+        self.func_groups = defaultdict(set)
+        self.func_props = defaultdict()
+        self.immutable_props = ['rule', 'endpoint']
+        self.default_props = ['methods', 'docstring', 
+            'args', 'defaults', 'location'] + self.immutable_props
+        self.func_locations = defaultdict(dict)
+        if app is not None:
+            self.init_app(app)
+
+    def init_app(self, app):
+        if hasattr(app, 'teardown_appcontext'):
+            app.teardown_appcontext(self.teardown)
+        else:
+            app.teardown_request(self.teardown)
+        self.add_custom_template_filters(app)
+
+    def teardown(self, exception):
+        ctx = stack.top
+
+    def add_custom_template_filters(self, app):
+        """Add custom filters to jinja2 templating engine"""
+        self.add_custom_nl2br_filters(app)
+
+    def add_custom_nl2br_filters(self, app):
+        """Add a custom filter nl2br to jinja2
+         Replaces all newline to <BR>
+        """
+        _paragraph_re = re.compile(r'(?:\r\n|\r|\n){3,}')
+
+        @app.template_filter()
+        @evalcontextfilter
+        def nl2br(eval_ctx, value):
+            result = '\n\n'.join('%s' % p.replace('\n', '<br>\n')
+                                 for p in _paragraph_re.split(value))
+            return result
+
+    def doc(self, groups=None, set_location=True, **properties):
+        """Add flask route to autodoc for automatic documentation
+
+        Any route decorated with this method will be added to the list of
+        routes to be documented by the generate() or html() methods.
+
+        By default, the route is added to the 'all' group.
+        By specifying group or groups argument, the route can be added to one
+        or multiple other groups as well, besides the 'all' group.
+
+        If set_location is True, the location of the function will be stored.
+        NOTE: this assumes that the decorator is placed just before the
+        function (in the normal way).
+
+        Custom parameters may also be passed in beyond groups, if they are
+        named something not already in the dict descibed in the docstring for
+        the generare() function, they will be added to the route's properties,
+        which can be accessed from the template.
+
+        If a parameter is passed in with a name that is already in the dict, but
+        not of a reserved name, the passed parameter overrides that dict value.
+        """
+        def decorator(f):
+            # Get previous group list (if any)
+            if f in self.func_groups:
+                groupset = self.func_groups[f]
+            else:
+                groupset = set()
+
+            # Set group[s]
+            if type(groups) is list:
+                groupset.update(groups)
+            elif type(groups) is str:
+                groupset.add(groups)
+            groupset.add('all')
+            self.func_groups[f] = groupset
+            self.func_props[f] = properties
+
+            # Set location
+            if set_location:
+                caller_frame = inspect.stack()[1]
+                self.func_locations[f] = {
+                        'filename': caller_frame[1],
+                        'line':     caller_frame[2],
+                        }
+
+            return f
+        return decorator
+
+    def generate(self, groups='all', sort=None):
+        """Return a list of dict describing the routes specified by the
+        doc() method
+
+        Each dict contains:
+         - methods: the set of allowed methods (ie ['GET', 'POST'])
+         - rule: relative url (ie '/user/<int:id>')
+         - endpoint: function name (ie 'show_user')
+         - doc: docstring of the function
+         - args: function arguments
+         - defaults: defaults values for the arguments
+
+        By specifying the group or groups arguments, only routes belonging to
+        those groups will be returned.
+
+        Routes are sorted alphabetically based on the rule.
+        """
+        groups_to_generate = list()
+        if type(groups) is list:
+            groups_to_generate = groups
+        elif type(groups) is str:
+            groups_to_generate.append(groups)
+
+        links = []
+        for rule in current_app.url_map.iter_rules():
+
+            if rule.endpoint == 'static':
+                continue
+
+            func = current_app.view_functions[rule.endpoint]
+            arguments = rule.arguments if rule.arguments else ['None']
+            func_groups = self.func_groups[func]
+            func_props = self.func_props[func] if func in self.func_props \
+                else {}
+            location = self.func_locations.get(func, None)
+
+            if func_groups.intersection(groups_to_generate):
+                props = dict(
+                    methods=rule.methods,
+                    rule="%s" % rule,
+                    endpoint=rule.endpoint,
+                    docstring=func.__doc__,
+                    args=arguments,
+                    defaults=rule.defaults,
+                    location=location,
+                )
+                for p in func_props:
+                    if p not in self.immutable_props:
+                        props[p] = func_props[p]
+                links.append(props)
+        if sort:
+            return sort(links)
+        else:
+            return sorted(links, key=itemgetter('rule'))
+
+    def html(self, groups='all', template=None, **context):
+        """Return an html string of the routes specified by the doc() method
+
+        A template can be specified. A list of routes is available under the
+        'autodoc' value (refer to the documentation for the generate() for a
+        description of available values). If no template is specified, a
+        default template is used.
+
+        By specifying the group or groups arguments, only routes belonging to
+        those groups will be returned.
+        """
+        context['autodoc'] = context['autodoc'] if 'autodoc' in context \
+            else self.generate(groups=groups)
+        context['defaults'] = context['defaults'] if 'defaults' in context \
+            else self.default_props
+        if template:
+            return render_template(template, **context)
+        else:
+            filename = os.path.join(
+                os.path.dirname(__file__),
+                'templates',
+                'autodoc_default.html'
+            )
+            with open(filename) as file:
+                content = file.read()
+                with current_app.app_context():
+                    return render_template_string(content, **context)
diff --git a/modules/mod_api/autodoc_LICENSE b/modules/mod_api/autodoc_LICENSE
new file mode 100644
index 0000000..69ec11e
--- /dev/null
+++ b/modules/mod_api/autodoc_LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2013 Arnaud Coomans
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
diff --git a/modules/mod_api/errorhandlers.py b/modules/mod_api/errorhandlers.py
new file mode 100644
index 0000000..af86bae
--- /dev/null
+++ b/modules/mod_api/errorhandlers.py
@@ -0,0 +1,15 @@
+from . import *
+import traceback
+
+@api.errorhandler(404)
+def handle_not_found(e=None):
+	return 'Error', 404
+
+@api.errorhandler(500)
+@api.errorhandler(Exception)
+def handle_internal_error(e):
+	if not e:
+		return 'Error', 505
+	else:
+		traceback.print_exc()
+		return 'Error', 500
diff --git a/modules/mod_api/help.py b/modules/mod_api/help.py
new file mode 100644
index 0000000..d281b22
--- /dev/null
+++ b/modules/mod_api/help.py
@@ -0,0 +1,8 @@
+from . import *
+
+@api.route('/help')
+@api.route('/')
+@doc.doc()
+def help():
+	"""Prints an API description """
+	return doc.html()
diff --git a/modules/mod_api/public.py b/modules/mod_api/public.py
new file mode 100644
index 0000000..62c05c1
--- /dev/null
+++ b/modules/mod_api/public.py
@@ -0,0 +1,36 @@
+from . import *
+from server import genlive
+
+@api.route('/courses')
+@doc.doc()
+def courses():
+	"""Returns a list of all courses without the lectures, optionaly takes a moderator token as request parameter *token* """
+	return jsonify(query('SELECT title, description, handle, id, organizer, semester, short, subject FROM courses WHERE (? OR (visible AND listed)) ORDER BY lower(semester), lower(title)'), auth.ismod())
+
+@api.route('/<int:id>')
+@api.route('/<handle>')
+@doc.doc()
+def course(id=None, handle=None):
+	"""Returns data to a single course, including the lectures, optionaly takes a moderator token as request parameter *token* """
+	coursedata = query('SELECT title, description, handle, id, organizer, semester, short, subject FROM courses WHERE (? OR (visible AND listed)) AND (id = ? OR handle = ?) ORDER BY lower(semester), lower(title)', auth.ismod(), id, handle)
+	if len(coursedata) == 1:
+		coursedata[0]['lectures'] = query('SELECT id, title, time, comment, speaker, duration, place FROM lectures WHERE course_id = ? AND (visible or ?)', coursedata[0]['id'], auth.ismod())
+		for lecture in coursedata[0]['lectures']:
+			videos = query('''
+					SELECT videos.id, videos.comment, videos.file_size, videos.hash, videos.path, "formats" AS sep, formats.description, formats.resolution
+					FROM videos
+					JOIN lectures ON (videos.lecture_id = lectures.id)
+					JOIN formats ON (videos.video_format = formats.id)
+					JOIN courses ON (lectures.course_id = courses.id)
+					WHERE lectures.course_id= ? AND (? OR videos.visible)
+					ORDER BY lectures.time, formats.prio DESC
+					''', coursedata[0]['id'], auth.ismod())
+			livestreams = query('''SELECT streams.handle AS livehandle, streams.lecture_id, "formats" AS sep, formats.*
+					FROM streams
+					JOIN lectures ON lectures.id = streams.lecture_id
+					JOIN formats ON formats.keywords = "hls"
+					WHERE streams.active AND (? OR streams.visible) AND lectures.course_id = ?
+					''', auth.ismod(), coursedata[0]['id'])
+			videos += genlive(livestreams)
+			lecture['videos'] = videos
+	return jsonify(coursedata[0] if len(coursedata) == 1 else [])
diff --git a/modules/mod_api/templates/autodoc_default.html b/modules/mod_api/templates/autodoc_default.html
new file mode 100644
index 0000000..247448f
--- /dev/null
+++ b/modules/mod_api/templates/autodoc_default.html
@@ -0,0 +1,82 @@
+<html>
+    <head>
+        <title>
+            {% if title is defined -%}
+                {{title}}
+            {% else -%}
+                Documentation
+            {% endif -%}
+        </title>
+        <style>
+            * {
+                margin: 0;
+                padding: 0;
+                font-family: Verdana, "Helvetica Neue", Arial, Helvetica, Geneva, sans-serif;
+            }
+
+            body {
+                margin: 10px;
+            }
+
+            div.mapping {
+                margin: 20px 20px;
+            }
+
+            ul.methods:before { content: "Methods: "; }
+            ul.methods li {
+                display: inline;
+                list-style: none;
+            }
+            ul.methods li:after { content: ","; }
+            ul.methods li:last-child:after { content: ""; }
+
+            ul.arguments:before { content: "Arguments: "; }
+            ul.arguments li {
+                display: inline;
+                list-style: none;
+            }
+            ul.arguments .argument { font-style:italic }
+            ul.arguments .default:not(:empty):before { content: "("; }
+            ul.arguments .default:not(:empty):after { content: ")"; }
+            ul.arguments li:after { content: ","; }
+            ul.arguments li:last-child:after { content: ""; }
+
+            .docstring:before { content: "Description: "; }
+        </style>
+    </head>
+    <body>
+        <h1>
+            {% if title is defined -%}
+                {{title}}
+            {% else -%}
+                Documentation
+            {% endif -%}
+        </h1>
+
+        {% for doc in autodoc %}
+        <div class="mapping">
+            <a id="rule-{{doc.rule|urlencode}}" class="rule"><h2>{{doc.rule|escape}}</h2></a>
+            <ul class="methods">
+                {% for method in doc.methods -%}
+                    {% if method == 'GET' and doc.args == ['None'] %}
+                        <a href="{{doc.rule}}" class="getmethod">
+                            <li class="method">{{method}}</li>
+                        </a>
+                    {% else %}
+                        <li class="method">{{method}}</li>
+                    {% endif %}
+                {% endfor %}
+            </ul>
+            <ul class="arguments">
+                {% for arg in doc.args %}
+                <li>
+                    <span class="argument">{{arg}}</span>
+                    <span class="default">{{doc.defaults[arg]}}</span>
+                </li>
+                {% endfor %}
+            </ul>
+            <p class="docstring">{{doc.docstring|urlize|nl2br}}</p>
+        </div>
+        {% endfor %}
+    </body>
+</html>
diff --git a/server.py b/server.py
index e484976..f2635a6 100644
--- a/server.py
+++ b/server.py
@@ -341,15 +341,11 @@ def check_mod(user, groups):
 			return True
 	return False
 
-@app.route('/internal/login', methods=['GET', 'POST'])
-def login():
-	if request.method == 'GET':
-		return render_template('login.html')
-	userinfo, groups = ldapauth(request.form.get('user'), request.form.get('password'))
+def dologin(username, password):
+	userinfo, groups = ldapauth(username, password)
 	user = userinfo.get('uid')
 	if not check_mod(user, groups):
-		flash('Login fehlgeschlagen!')
-		return make_response(render_template('login.html'), 403)
+		return False
 	session['user'] = userinfo
 	dbuser = query('SELECT * FROM users WHERE name = ?', user)
 	if not dbuser:
@@ -358,7 +354,17 @@ def login():
 	session['user']['dbid'] = dbuser[0]['id']
 	session['_csrf_token'] = ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(64))
 	session.permanent = True
-	return redirect(request.values.get('ref', url_for('index')))
+	return True
+
+@app.route('/internal/login', methods=['GET', 'POST'])
+def login():
+	if request.method == 'GET':
+		return render_template('login.html')
+	if not dologin (request.form.get('user'), request.form.get('password')):
+		flash('Login fehlgeschlagen!')
+		return make_response(render_template('login.html'), 403)
+	else:
+		return redirect(request.values.get('ref', url_for('index')))
 
 @app.route('/internal/logout', methods=['GET', 'POST'])
 def logout():
@@ -490,3 +496,5 @@ import chapters
 import icalexport
 import livestreams
 import cutprogress
+
+import modules
-- 
GitLab