#!/usr/bin/env python3

"""
Centrally run the Gitweb repository frontend (https://git-scm.com/book/en/v2/Git-on-the-Server-GitWeb) in a python HTTP
server, as a more stable and featured self-hosted alternative to `git instaweb` generated python code.
See: https://github.com/git/git/blob/master/git-instaweb.sh

In general, this self-hosted git web interface still *should not* be considered safe enough to be exposed to the
internet - even when using authentication and HTTPS.


No local files will be created, a temporary directory containing the configuration file is used as environment instead.
The `GITWEB_CONFIG` environment variable can be used to override this.
Note that `GITWEB_CONFIG_SYSTEM` and `GITWEB_CONFIG_COMMON` can be set, too - see the Gitweb documentation at
https://git-scm.com/docs/gitweb and https://git-scm.com/docs/gitweb.conf for details.

Basic HTTP authentication is supported - via commandline argument or environment variable.
Given a certificate and key, the web frontend can also be served via HTTPS.

There are no additional python3 dependencies needed and `gitweb` is usually already installed per default with `git`.
For actually running the Perl CGI script, `libcgi-pm-perl` or similar might be needed, though.
"""

import argparse
import os
import signal
import sys
import tempfile
import threading

from base64 import b64encode
from hmac import compare_digest
from pathlib import Path
from urllib.parse import quote_plus

from http.server import HTTPServer, ThreadingHTTPServer, CGIHTTPRequestHandler
from ssl import wrap_socket

from typing import Optional, Tuple, List, Type


class CGIHTTPServer(ThreadingHTTPServer):
    def __init__(self,
                 server_address: Tuple[str, int],
                 handler_class: Type[CGIHTTPRequestHandler],
                 auth: Optional[str],
                 cgi_path: str, cgi_file: str,
                 ssl_cert: Optional[str], ssl_key: Optional[str],
                 directory: str) -> None:
        super().__init__(server_address, handler_class, bind_and_activate=True)
        self.auth: Optional[str] = b64encode(auth.encode("utf-8")).decode("ascii") if auth is not None else None
        self.cgi_path: str = cgi_path
        self.cgi_file: str = cgi_file
        if ssl_cert or ssl_key:
            self.socket = wrap_socket(self.socket, keyfile=ssl_key, certfile=ssl_cert, server_side=True)
            handler_class.have_fork = False
        os.chdir(directory)


class AuthCGIHTTPRequestHandler(CGIHTTPRequestHandler):
    server: CGIHTTPServer

    def do_HEAD(self) -> None:
        if self._check_auth():
            super().do_HEAD()

    def do_GET(self) -> None:
        if self._check_auth():
            super().do_GET()

    def do_POST(self) -> None:
        if self._check_auth():
            super().do_POST()

    def _check_auth(self) -> bool:
        auth: Optional[str] = self.headers.get("Authorization", None)
        if self.server.auth is None:
            return True
        elif auth is not None and compare_digest(auth, "Basic " + self.server.auth):
            return True
        else:
            self.send_response(401, "Unauthorized")
            self.send_header("WWW-Authenticate", "Basic realm=\"gitweb\"")
            self.end_headers()
            return False

    def list_directory(self, *args, **kwargs) -> None:
        self.send_error(403, "Forbidden")


class CGIFileHTTPRequestHandler(AuthCGIHTTPRequestHandler):
    server: CGIHTTPServer

    def send_head(self):
        if self.path == "/":
            self.send_response(303, "See Other")
            self.send_header("Location", self.server.cgi_file)
            self.end_headers()
            return None
        return super().send_head()

    def is_cgi(self) -> bool:
        if self.path == self.server.cgi_file or self.path.startswith(self.server.cgi_file + "?"):
            self.cgi_info = self.server.cgi_path, self.path[1:]
            return True
        return False

    def end_headers(self) -> None:
        if not hasattr(self, "cgi_info"):
            self.send_header("Cache-Control", "public, max-age=3600")
            self.send_header("Vary", "Authorization")
        super().end_headers()


