diff --git a/course/auth.py b/course/auth.py
index 77399f9d605829b9cc263e486b0bdbd49efaca28..7453ce8e53e005d07ab30d9fa48bf04baccd708a 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 6e7386550b7781fc238ea3d8c0b868a77597b597..0f76878829d61a0b4c5e8704277ed12f8fb0fc60 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 5a8d0d87095253fea8b937a9ec3a25b75248e844..117d9f33c9ae276e8773abe9381f82a036f9a1d2 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 943e04ff15b6db9fef5bc8d7b9aefd1201f1ad2f..1960b06e46ab0670b8743231b9a40e004cbf1d84 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, 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 1dbd910eec9694be603a1d897e756aa368ad5ffb..8a4d1f9c8dc6a8bafc38a6280ee9ecca4f2ec810 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"),
# }}}