172 lines
6.0 KiB
Python
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
|