gempher/gempher.py

172 lines
6.0 KiB
Python

import configparser, argparse, socketserver, ssl, threading, enum, time, utils
from urllib import parse as urlparse
# monkeypatch urllib.parse to understand gemini URLs
urlparse.uses_relative.append('gemini')
urlparse.uses_netloc.append('gemini')
# now import the utils (which will use the monkeypatched urllib.parse
import utils
# setup ssl context
ctx = ssl.create_default_context()
ctx.check_hostname=False
ctx.verify_mode=ssl.CERT_NONE
class ReturnCode(enum.IntEnum):
SUCCESS = 0
ERROR = auto()
INVALID_RESPONSE = auto()
SOCKET_TIMEOUT = auto()
UNKNOWN_ERROR = 9999
class Config:
def __init__(self,filename=None,overrides=dict()):
self._conf = configparser.ConfigParser()
if filename is not None: self._conf.read(filename)
self._overrides = overrides
@property
def port(self):
if "port" in self._overrides:
return self._overrides["port"]
return self._conf.getint("gopher","port",70)
@property
def hostname(self):
if "hostname" in self._overrides:
return self._overrides["hostname"]
return self._conf["gemini"]["hostname"]
@property
def self_hostname(self):
if "self_hostname" in self._overrides:
return self._overrides["self_hostname"]
return self._conf["gopher"]["hostname"]
@property
def server_cls(self):
name = self._conf.get("server","type","ThreadingTCPServer")
if "server_type" in self._overrides:
name = self._overrides["server_type"]
return getattr(socketserver,name)
class GeminiRequestThread(threading.Thread):
def __init__(self,requrl):
self.killswitch = threading.Event()
self.requrl = requrl
self.rc = None
self.retval = None
def run(self):
requrl = self.requrl
with socket.socket(socket.AF_INET,socket.SOCK_STREAM) as s:
s.settimeout(5)
try:
ss = ctx.wrap_socket(s,server_hostname=requrl.hostname)
ss.connect((requrl.hostname,requrl.port or 1965))
ss.send((urlparse.urlunparse(requrl)+"\r\n").encode("utf-8"))
resp = b""
while (data:=ss.recv(1024)):
resp+=data
header, resp = resp.split(b'\r\n',1)
header = header.decode("utf-8")
status, meta = header.split(None,1)
assert status[0] in '123456', ReturnCode.INVALID_RESPONSE
self.retval = header
assert status[0] in '2345', ReturnCode.UNSUPPORTED
assert status[0] in '23', ReturnCode.ERROR
if status[0]=='3':
resp = '=> '+meta+' Redirect target'
meta = 'text/gemini'
resp = resp.encode('utf-8')
self.rc = ReturnCode.SUCCESS
self.retval = [meta,resp]
except UnicodeDecodeError:
self.rc = ReturnCode.INVALID_RESPONSE
except AssertionError as e:
self.rc = e.args[0]
except socket.timeout:
self.rc = ReturnCode.SOCKET_TIMEOUT
except:
self.rc = ReturnCode.UNKNOWN_ERROR
class Gempher(socketserver.StreamRequestHandler):
def handle(self):
self.gplus = False
req, query = self.rfile.readline().strip(), None
req = req.decode("ascii")
if "\t" in req:
req, query = req.split("\t",1)
if query[0]=="+":
self.gplus=True
query=None
requrl = self.PARSED_URL._replace(path=req,query=query)
if requrl.path.startswith("/x/"):
nurl = requrl.path[3:].split("/",2)
requrl = requrl._replace(protocol=nurl[0],netloc=nurl[1],path="/"+nurl[2])
if requrl.scheme=="gopher":
itemtype = "1"
if requrl.path[1] in "0123456789gI:;<dhs" and requrl.path[2]=="/":
itemtype = requrl.path[1]
requrl = requrl._replace(path=requrl.path[2:])
port = requrl.port or 70
self.send_response((f"{itemtype}Click here to follow through\t{requrl.path}\t{requrl.hostname}\t{port}\r\n").encode())
return
if requrl.scheme!="gemini":
hn = self.CONFIG.self_hostname
port = self.CONFIG.port
ru = urlparse.urlunparse(requrl)
self.send_response((f"hClick here to follow through\tURL:{ru}\t{hn}\t{port}\r\n").encode())
t = GeminiRequestThread(requrl)
start = time.time()
t.start()
while t.is_alive():
if (time.time()-start)>5:
# if the server hasn't sent anything in 5 seconds, a socket timeout will occur
# if the server is *still* sending things 5 seconds later, setting the killswitch will terminate the read loop
t.killswitch.set()
t.join()
if t.rc==ReturnCode.SUCCESS: # success/redirect
if t.retval[0].startswith("text/gemini"):
mimetype, params = utils.parse_mime(t.retval[0])
self.send_gemini(t.retval[1],params.get("encoding","utf-8"),requrl)
else:
self.send_response(t.retval[1])
elif t.rc==ReturnCode.ERROR: # error provided by the server
self.send_error(t.retval)
elif t.rc==ReturnCode.INVALID_RESPONSE: # error caused by the server
self.send_error("Server returned invalid response")
elif t.rc==ReturnCode.UNSUPPORTED:
self.send_error("Server returned valid response that we could not handle")
elif t.rc==ReturnCode.SOCKET_TIMEOUT:
self.send_error("Server timed out",2)
elif t.rc>ReturnCode.ERROR: # any other unspecified error
self.send_error("Unknown error occurred",2)
def send_response(self,resp,error=None):
if self.gplus:
if error is None:
l = len(resp)
self.wfile.write((f"+{l!s}\r\n").encode("ascii"))
else:
self.wfile.write((f"--{error!s}\r\n").encode("ascii"))
else:
if error is not None:
self.wfile.write(b"3")
self.wfile.write(resp)
if error is not None:
if not self.gplus:
self.wfile.write(b"\t.\tnull.host\t70")
self.wfile.write(b"\r\n")
def send_error(self,err,code=1):
self.send_response(err,code)
def send_gemini(self,body,encoding,requrl):
body = body.decode(encoding)
# run it through the gemtext->html->text gauntlet and send it
self.send_response(utils.gemtext2gopher(body,urlparse.unparse(requrl),self.CONFIG.self_hostname,self.CONFIG.port,self.CONFIG.hostname))
def create_server(server_address, config_fn, overrides={}, server=None):
conf = Config(config_fn,overrides)
if server is None: server = conf.server_cls
handler = type("Gempher",(Gempher,),{"CONFIG":conf,"PARSED_URL":urlparse.urlparse("gemini://"+conf.hostname)})
ret = server(server_address,handler)
def __shutdown():
ret._BaseServer__shutdown_request=True
ret.shutdown = __shutdown
def __join():
ret._BaseServer__is_shut_down.wait()
ret.join = __join
return ret