From 016ef1d1934ff94e2dd4c5df3374c6d492e3fe9f Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Sun, 16 Oct 2016 20:53:02 -0500 Subject: [PATCH 1/2] Add a functioning git end point (with authentication), needs preview/update integration --- course/auth.py | 4 +- course/enrollment.py | 14 +- course/templates/course/course-base.html | 1 - course/versioning.py | 176 ++++++++++++++++++++++- relate/urls.py | 7 + 5 files changed, 193 insertions(+), 9 deletions(-) diff --git a/course/auth.py b/course/auth.py index 77399f9d..7453ce8e 100644 --- a/course/auth.py +++ b/course/auth.py @@ -24,7 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import cast, Any, Optional # noqa +from typing import cast, Text, Any, Optional # noqa from django.utils.translation import ugettext_lazy as _, string_concat from django.shortcuts import ( # noqa render, get_object_or_404, redirect, resolve_url) @@ -248,6 +248,8 @@ def impersonation_context_processor(request): def make_sign_in_key(user): + # type: (User) -> Text + # Try to ensure these hashes aren't guessable. import random import hashlib diff --git a/course/enrollment.py b/course/enrollment.py index 6e738655..0f768788 100644 --- a/course/enrollment.py +++ b/course/enrollment.py @@ -73,18 +73,18 @@ from pytools.lex import RE as REBase from typing import Any, Tuple, Text, Optional # noqa from course.utils import CoursePageContext # noqa +import accounts.models # noqa # }}} -# {{{ get_participation_for_request +# {{{ get_participation_for_{user,request} -def get_participation_for_request(request, course): - # type: (http.HttpRequest, Course) -> Optional[Participation] +def get_participation_for_user(user, course): + # type: (accounts.models.User, Course) -> Optional[Participation] # "wake up" lazy object # http://stackoverflow.com/questions/20534577/int-argument-must-be-a-string-or-a-number-not-simplelazyobject # noqa - user = request.user try: possible_user = user._wrapped except AttributeError: @@ -110,6 +110,12 @@ def get_participation_for_request(request, course): return participations[0] + +def get_participation_for_request(request, course): + # type: (http.HttpRequest, Course) -> Optional[Participation] + + return get_participation_for_user(request.user, course) + # }}} diff --git a/course/templates/course/course-base.html b/course/templates/course/course-base.html index 5a8d0d87..117d9f33 100644 --- a/course/templates/course/course-base.html +++ b/course/templates/course/course-base.html @@ -97,7 +97,6 @@ {% if pperm.preview_content or pperm.update_content %}
  • {% trans "Retrieve/preview new course revisions" %}
  • -
  • {% trans "Manage Git Authentication Token" %}
  • {% endif %} {% if pperm.edit_course %} diff --git a/course/versioning.py b/course/versioning.py index 943e04ff..c33374b1 100644 --- a/course/versioning.py +++ b/course/versioning.py @@ -2,7 +2,10 @@ from __future__ import division -__copyright__ = "Copyright (C) 2014 Andreas Kloeckner" +__copyright__ = """ +Copyright (C) 2014 Andreas Kloeckner +Copyright (c) 2016 Polyconseil SAS. (the WSGI wrapping bits) +""" __license__ = """ Permission is hereby granted, free of charge, to any person obtaining a copy @@ -31,7 +34,8 @@ from django.shortcuts import ( # noqa from django.contrib import messages import django.forms as forms from django.contrib.auth.decorators import login_required, permission_required -from django.core.exceptions import PermissionDenied, SuspiciousOperation +from django.core.exceptions import (PermissionDenied, SuspiciousOperation, + ObjectDoesNotExist) from django.utils.translation import ( ugettext_lazy as _, ugettext, @@ -39,8 +43,12 @@ from django.utils.translation import ( pgettext_lazy, string_concat, ) +from django.views.decorators.csrf import csrf_exempt + from django_select2.forms import Select2Widget from bootstrap3_datetime.widgets import DateTimePicker +from django.urls import reverse +from django.contrib.auth import get_user_model from django.db import transaction @@ -67,7 +75,7 @@ from course.constants import ( # {{{ for mypy from django import http # noqa -from typing import Tuple, List, Text, Any # noqa +from typing import cast, Tuple, List, Text, Any # noqa from dulwich.client import GitClient # noqa # }}} @@ -662,6 +670,25 @@ def update_course(pctx): "", ])) + text_lines.append( + string_concat( + "", + ugettext("Direct git endpoint"), + "" + "%(git_url)s", + " ", + '(', + ugettext("Manage access token"), + ')', + "" + ) + % { + "git_url": request.build_absolute_uri( + reverse("relate-git_endpoint", + args=(pctx.course.identifier,))), + "token_url": reverse("relate-manage_authentication_token"), + }) + text_lines.append("") return render_course_page(pctx, "course/generic-course-form.html", { @@ -675,4 +702,147 @@ def update_course(pctx): # }}} + +# {{{ git endpoint + + +# {{{ wsgi wrapping + +# Nabbed from +# https://github.com/Polyconseil/django-viewsgi/blob/master/viewsgi.py +# (BSD-licensed) + +def shift_path(environ, prefix): + assert environ['PATH_INFO'].startswith(prefix) + environ['SCRIPT_NAME'] += prefix + environ['PATH_INFO'] = environ['PATH_INFO'][len(prefix):] + + +def call_wsgi_app(application, request): + response = http.HttpResponse() + + # request.environ and request.META are the same object, so changes + # to the headers by middlewares will be seen here. + environ = request.environ.copy() + #if len(args) > 0: + # shift_path(environ, '/' + args[0]) + + if six.PY2: + # Django converts SCRIPT_NAME and PATH_INFO to unicode in WSGIRequest. + environ['SCRIPT_NAME'] = environ['SCRIPT_NAME'].encode('iso-8859-1') + environ['PATH_INFO'] = environ['PATH_INFO'].encode('iso-8859-1') + + headers_set = [] + headers_sent = [] + + def write(data): + if not headers_set: + raise AssertionError("write() called before start_response()") + if not headers_sent: + # Send headers before the first output. + for k, v in headers_set: + response[k] = v + headers_sent[:] = [True] + response.write(data) + # We could call response.flush() here, but is actually a no-op. + + def start_response(status, headers, exc_info=None): + # Let Django handle all errors. + if exc_info: + raise exc_info[1].with_traceback(exc_info[2]) + if headers_set: + raise AssertionError("start_response() called again " + "without exc_info") + response.status_code = int(status.split(' ', 1)[0]) + headers_set[:] = headers + # Django provides no way to set the reason phrase (#12747). + return write + + result = application(environ, start_response) + try: + for data in result: + if data: + write(data) + if not headers_sent: + write('') + finally: + if hasattr(result, 'close'): + result.close() + + return response + +# }}} + + +@csrf_exempt +def git_endpoint(request, course_identifier, git_path): + # type: (http.HttpRequest, Text) -> http.HttpResponse + + auth_value = request.META.get("HTTP_AUTHORIZATION") + + user = None + if auth_value is not None: + auth_values = auth_value.split(" ") + if len(auth_values) == 2: + auth_method, auth_data = auth_values + if auth_method == "Basic": + from base64 import b64decode + auth_data = b64decode(auth_data.strip()).decode( + "utf-8", errors="replace") + auth_data_values = auth_data.split(':', 1) + if len(auth_data_values) == 2: + username, token = auth_data_values + try: + possible_user = get_user_model().objects.get( + username=username) + except ObjectDoesNotExist: + pass + else: + from django.contrib.auth.hashers import check_password + if check_password( + token, possible_user.git_auth_token_hash): + user = possible_user + + if user is None: + realm = _("Relate direct git access") + response = http.HttpResponse( + _('Authorization Required'), content_type="text/plain") + response['WWW-Authenticate'] = 'Basic realm="%s"' % (realm) + response.status_code = 401 + return response + + course = get_object_or_404(Course, identifier=course_identifier) + + from course.enrollment import get_participation_for_user + participation = get_participation_for_user(user, course) + if participation is None: + raise PermissionDenied() + + if not ( + participation.has_permission(pperm.update_content) + or + participation.has_permission(pperm.preview_content)): + raise PermissionDenied() + + from course.content import get_course_repo + repo = get_course_repo(course) + + from course.content import SubdirRepoWrapper + if isinstance(repo, SubdirRepoWrapper): + true_repo = repo.repo + else: + true_repo = cast(dulwich.repo.Repo, repo) + + base_path = reverse(git_endpoint, args=(course_identifier, "")) + assert base_path.endswith("/") + base_path = base_path[:-1] + + import dulwich.web as dweb + backend = dweb.DictBackend({base_path: true_repo}) + app = dweb.make_wsgi_chain(backend) + + return call_wsgi_app(app, request) + +# }}} + # vim: foldmethod=marker diff --git a/relate/urls.py b/relate/urls.py index 1dbd910e..8a4d1f9c 100644 --- a/relate/urls.py +++ b/relate/urls.py @@ -340,6 +340,13 @@ urlpatterns = [ "/update/$", course.versioning.update_course, name="relate-update_course"), + url(r"^course" + "/" + COURSE_ID_REGEX + + "/git" + "/(?P.*)" + "$", + course.versioning.git_endpoint, + name="relate-git_endpoint"), # }}} -- GitLab From a98ed433aa6edc7f2b786d11165658b1da51cda9 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Sun, 16 Oct 2016 21:10:19 -0500 Subject: [PATCH 2/2] Fix type signature of git endpoint --- course/versioning.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/course/versioning.py b/course/versioning.py index c33374b1..1960b06e 100644 --- a/course/versioning.py +++ b/course/versioning.py @@ -776,7 +776,7 @@ def call_wsgi_app(application, request): @csrf_exempt def git_endpoint(request, course_identifier, git_path): - # type: (http.HttpRequest, Text) -> http.HttpResponse + # type: (http.HttpRequest, Text, Text) -> http.HttpResponse auth_value = request.META.get("HTTP_AUTHORIZATION") -- GitLab