From e1182b36279e1c852d71d44de176a54554e42602 Mon Sep 17 00:00:00 2001 From: s-ol Date: Sun, 26 Jun 2022 18:54:36 +0200 Subject: [PATCH] redirect/handle old-style index and SmartHTTP requests --- klaus/__init__.py | 103 +++++++++++++++++++++++++++++++++-------- klaus/utils.py | 49 ++++++++++++++++++++ klaus/views.py | 5 +- tests/test_contrib.py | 2 +- tests/test_make_app.py | 2 +- 5 files changed, 139 insertions(+), 22 deletions(-) diff --git a/klaus/__init__.py b/klaus/__init__.py index e85c9e3d..d42558b5 100644 --- a/klaus/__init__.py +++ b/klaus/__init__.py @@ -7,6 +7,7 @@ import flask import httpauth import dulwich.web +from werkzeug.exceptions import NotFound from dulwich.errors import NotGitRepository from klaus import views, utils from klaus.repo import FancyRepo, InvalidRepo @@ -15,6 +16,59 @@ KLAUS_VERSION = utils.guess_git_revision() or "1.5.2" +class KlausRedirects(flask.Flask): + def __init__(self, repos): + flask.Flask.__init__(self, __name__) + + for namespaced_name in repos: + self.setup_redirects('/' + namespaced_name) + if namespaced_name.count('/') == 1: + self.setup_redirects('/' + namespaced_name, '/~' + namespaced_name) + + def query_str(self): + query = flask.request.query_string.decode() + if len(query) > 0: + return '?' + query + + return '' + + def setup_redirects(self, route, pattern=None): + if not pattern: + pattern = route + + def redirect_root(): + return flask.redirect(route + '/-/' + self.query_str(), 301) + + def redirect_rest(path): + if path.startswith('-/'): + raise NotFound() + return flask.redirect(route + '/-/' + path + self.query_str(), 301) + + def redirect_git(): + return flask.redirect(route + '.git/info/refs' + self.query_str(), 301) + + self.add_url_rule( + pattern + '/', + endpoint=pattern + '_root', + view_func=redirect_root, + ) + self.add_url_rule( + pattern + '.git', + endpoint=pattern + '_git2root', + view_func=redirect_root, + ) + self.add_url_rule( + pattern + '/', + endpoint=pattern + '_rest', + view_func=redirect_rest, + ) + self.add_url_rule( + pattern + '/info/refs', + endpoint=pattern + '_git', + view_func=redirect_git, + ) + + class Klaus(flask.Flask): jinja_options = { "extensions": [] if jinja2_autoescape_builtin else ["jinja2.ext.autoescape"], @@ -25,6 +79,7 @@ def __init__(self, repo_paths, site_name, use_smarthttp, ctags_policy="none"): """(See `make_app` for parameter descriptions.)""" self.site_name = site_name self.use_smarthttp = use_smarthttp + self.smarthttp = None # dulwich wsgi app self.ctags_policy = ctags_policy valid_repos, invalid_repos = self.load_repos(repo_paths) @@ -55,6 +110,8 @@ def create_jinja_environment(self): return env def setup_routes(self): + redirects = {} + # fmt: off for endpoint, rule in [ ('repo_list', '/'), @@ -84,6 +141,17 @@ def setup_routes(self): view_func=getattr(views, endpoint) ) # fmt: on + if self.use_smarthttp: + self.add_url_rule( + '/.git/', + view_func=views.smarthttp, + methods=['GET', 'POST'], + ) + self.add_url_rule( + '//.git/', + view_func=views.smarthttp, + methods=['GET', 'POST'], + ) def should_use_ctags(self, git_repo, git_commit): if self.ctags_policy == "none": @@ -169,23 +237,20 @@ def make_app( use_smarthttp, ctags_policy, ) + app.wsgi_app = utils.ChainedApps( + app, + KlausRedirects(app.valid_repos), + ) app.wsgi_app = utils.ProxyFix(app.wsgi_app) if use_smarthttp: # `path -> Repo` mapping for Dulwich's web support - dulwich_backend = dulwich.server.DictBackend( - { - "/" + namespaced_name + '.git': repo - for namespaced_name, repo in app.valid_repos.items() - } - ) - # Dulwich takes care of all Git related requests/URLs - # and passes through everything else to klaus - dulwich_wrapped_app = dulwich.web.make_wsgi_chain( - backend=dulwich_backend, - fallback_app=app.wsgi_app, - ) - dulwich_wrapped_app = utils.ProxyFix(dulwich_wrapped_app) + dulwich_repos = {} + for namespaced_name, repo in app.valid_repos.items(): + dulwich_repos["/" + namespaced_name + '.git'] = repo + + dulwich_backend = dulwich.server.DictBackend(dulwich_repos) + dulwich_app = dulwich.web.make_wsgi_chain(backend=dulwich_backend) # `receive-pack` is requested by the "client" on a push # (the "server" is asked to *receive* packs), i.e. we need to secure @@ -206,18 +271,18 @@ def make_app( ) if unauthenticated_push: # DANGER ZONE: Don't require authentication for push'ing - app.wsgi_app = dulwich_wrapped_app + app.smarthttp = dulwich_app elif htdigest_file and not disable_push: # .htdigest file given. Use it to read the push-er credentials from. if require_browser_auth: # No need to secure push'ing if we already require HTTP auth # for all of the Web interface. - app.wsgi_app = dulwich_wrapped_app + app.smarthttp = dulwich_app else: # Web interface isn't already secured. Require authentication for push'ing. - app.wsgi_app = httpauth.DigestFileHttpAuthMiddleware( + app.smarthttp = httpauth.DigestFileHttpAuthMiddleware( htdigest_file, - wsgi_app=dulwich_wrapped_app, + wsgi_app=dulwich_app, routes=[PATTERN], ) else: @@ -225,8 +290,8 @@ def make_app( # use HTTP 403 here but since that results in freaky error messages # (see above) we keep asking for authentication (401) instead. # Git will print a nice error message after a few tries. - app.wsgi_app = httpauth.AlwaysFailingAuthMiddleware( - wsgi_app=dulwich_wrapped_app, + app.smarthttp = httpauth.AlwaysFailingAuthMiddleware( + wsgi_app=dulwich_app, routes=[PATTERN], ) diff --git a/klaus/utils.py b/klaus/utils.py index 0cb8f255..209ae3e7 100644 --- a/klaus/utils.py +++ b/klaus/utils.py @@ -2,6 +2,7 @@ import binascii import os import re +import sys import time import datetime import mimetypes @@ -103,6 +104,54 @@ def __call__(self, environ, start_response): return self.app(environ, start_response) +class ChainedApps(object): + """WSGI middleware to chain two or more Flask apps. + + The request is passed to the next app if a response has a 404 status.""" + + def __init__(self, *apps): + self.apps = apps + + def __call__(self, environ, start_response): + # this method is almost verbatim flask.Flask.wsgi_app(), + # except for the for/continue statements. + for app in self.apps: + ctx = app.request_context(environ) + error = None + first_response = None + try: + try: + ctx.push() + response = app.full_dispatch_request() + except Exception as e: + error = e + response = app.handle_exception(e) + except: # noqa: B001 + error = sys.exc_info()[1] + raise + + if first_response is None: + first_response = response + + if response.status_code == 404: + # pass through 404 codes + continue + + return response(environ, start_response) + finally: + if "werkzeug.debug.preserve_context" in environ: + environ["werkzeug.debug.preserve_context"](_cv_app.get()) + environ["werkzeug.debug.preserve_context"](_cv_request.get()) + + if error is not None and app.should_ignore_error(error): + error = None + + ctx.pop(error) + + if first_response: + return first_response(environ, start_response) + + def timesince(when, now=time.time): """Return the difference between `when` and `now` in human readable form.""" return naturaltime(now() - when) diff --git a/klaus/views.py b/klaus/views.py index 7d5c404c..a4f9baec 100644 --- a/klaus/views.py +++ b/klaus/views.py @@ -548,4 +548,7 @@ def get_response(self): def smarthttp(*args, **kwargs): - raise ValueError("this endpoint shouldn't be reachable") + if not current_app.use_smarthttp or not current_app.smarthttp: + raise NotFound() + + return current_app.smarthttp diff --git a/tests/test_contrib.py b/tests/test_contrib.py index c654948d..1f8e145a 100644 --- a/tests/test_contrib.py +++ b/tests/test_contrib.py @@ -135,7 +135,7 @@ def _can_push(http_get, url): url + "/info/refs?service=git-receive-pack", ), _check_http200( - http_get, TEST_REPO_NO_NAMESPACE_BASE_URL + "git-receive-pack" + http_get, url + "/git-receive-pack" ), subprocess.call(["git", "push", url, "master"], cwd=TEST_REPO_NO_NAMESPACE) == 0, diff --git a/tests/test_make_app.py b/tests/test_make_app.py index accac11e..998e3579 100644 --- a/tests/test_make_app.py +++ b/tests/test_make_app.py @@ -170,7 +170,7 @@ def _can_push(http_get, url): _check_http200( http_get, url + "/info/refs?service=git-receive-pack" ), - _check_http200(http_get, TEST_REPO_BASE_URL + "git-receive-pack"), + _check_http200(http_get, url + "/git-receive-pack"), subprocess.call(["git", "push", url, "master"], cwd=TEST_REPO) == 0, ] )