Initial implementation.

This commit is contained in:
Solderpunk 2019-08-12 20:56:27 +03:00
parent 56ad4794a5
commit cbde84c389
2 changed files with 180 additions and 1 deletions

View File

@ -1,4 +1,4 @@
Copyright (c) <year> <owner> . All rights reserved.
Copyright (c) 2019 <solderpunk@sdf.org> . All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

179
agena.py Executable file
View File

@ -0,0 +1,179 @@
#!/usr/bin/env python3
import mimetypes
import os
import shlex
import subprocess
import socket
import socketserver
import ssl
import tempfile
import urllib.parse
HOST, PORT = "localhost", 1965
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(certfile="cert.pem", keyfile="key.pem")
class AgenaHandler(socketserver.BaseRequestHandler):
def setup(self):
"""
Wrap socket in SSL session.
"""
self.request = context.wrap_socket(self.request, server_side=True)
def handle(self):
# 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:
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 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
def download_gopher_resource(self):
"""
Download the requested Gopher resource to a temporary file.
"""
print("Requesting {} from {}...".format(self.gopher_selector, self.gopher_host), end="")
# Send request
s = socket.create_connection((self.gopher_host, self.gopher_port))
s.sendall((self.gopher_selector + '\r\n').encode("UTF-8"))
# Write gopher response to temp file
tmpf = tempfile.NamedTemporaryFile("wb", delete=False)
size = tmpf.write(s.makefile("rb").read())
tmpf.close()
print("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.send_gemini_header(20, "text/plain")
with open(filename,"rb") as fp:
self.request.send(fp.read())
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_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.send_gemini_header(20, mimetype)
with open(filename,"rb") as fp:
self.request.send(fp.read())
if __name__ == "__main__":
agena = socketserver.TCPServer((HOST, PORT), AgenaHandler)
try:
agena.serve_forever()
except KeyboardInterrupt:
agena.shutdown()
agena.server_close()