#!/usr/bin/env python3
# Agena Geimin-to-Gopher proxy
# (C) 2019,2025 Solderpunk <solderpunk@posteo.net>
# With contributions from:
#  - James Tomasino <james@tomasino.org>
import argparse
import os
import shlex
import subprocess
import socket
import socketserver
import ssl
import sys
import tempfile
import urllib.parse

_VERSION = "1.0.0"
_MODE = None
_QUIET = False
try:
    import chardet
    _HAS_CHARDET = True
except ImportError:
    _HAS_CHARDET = False

context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)

def qprint(*args, **kwargs):
    if not _QUIET:
        return print(*args, **kwargs)

class AgenaHandler(socketserver.BaseRequestHandler):

    def setup(self):
        """
        Wrap socket in SSL session.
        """
        if _MODE == "tcp-ip":
            self.request = context.wrap_socket(self.request, server_side=True)

    def handle(self):
        """
        Handle a Gemini request for a gopher:// URL
        """
        # Parse request URL, make sure it's for a Gopher resource
        self.parse_request()
        if self.request_scheme != "gopher":
            self.send_gemini_header(50, "Agena only proxies to gopher resources.")
            return
        # Try to do a Gopher transaction with the remote host
        try:
            filename = self.download_gopher_resource()
        except UnicodeError:
            self.send_gemini_header(43, "Remote gopher host served content in an unrecognisable character encoding.")
            return
        except:
            self.send_gemini_header(43, "Couldn't connect to remote gopher host.")
            return
        # Handle what we received based on item type
        if self.gopher_itemtype == "0":
            self.handle_text(filename)
        elif self.gopher_itemtype == "1":
            self.handle_menu(filename)
        elif self.gopher_itemtype == "h":
            self.handle_html(filename)
        elif self.gopher_itemtype in ("9", "g", "I", "s"):
            self.handle_binary(filename)
        # Clean up
        self.request.close()
        os.unlink(filename)

    def send_gemini_header(self, status, meta):
        """
        Send a Gemini header, and close the connection if the status code does
        not indicate success.
        """
        self.request.send("{} {}\r\n".format(status, meta).encode("UTF-8"))
        if status / 10 != 2:
            self.request.close()

    def parse_request(self):
        """
        Read a URL from the Gemini client and parse it up into parts,
        including separating out the Gopher item type.
        """
        requested_url = self.request.recv(1024).decode("UTF-8").strip()
        if "://" not in requested_url:
            requested_url = "gemini://" + requested_url
        parsed =  urllib.parse.urlparse(requested_url)
        self.request_scheme = parsed.scheme
        self.gopher_host = parsed.hostname
        self.gopher_port = parsed.port or 70
        if parsed.path and parsed.path[0] == '/' and len(parsed.path) > 1:
            self.gopher_itemtype = parsed.path[1]
            self.gopher_selector = parsed.path[2:]
        else:
            # Use item type 1 for top-level selector
            self.gopher_itemtype = "1"
            self.gopher_selector = parsed.path
        self.gopher_query = parsed.query

    def download_gopher_resource(self):
        """
        Download the requested Gopher resource to a temporary file.
        """
        qprint("Requesting {} from {}...".format(self.gopher_selector,
                                                 self.gopher_host), end="")

        # Send request and read response
        s = socket.create_connection((self.gopher_host, self.gopher_port))
        if self.gopher_query:
            request = self.gopher_selector + '\t' + self.gopher_query
        else:
            request = self.gopher_selector
        request += '\r\n'
        s.sendall(request.encode("UTF-8"))
        response= s.makefile("rb").read()

        # Transcode text responses into UTF-8
        if self.gopher_itemtype in ("0", "1", "h"):
            # Try some common encodings
            for encoding in ("UTF-8", "ISO-8859-1"):
                try:
                    response = response.decode("UTF-8")
                    break
                except UnicodeDecodeError:
                    pass
            else:
                # If we didn't break out of the loop above, none of the
                # common encodings worked.  If we have chardet installed,
                # try to autodetect.
                if _HAS_CHARDET:
                    detected = chardet.detect(response)
                    response = response.decode(detected["encoding"])
                else:
                    # Surrender.
                    raise UnicodeDecodeError
            # Re-encode as God-fearing UTF-8
            response = response.encode("UTF-8")

        # Write gopher response to temp file
        tmpf = tempfile.NamedTemporaryFile("wb", delete=False)
        size = tmpf.write(response)
        tmpf.close()
        qprint("wrote {} bytes to {}...".format(size, tmpf.name))
        return tmpf.name

    def handle_text(self, filename):
        """
        Send a Gemini response for a downloaded Gopher resource whose item
        type indicates it should be plain text.
        """
        self._serve_file("text/plain", filename)

    def handle_menu(self, filename):
        """
        Send a Gemini response for a downloaded Gopher resource whose item
        type indicates it should be a menu.
        """
        self.send_gemini_header(20, "text/gemini")
        with open(filename,"r") as fp:
            for line in fp:
                if line.strip() == ".":
                    continue
                elif line.startswith("i"):
                    # This is an "info" line.  Just strip off the item type
                    # and send the item name, ignorin the dummy selector, etc.
                    self.request.send((line[1:].split("\t")[0]+"\r\n").encode("UTF-8"))
                else:
                    # This is an actual link to a Gopher resource
                    gemini_link = self.gopher_link_to_gemini_link(line)
                    self.request.send(gemini_link.encode("UTF-8"))

    def gopher_link_to_gemini_link(self, line):
        """
        Convert one line of a Gopher menu to one line of a Geminimap.
        """

        # Code below pinched from VF-1

        # Split on tabs.  Strip final element after splitting,
        # since if we split first we loose empty elements.
        parts = line.split("\t")
        parts[-1] = parts[-1].strip()
        # Discard Gopher+ noise
        if parts[-1] == "+":
            parts = parts[:-1]

        # Attempt to assign variables.  This may fail.
        # It's up to the caller to catch the Exception.
        name, path, host, port = parts
        itemtype = name[0]
        name = name[1:]
        port = int(port)
        if itemtype == "h" and path.startswith("URL:"):
            url = path[4:]
        else:
            url = "gopher://%s%s/%s%s" % (
                host,
                "" if port == 70 else ":%d" % port,
                itemtype,
                path
            )

        return "=> {} {}\r\n".format(url, name)

    def handle_html(self, filename):
        """
        Send a Gemini response for a downloaded Gopher resource whose item
        type indicates it should be HTML.
        """
        self._serve_file("text/html", filename)

    def handle_binary(self, filename):
        """
        Send a Gemini response for a downloaded Gopher resource whose item
        type indicates it should be a binary file.  Uses file(1) to sniff MIME
        types.
        """
        # Detect MIME type
        out = subprocess.check_output(
            shlex.split("file --brief --mime-type %s" % filename)
        )
        mimetype = out.decode("UTF-8").strip()
        self._serve_file(mimetype, filename)

    def _serve_file(self, mime, filename):
        """
        Send a Gemini response with a given MIME type whose body is the
        contents of the specified file.
        """
        self.send_gemini_header(20, mime)
        with open(filename,"rb") as fp:
            self.request.send(fp.read())