def _setup_config(private_root: Path, project_root: Path, project_list: List[Path]) -> None:
    if "GITWEB_CONFIG" in os.environ:
        return

    private_root = private_root.resolve(strict=True)
    project_root = project_root.resolve(strict=True)

    project_list = [_.resolve(strict=True).relative_to(project_root) for _ in project_list]
    projects_list: Path = private_root / "projects_list"
    with projects_list.open(mode="w") as fp:
        fp.writelines(["{}\n".format(quote_plus(project.as_posix(), safe='/')) for project in project_list])

    temp_dir: Path = private_root / "tmp"
    temp_dir.mkdir()

    config_file: Path = private_root / "config"
    os.environ.setdefault("GITWEB_CONFIG", config_file.as_posix())
    with config_file.open(mode="w") as fp:
        fp.write(f"""
            our $projectroot = "{project_root}";
            our $projects_list = "{projects_list if len(project_list) else project_root}";
            our $git_temp = "{temp_dir}";
            our $strict_export = 1;
            our $prevent_xss = 1;
            our $per_request_config = 0;
            our $site_html_head_string = "<style>html{{padding:10px;}}body{{max-width:1280px;margin:0 auto;}}</style>";
        """)
        fp.write("""
            $feature{'timed'}{'default'} = [1];
            $feature{'blame'}{'default'} = [1];
            $feature{'snapshot'}{'default'} = ['tgz', 'zip'];
            $feature{'grep'}{'default'} = [1];
            $feature{'patches'}{'default'} = [0];
            $feature{'remote_heads'}{'default'} = [1];
        """)


def _serve_forever(server: HTTPServer) -> bool:
    shutdown_requested: threading.Event = threading.Event()

    def _handler(signum: int, frame) -> None:
        shutdown_requested.set()

    signal.signal(signal.SIGINT, _handler)
    signal.signal(signal.SIGTERM, _handler)

    thread: threading.Thread = threading.Thread(target=server.serve_forever)
    thread.start()

    shutdown_requested.wait()
    server.shutdown()
    server.server_close()
    thread.join()
    return True


if __name__ == "__main__":
    parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter,
                                     description=__doc__.partition("\n\n\n")[0],
                                     epilog=__doc__.partition("\n\n\n")[2])
    parser.add_argument("--local", action="store_const", const=True, default=False,
                        help="only bind to 127.0.0.1")
    parser.add_argument("--port", type=int, default=8000,
                        help="port number to bind to")
    parser.add_argument("--auth", type=str, metavar="USER:PASS", default=None,
                        help="require HTTP basic authentication")
    parser.add_argument("--auth-env", type=str, metavar="ENVAR", default=None,
                        help="require HTTP basic authentication, credentials from environment variable")
    parser.add_argument("--ssl-cert", type=str, metavar="CERT_PEM", default=None,
                        help="certificate PEM file for HTTPS")
    parser.add_argument("--ssl-key", type=str, metavar="KEY_PEM", default=None,
                        help="certificate key PEM file for HTTPS")
    parser.add_argument("--gitweb", type=str, metavar="DIR", default="/usr/share/gitweb",
                        help="gitweb installation directory")
    parser.add_argument("--project-root", type=Path, metavar="DIR", required=True,
                        help="common root directory for repositories or where to scan for")
    parser.add_argument("projects", type=Path, nargs='*', metavar="REPOSITORY",
                        help="repository git directory to expose, scan root if none given")
    args = parser.parse_args()

    os.umask(0o027)
    with tempfile.TemporaryDirectory() as tempdir:
        _setup_config(Path(tempdir), args.project_root, args.projects)

        httpd: CGIHTTPServer = CGIHTTPServer(
            server_address=("127.0.0.1" if args.local else "0.0.0.0", args.port),
            handler_class=CGIFileHTTPRequestHandler,
            auth=os.environ[args.auth_env] if args.auth_env is not None else args.auth,
            cgi_path="", cgi_file="/gitweb.cgi",
            ssl_cert=args.ssl_cert, ssl_key=args.ssl_key,
            directory=args.gitweb,
        )

        sys.exit(0 if _serve_forever(httpd) else 1)