def main():

    global _MODE, _QUIET
    parser = argparse.ArgumentParser(description=
"""Agena is a simple Gemini-to-Gopher proxy designed to be run locally to
let you seamlessly access Gopherspace from inside a Gemini client.""")
    parser.add_argument('--cert', type=str, nargs="?", default="cert.pem",
                        help='TLS certificate file (PEM format).')
    parser.add_argument('--key', type=str, nargs="?", default="key.pem",
                        help='TLS private key file (PEM format).')
    parser.add_argument('--port', type=int, nargs="?", default=1965,
                        help='TCP port to serve on (default 1965).')
    parser.add_argument('--host', type=str, nargs="?", default="0.0.0.0",
                        help='TCP host to serve on (default 0.0.0.0).')
    parser.add_argument('--sockfile', type=str, nargs="?", default=None,
                        help='UNIX domain socket filename to serve on.')
    parser.add_argument('--quiet', action='store_true',
                        help='supress output in absence of errors')
    parser.add_argument('--version', action='store_true',
                        help='display version information and quit')
    args = parser.parse_args()

    # Handle --version
    if args.version:
        print("Agena " + _VERSION)
        sys.exit()

    # Handle --quiet
    if args.quiet:
        _QUIET = True

    # Create socketserver object, either for domain socket or TCP/IP
    if args.sockfile:
        _MODE = "domain"
        agena = socketserver.UnixStreamServer(args.sockfile, AgenaHandler)
        qprint("Listening on {}...".format(os.path.abspath(args.sockfile)))
    else:
        _MODE = "tcp-ip"
        if not (os.path.exists(args.cert) and os.path.exists(args.key)):
            print("Couldn't find cert.pem and/or key.pem. :(", file=sys.stderr)
            sys.exit(1)

        context.load_cert_chain(certfile=args.cert, keyfile=args.key)

        socketserver.TCPServer.allow_reuse_address = True
        agena = socketserver.TCPServer((args.host, args.port), AgenaHandler)
        qprint("Listening on {}:{}...".format(args.host, args.port))

    # Listen forever until killed by Ctrl-C
    try:
        agena.serve_forever()
    except KeyboardInterrupt:
        agena.shutdown()
        agena.server_close()

if __name__ == "__main__":
    main()
