GmCapsule [gsorg-style]
Initial commit
12d756e033950f508fd0e3819629865f122646ce
[1mdiff --git a/.gitignore b/.gitignore[m
[1mnew file mode 100644[m
[1mindex 0000000..db85930[m
[1m--- /dev/null[m
[1m+++ b/.gitignore[m
[36m@@ -0,0 +1,4 @@[m
[32m+[m[32m__pycache__[m
[32m+[m[32m.certs/[m
[32m+[m[32mlocalhost/[m
[32m+[m
[1mdiff --git a/README.md b/README.md[m
[1mnew file mode 100644[m
[1mindex 0000000..f7c555b[m
[1m--- /dev/null[m
[1m+++ b/README.md[m
[36m@@ -0,0 +1,2 @@[m
[32m+[m[32m# GmCapsule: Extensible Server for Gemini and Titan[m
[32m+[m
[1mdiff --git a/gemini.py b/gemini.py[m
[1mnew file mode 100644[m
[1mindex 0000000..7297a3b[m
[1m--- /dev/null[m
[1m+++ b/gemini.py[m
[36m@@ -0,0 +1,296 @@[m
[32m+[m[32m# Copyright (c) 2021-2022 Jaakko Keränen <jaakko.keranen@iki.fi>[m
[32m+[m[32m# License: BSD 2-Clause[m
[32m+[m
[32m+[m[32mimport fnmatch[m
[32m+[m[32mimport hashlib[m
[32m+[m[32mimport socket[m
[32m+[m[32mimport time[m
[32m+[m[32mfrom urllib.parse import urlparse[m
[32m+[m
[32m+[m[32mimport OpenSSL.crypto[m
[32m+[m[32mfrom OpenSSL import SSL, crypto[m
[32m+[m
[32m+[m
[32m+[m[32mdef report_error(stream, code, msg):[m
[32m+[m[32m stream.sendall(f'{code} {msg}\r\n'.encode('utf-8'))[m
[32m+[m
[32m+[m
[32m+[m[32mclass Identity:[m
[32m+[m[32m def __init__(self, cert):[m
[32m+[m[32m self.cert = cert[m
[32m+[m[32m m = hashlib.sha256()[m
[32m+[m[32m m.update(crypto.dump_certificate(crypto.FILETYPE_ASN1, self.cert))[m
[32m+[m[32m self.fp_cert = m.hexdigest()[m
[32m+[m[32m self.pubkey = self.cert.get_pubkey()[m
[32m+[m[32m m = hashlib.sha256()[m
[32m+[m[32m m.update(crypto.dump_publickey(crypto.FILETYPE_ASN1, self.pubkey))[m
[32m+[m[32m self.fp_pubkey = m.hexdigest()[m
[32m+[m
[32m+[m[32m def __str__(self):[m
[32m+[m[32m return f"{self.fp_cert};{self.fp_pubkey}"[m
[32m+[m
[32m+[m[32m def subject(self):[m
[32m+[m[32m return ''.join('/{:s}={:s}'.format(name.decode(), value.decode())[m
[32m+[m[32m for name, value in self.cert.get_subject().get_components())[m
[32m+[m
[32m+[m
[32m+[m[32mclass Request:[m
[32m+[m[32m def __init__(self, identity=None, scheme='gemini', hostname='', path='', query='',[m
[32m+[m[32m remote_address=None, content_token=None, content_mime=None, content=None):[m
[32m+[m[32m self.remote_address = remote_address[m
[32m+[m[32m self.scheme = scheme[m
[32m+[m[32m self.identity = identity[m
[32m+[m[32m self.hostname = hostname[m
[32m+[m[32m self.path = path[m
[32m+[m[32m self.query = query[m
[32m+[m[32m self.content_token = content_token[m
[32m+[m[32m self.content_mime = content_mime[m
[32m+[m[32m self.content = content[m
[32m+[m
[32m+[m
[32m+[m[32mdef verify_callback(connection, cert, err_num, err_depth, ret_code):[m
[32m+[m[32m #print("verify_callback:", connection, cert, ret_code)[m
[32m+[m[32m return True[m
[32m+[m
[32m+[m
[32m+[m[32mclass Cache:[m
[32m+[m[32m def __init__(self):[m
[32m+[m[32m pass[m
[32m+[m
[32m+[m[32m def try_load(self, path):[m
[32m+[m[32m """Load the contents of `path` from cache (media type, content)."""[m
[32m+[m[32m return None, None[m
[32m+[m
[32m+[m[32m def save(self, path, media_type, content):[m
[32m+[m[32m return[m
[32m+[m
[32m+[m
[32m+[m[32mclass Server:[m
[32m+[m[32m def __init__(self, hostname_or_hostnames, cert_path, key_path,[m
[32m+[m[32m address='localhost', port=1965,[m
[32m+[m[32m cache=None, session_id=None, max_upload_size=0):[m
[32m+[m[32m self.hostnames = [hostname_or_hostnames] \[m
[32m+[m[32m if type(hostname_or_hostnames) == str else hostname_or_hostnames[m
[32m+[m[32m self.address = address[m
[32m+[m[32m self.port = port[m
[32m+[m[32m self.entrypoints = {'gemini': {}, 'titan': {}}[m
[32m+[m[32m for proto in ['gemini', 'titan']:[m
[32m+[m[32m self.entrypoints[proto] = {}[m
[32m+[m[32m for hostname in self.hostnames:[m
[32m+[m[32m self.entrypoints[proto][hostname] = [][m
[32m+[m[32m self.cache = cache[m
[32m+[m[32m self.max_upload_size = max_upload_size[m
[32m+[m
[32m+[m[32m self.context = SSL.Context(SSL.TLS_SERVER_METHOD)[m
[32m+[m[32m self.context.use_certificate_file(str(cert_path))[m
[32m+[m[32m self.context.use_privatekey_file(str(key_path))[m
[32m+[m[32m self.context.set_verify(SSL.VERIFY_PEER, verify_callback)[m
[32m+[m[32m if session_id:[m
[32m+[m[32m if type(session_id) != bytes:[m
[32m+[m[32m raise Exception("session_id type must be `bytes`")[m
[32m+[m[32m self.context.set_session_id(session_id)[m
[32m+[m
[32m+[m[32m attempts = 60[m
[32m+[m[32m print(f'Opening port {port}...')[m
[32m+[m[32m while True:[m
[32m+[m[32m try:[m
[32m+[m[32m self.sock = socket.socket()[m
[32m+[m[32m self.sock.bind((address, port))[m
[32m+[m[32m self.sock.listen(5)[m
[32m+[m[32m self.sv_conn = SSL.Connection(self.context, self.sock)[m
[32m+[m[32m self.sv_conn.set_accept_state()[m
[32m+[m[32m break[m
[32m+[m[32m except:[m
[32m+[m[32m attempts -= 1[m
[32m+[m[32m if attempts == 0:[m
[32m+[m[32m raise Exception(f'Failed to open port {port} for listening')[m
[32m+[m[32m time.sleep(2.0)[m
[32m+[m[32m print('...')[m
[32m+[m[32m print(f'Server started on port {port}')[m
[32m+[m
[32m+[m[32m def add_entrypoint(self, protocol, hostname, path_pattern, entrypoint):[m
[32m+[m[32m self.entrypoints[protocol][hostname].append((path_pattern, entrypoint))[m
[32m+[m
[32m+[m[32m def __setitem__(self, key, value):[m
[32m+[m[32m #if key.endswith('*'):[m
[32m+[m[32m # self.wild_entrypoints[key[:-1]] = value[m
[32m+[m[32m #else:[m
[32m+[m[32m # self.entrypoints[key] = value[m
[32m+[m[32m for hostname in self.hostnames:[m
[32m+[m[32m self.add_entrypoint('gemini', hostname, key, value)[m
[32m+[m
[32m+[m[32m # def __getitem__(self, key):[m
[32m+[m[32m # if key.endswith('*'):[m
[32m+[m[32m # return self.wild_entrypoints[key[:-1]][m
[32m+[m[32m # return self.entrypoints[key][m
[32m+[m
[32m+[m[32m def run(self):[m
[32m+[m[32m while True:[m
[32m+[m[32m try:[m
[32m+[m[32m stream = None[m
[32m+[m[32m try:[m
[32m+[m[32m stream, from_addr = self.sv_conn.accept()[m
[32m+[m[32m #print(stream, from_addr)[m
[32m+[m[32m self.process_request(stream, from_addr)[m
[32m+[m[32m except Exception as ex:[m
[32m+[m[32m import traceback[m
[32m+[m[32m traceback.print_exc()[m
[32m+[m[32m print(ex)[m
[32m+[m[32m if stream:[m
[32m+[m[32m report_error(stream, 42, str(ex))[m
[32m+[m[32m finally:[m
[32m+[m[32m if stream:[m
[32m+[m[32m stream.shutdown()[m
[32m+[m[32m #print('Goodbye', from_addr)[m
[32m+[m[32m except Exception as ex:[m
[32m+[m[32m print(ex)[m
[32m+[m
[32m+[m[32m def find_entrypoint(self, protocol, hostname, path):[m
[32m+[m[32m try:[m
[32m+[m[32m for entry in self.entrypoints[protocol][hostname]:[m
[32m+[m[32m if len(entry[0]) == 0 or fnmatch.fnmatch(path, entry[0]):[m
[32m+[m[32m return entry[1][m
[32m+[m[32m except:[m
[32m+[m[32m return None[m
[32m+[m
[32m+[m[32m # # Check the more specific virtual host entrypoint first.[m
[32m+[m[32m # virt_path = f":{hostname}:{path}"[m
[32m+[m[32m # if virt_path in self.entrypoints:[m
[32m+[m[32m # return self.entrypoints[virt_path][m
[32m+[m[32m # for entry in self.wild_entrypoints:[m
[32m+[m[32m # if virt_path.startswith(entry):[m
[32m+[m[32m # return self.wild_entrypoints[entry][m
[32m+[m
[32m+[m[32m # if path in self.entrypoints:[m
[32m+[m[32m # return self.entrypoints[path][m
[32m+[m[32m # for entry in self.wild_entrypoints:[m
[32m+[m[32m # if path.startswith(entry):[m
[32m+[m[32m # return self.wild_entrypoints[entry][m
[32m+[m
[32m+[m[32m return None[m
[32m+[m
[32m+[m[32m def process_request(self, stream, from_addr):[m
[32m+[m[32m print(time.strftime('%Y-%m-%d %H:%M:%S'))[m
[32m+[m[32m data = bytes()[m
[32m+[m[32m MAX_LEN = 1024[m
[32m+[m[32m request = None[m
[32m+[m[32m expected_size = 0[m
[32m+[m[32m req_token = None[m
[32m+[m[32m req_mime = None[m
[32m+[m[32m incoming = stream.recv(MAX_LEN)[m
[32m+[m
[32m+[m[32m print(dir(stream))[m
[32m+[m
[32m+[m[32m while len(data) < MAX_LEN:[m
[32m+[m[32m data += incoming[m
[32m+[m[32m crlf_pos = data.find(b'\r\n')[m
[32m+[m[32m if crlf_pos >= 0:[m
[32m+[m[32m request = data[:crlf_pos].decode('utf-8')[m
[32m+[m[32m data = data[crlf_pos + 2:][m
[32m+[m[32m break[m
[32m+[m[32m if len(data) == MAX_LEN:[m
[32m+[m[32m report_error(stream, 59, 'Request is too long')[m
[32m+[m[32m return[m
[32m+[m[32m incoming = stream.recv(MAX_LEN - len(data))[m
[32m+[m[32m if len(incoming) == 0:[m
[32m+[m[32m break[m
[32m+[m
[32m+[m[32m if not request or not (request.startswith('gemini:') or request.startswith('titan:')):[m
[32m+[m[32m report_error(stream, 59, "Unsupported protocol")[m
[32m+[m[32m return[m
[32m+[m
[32m+[m[32m if request.startswith('titan:'):[m
[32m+[m[32m # Read the rest of the data.[m
[32m+[m[32m parms = request.split(';')[m
[32m+[m[32m request = parms[0][m
[32m+[m[32m for p in parms:[m
[32m+[m[32m if p.startswith('size='):[m
[32m+[m[32m expected_size = int(p[5:])[m
[32m+[m[32m elif p.startswith('token='):[m
[32m+[m[32m req_token = p[6:][m
[32m+[m[32m elif p.startswith('mime='):[m
[32m+[m[32m req_mime = p[5:][m
[32m+[m[32m if expected_size > self.max_upload_size and self.max_upload_size > 0:[m
[32m+[m[32m report_error(stream, 59, "Maximum content length exceeded")[m
[32m+[m[32m return[m
[32m+[m[32m while len(data) < expected_size:[m
[32m+[m[32m incoming = stream.recv(65536)[m
[32m+[m[32m if len(incoming) == 0:[m
[32m+[m[32m break[m
[32m+[m[32m data += incoming[m
[32m+[m[32m if len(data) != expected_size:[m
[32m+[m[32m report_error(stream, 59, "Invalid content length")[m
[32m+[m[32m return[m
[32m+[m[32m else:[m
[32m+[m[32m # No Payload in Gemini.[m
[32m+[m[32m if len(data):[m
[32m+[m[32m report_error(stream, 59, "Gemini disallows request content")[m
[32m+[m[32m return[m
[32m+[m
[32m+[m[32m url = urlparse(request)[m
[32m+[m[32m cl_cert = stream.get_peer_certificate()[m
[32m+[m[32m identity = Identity(cl_cert) if cl_cert else None[m
[32m+[m[32m path = url.path[m
[32m+[m[32m if path == '':[m
[32m+[m[32m path = '/'[m
[32m+[m[32m # TODO: get TLS SNI[m
[32m+[m[32m hostname = url.hostname[m
[32m+[m[32m entrypoint = self.find_entrypoint(url.scheme, hostname, path)[m
[32m+[m[32m print(entrypoint)[m
[32m+[m[32m cache = None if identity or len(url.query) > 0 else self.cache[m
[32m+[m[32m is_from_cache = False[m
[32m+[m
[32m+[m[32m # print(f'Request : {request}')[m
[32m+[m[32m # print(f'Cert : {cl_cert}')[m
[32m+[m
[32m+[m[32m if entrypoint:[m
[32m+[m[32m # Check the cache first.[m
[32m+[m[32m if cache:[m
[32m+[m[32m media, content = cache.try_load(path)[m
[32m+[m[32m if not media is None:[m
[32m+[m[32m response = 20, media, content[m
[32m+[m[32m is_from_cache = True[m
[32m+[m[32m print('%d bytes from cache, %s' % (len(content), media))[m
[32m+[m
[32m+[m[32m # Process the request normally if there is nothing cached.[m
[32m+[m[32m if not is_from_cache:[m
[32m+[m[32m response = entrypoint(Request([m
[32m+[m[32m identity,[m
[32m+[m[32m remote_address=from_addr,[m
[32m+[m[32m scheme=url.scheme,[m
[32m+[m[32m hostname=hostname,[m
[32m+[m[32m path=path,[m
[32m+[m[32m query=url.query,[m
[32m+[m[32m content_token=req_token,[m
[32m+[m[32m content_mime=req_mime,[m
[32m+[m[32m content=data if len(data) else None[m
[32m+[m[32m ))[m
[32m+[m
[32m+[m[32m # Determine status code, meta line, and body content.[m
[32m+[m[32m if type(response) == tuple:[m
[32m+[m[32m if len(response) == 2:[m
[32m+[m[32m status = response[0][m
[32m+[m[32m meta = response[1][m
[32m+[m[32m response = ''[m
[32m+[m[32m else:[m
[32m+[m[32m status = response[0][m
[32m+[m[32m meta = response[1][m
[32m+[m[32m response = response[2][m
[32m+[m[32m else:[m
[32m+[m[32m status = 20[m
[32m+[m[32m meta = 'text/gemini;charset=utf-8'[m
[32m+[m
[32m+[m[32m if response == None:[m
[32m+[m[32m response_data = b''[m
[32m+[m[32m elif type(response) == str:[m
[32m+[m[32m response_data = response.encode('utf-8')[m
[32m+[m[32m else:[m
[32m+[m[32m response_data = response[m
[32m+[m
[32m+[m[32m stream.sendall(f'{status} {meta}\r\n'.encode('utf-8') + response_data)[m
[32m+[m
[32m+[m[32m # Save to cache.[m
[32m+[m[32m if not is_from_cache and cache and status == 20:[m
[32m+[m[32m cache.save(path, meta, response_data)[m
[32m+[m[32m else:[m
[32m+[m[32m report_error(stream, 50, 'Permanent failure')[m
[1mdiff --git a/gmcapsule.py b/gmcapsule.py[m
[1mnew file mode 100644[m
[1mindex 0000000..4886141[m
[1m--- /dev/null[m
[1m+++ b/gmcapsule.py[m
[36m@@ -0,0 +1,109 @@[m
[32m+[m[32m# Copyright (c) 2022 Jaakko Keränen <jaakko.keranen@iki.fi>[m
[32m+[m[32m# License: BSD 2-Clause[m
[32m+[m
[32m+[m[32mimport configparser[m
[32m+[m[32mimport fnmatch[m
[32m+[m[32mimport importlib[m
[32m+[m[32mimport mimetypes[m
[32m+[m[32mimport os[m
[32m+[m[32mimport subprocess[m
[32m+[m[32mfrom pathlib import Path[m
[32m+[m
[32m+[m[32mimport gemini[m
[32m+[m
[32m+[m
[32m+[m[32mclass Config:[m
[32m+[m[32m def __init__(self):[m
[32m+[m[32m # TODO: Get this using configparser[m
[32m+[m[32m self.hostnames = ['localhost'][m
[32m+[m[32m self.address = '0.0.0.0'[m
[32m+[m[32m self.port = 1965[m
[32m+[m[32m self.certs_dir = Path('.certs')[m
[32m+[m[32m self.root_dir = Path('.') # vhosts as subdirs[m
[32m+[m[32m self.mod_dir = Path('modules') # extension modules[m
[32m+[m[32m self.max_upload_size = 10 * 1024 * 1024[m
[32m+[m[32m self.cgi = {[m
[32m+[m[32m 'gemini': {[m
[32m+[m[32m 'localhost': [[m
[32m+[m[32m ('/test', ['/bin/ls', '-l'])[m
[32m+[m[32m ][m
[32m+[m[32m },[m
[32m+[m[32m 'titan': {[m
[32m+[m[32m 'localhost': [[m
[32m+[m[32m ('/test', ['printenv']),[m
[32m+[m[32m ('/test/*', ['printenv'])[m
[32m+[m[32m ][m
[32m+[m[32m }[m
[32m+[m[32m }[m
[32m+[m
[32m+[m
[32m+[m[32mclass Capsule:[m
[32m+[m[32m _capsule = None[m
[32m+[m
[32m+[m[32m def __init__(self, cfg):[m
[32m+[m[32m Capsule._capsule = self[m
[32m+[m[32m self.cfg = cfg[m
[32m+[m[32m self.sv = gemini.Server([m
[32m+[m[32m cfg.hostnames,[m
[32m+[m[32m cfg.certs_dir / 'cert.pem',[m
[32m+[m[32m cfg.certs_dir / 'key.pem',[m
[32m+[m[32m address=cfg.address,[m
[32m+[m[32m port=cfg.port,[m
[32m+[m[32m session_id=f'GmCapsule:{cfg.port}'.encode('utf-8'),[m
[32m+[m[32m max_upload_size=cfg.max_upload_size[m
[32m+[m[32m )[m
[32m+[m[32m # Modules define the entrypoints.[m
[32m+[m[32m self.load_modules()[m
[32m+[m
[32m+[m[32m @staticmethod[m
[32m+[m[32m def config():[m
[32m+[m[32m return Capsule._capsule.cfg[m
[32m+[m
[32m+[m[32m def add(self, path, entrypoint, hostname=None, protocol='gemini'):[m
[32m+[m[32m if hostname:[m
[32m+[m[32m self.sv.add_entrypoint(protocol, hostname, path, entrypoint)[m
[32m+[m[32m else:[m
[32m+[m[32m for hostname in self.cfg.hostnames:[m
[32m+[m[32m if not hostname:[m
[32m+[m[32m raise Exception(f'invalid hostname: "{hostname}"')[m
[32m+[m[32m self.sv.add_entrypoint(protocol, hostname, path, entrypoint)[m
[32m+[m
[32m+[m[32m def load_modules(self):[m
[32m+[m[32m for mod_file in sorted(os.listdir(self.cfg.mod_dir)):[m
[32m+[m[32m if mod_file.endswith('.py'):[m
[32m+[m[32m path = (self.cfg.mod_dir / mod_file).resolve()[m
[32m+[m[32m name = mod_file[:-3][m
[32m+[m[32m print('Module:', name)[m
[32m+[m[32m loader = importlib.machinery.SourceFileLoader(name, str(path))[m
[32m+[m[32m spec = importlib.util.spec_from_loader(name, loader)[m
[32m+[m[32m mod = importlib.util.module_from_spec(spec)[m
[32m+[m[32m loader.exec_module(mod)[m
[32m+[m[32m mod.init(self)[m
[32m+[m
[32m+[m[32m def run(self):[m
[32m+[m[32m self.sv.run()[m
[32m+[m
[32m+[m
[32m+[m[32mdef get_mime_type(path):[m
[32m+[m[32m p = str(path)[m
[32m+[m[32m lp = p.lower()[m
[32m+[m[32m if lp.endswith('.txt'):[m
[32m+[m[32m return 'text/plain'[m
[32m+[m[32m if lp.endswith('.gmi') or lp.endswith('.gemini'):[m
[32m+[m[32m return 'text/gemini'[m
[32m+[m[32m if lp.endswith('.md') or lp.endswith('.markdown') or lp.endswith('.mdown'):[m
[32m+[m[32m return 'text/markdown'[m
[32m+[m
[32m+[m[32m if not path.exists():[m
[32m+[m[32m return None[m
[32m+[m
[32m+[m[32m mt = mimetypes.guess_type(path)[0][m
[32m+[m[32m if mt is not None:[m
[32m+[m[32m return mt[m
[32m+[m
[32m+[m[32m try:[m
[32m+[m[32m return subprocess.check_output([[m
[32m+[m[32m '/usr/bin/env', 'file', '--mime-type', '-b', p[m
[32m+[m[32m ]).decode('utf-8').strip()[m
[32m+[m[32m except:[m
[32m+[m[32m return 'application/octet-stream'[m
[1mdiff --git a/gmcapsuled b/gmcapsuled[m
[1mnew file mode 100755[m
[1mindex 0000000..cab942e[m
[1m--- /dev/null[m
[1m+++ b/gmcapsuled[m
[36m@@ -0,0 +1,21 @@[m
[32m+[m[32m#!/usr/bin/env python3[m
[32m+[m[32m# GmCapsule: Extensible Server for Gemini and Titan[m
[32m+[m[32m#[m
[32m+[m[32m# Copyright (c) 2022 Jaakko Keränen <jaakko.keranen@iki.fi>[m
[32m+[m[32m# License: BSD 2-Clause[m
[32m+[m
[32m+[m[32mimport argparse[m
[32m+[m[32mimport sys[m
[32m+[m[32mimport threading[m
[32m+[m
[32m+[m[32mfrom gmcapsule import *[m
[32m+[m
[32m+[m[32mVERSION = '0.1'[m
[32m+[m
[32m+[m[32mprint(f"GmCapsule {VERSION}")[m
[32m+[m
[32m+[m[32m# TODO: Parse command arguments.[m
[32m+[m
[32m+[m[32mcfg = Config()[m
[32m+[m[32mcapsule = Capsule(cfg)[m
[32m+[m[32mcapsule.run()[m
[1mdiff --git a/modules/400_cgi.py b/modules/400_cgi.py[m
[1mnew file mode 100644[m
[1mindex 0000000..c635a85[m
[1m--- /dev/null[m
[1m+++ b/modules/400_cgi.py[m
[36m@@ -0,0 +1,68 @@[m
[32m+[m
[32m+[m[32mimport os[m
[32m+[m[32mimport subprocess[m
[32m+[m[32mimport urllib.parse[m
[32m+[m
[32m+[m[32mfrom gmcapsule import Capsule[m
[32m+[m
[32m+[m
[32m+[m[32mclass CgiContext:[m
[32m+[m[32m def __init__(self, url_path, args):[m
[32m+[m[32m self.args = args[m
[32m+[m[32m self.base_path = url_path[m
[32m+[m[32m if self.base_path.endswith('/*'):[m
[32m+[m[32m self.base_path = self.base_path[:-2][m
[32m+[m
[32m+[m[32m def __call__(self, req):[m
[32m+[m[32m try:[m
[32m+[m[32m cfg = Capsule.config()[m
[32m+[m[32m query = urllib.parse.unquote(req.query)[m
[32m+[m[32m env_vars = dict(os.environ)[m
[32m+[m
[32m+[m[32m # Standard CGI variables.[m
[32m+[m[32m env_vars['REMOTE_ADDR'] = '%s:%d' % req.remote_address[m
[32m+[m[32m env_vars['QUERY_STRING'] = req.query[m
[32m+[m[32m assert req.path.startswith(self.base_path)[m
[32m+[m[32m env_vars['PATH_INFO'] = req.path[len(self.base_path):][m
[32m+[m
[32m+[m[32m if req.identity:[m
[32m+[m[32m env_vars['REMOTE_IDENT'] = str(req.identity)[m
[32m+[m[32m env_vars['REMOTE_USER'] = req.identity.subject()[m
[32m+[m
[32m+[m[32m if req.content:[m
[32m+[m[32m env_vars['TITAN_TOKEN'] = req.content_token if req.content_token is not None else ''[m
[32m+[m[32m env_vars['TITAN_MIME'] = req.content_mime if req.content_mime is not None else ''[m
[32m+[m
[32m+[m[32m print(req.content)[m
[32m+[m
[32m+[m[32m result = subprocess.run(self.args, check=True,[m
[32m+[m[32m input=req.content,[m
[32m+[m[32m stdout=subprocess.PIPE,[m
[32m+[m[32m env=env_vars).stdout[m
[32m+[m[32m try:[m
[32m+[m[32m # Parse response header.[m
[32m+[m[32m crlf_pos = result.find(b'\r\n')[m
[32m+[m[32m if crlf_pos >= 1024:[m
[32m+[m[32m return 42, "CGI command returned invalid response header"[m
[32m+[m[32m header = result[:crlf_pos].decode('utf-8')[m
[32m+[m[32m body = result[crlf_pos + 2:][m
[32m+[m[32m status = int(header[:2])[m
[32m+[m[32m meta = header[2:].strip()[m
[32m+[m[32m if status < 10:[m
[32m+[m[32m return 42, "CGI command returned invalid status code"[m
[32m+[m[32m return status, meta, body[m
[32m+[m[32m except:[m
[32m+[m[32m try:[m
[32m+[m[32m return 20, 'text/plain; charset=utf-8', result.decode('utf-8')[m
[32m+[m[32m except:[m
[32m+[m[32m return 20, 'application/octet-stream', result[m
[32m+[m[32m except Exception as er:[m
[32m+[m[32m return 42, "CGI error: " + str(er)[m
[32m+[m
[32m+[m
[32m+[m[32mdef init(capsule):[m
[32m+[m[32m cfg = Capsule.config()[m
[32m+[m[32m for protocol in cfg.cgi:[m
[32m+[m[32m for hostname in cfg.cgi[protocol]:[m
[32m+[m[32m for entry in cfg.cgi[protocol][hostname]:[m
[32m+[m[32m capsule.add(entry[0], CgiContext(entry[0], entry[1]), hostname, protocol)[m
[1mdiff --git a/modules/500_static.py b/modules/500_static.py[m
[1mnew file mode 100644[m
[1mindex 0000000..a525993[m
[1m--- /dev/null[m
[1m+++ b/modules/500_static.py[m
[36m@@ -0,0 +1,66 @@[m
[32m+[m[32mimport fnmatch[m
[32m+[m[32mimport os.path[m
[32m+[m[32mimport string[m
[32m+[m
[32m+[m[32mfrom gmcapsule import Capsule, get_mime_type[m
[32m+[m[32mfrom pathlib import Path[m
[32m+[m
[32m+[m[32mMETA = '.meta'[m
[32m+[m
[32m+[m
[32m+[m[32mdef check_meta_rules(path, hostname):[m
[32m+[m[32m cfg = Capsule.config()[m
[32m+[m[32m root = (cfg.root_dir / hostname).resolve()[m
[32m+[m[32m dir = path.parent[m
[32m+[m[32m while True:[m
[32m+[m[32m if not str(dir.resolve()).startswith(str(root)):[m
[32m+[m[32m break[m
[32m+[m[32m if (dir / META).exists():[m
[32m+[m[32m for rule in open(dir / META, 'rt').readlines():[m
[32m+[m[32m rule = rule.strip()[m
[32m+[m[32m if len(rule) == 0: continue[m
[32m+[m[32m pos = rule.find(':')[m
[32m+[m[32m if pos < 0: continue[m
[32m+[m[32m rule_path = dir / rule[:pos].strip()[m
[32m+[m[32m rule_meta = rule[pos + 1:].strip()[m
[32m+[m[32m if fnmatch.fnmatch(root / path, rule_path):[m
[32m+[m[32m if len(rule_meta) >= 4 and rule_meta[2] in string.whitespace:[m
[32m+[m[32m return int(rule_meta[:2]), rule_meta[3:][m
[32m+[m[32m return 20, rule_meta[m
[32m+[m[32m dir = dir.parent[m
[32m+[m
[32m+[m[32m return 20, get_mime_type(path)[m
[32m+[m
[32m+[m
[32m+[m[32mdef serve_file(req):[m
[32m+[m[32m if req.scheme != 'gemini':[m
[32m+[m[32m return 59, "Only Gemini requests allowed"[m
[32m+[m
[32m+[m[32m cfg = Capsule.config()[m
[32m+[m[32m if req.path == '':[m
[32m+[m[32m return 30, '/'[m
[32m+[m
[32m+[m[32m for seg in req.path.split('/'):[m
[32m+[m[32m if seg != '.' and seg != '..' and seg.startswith('.'):[m
[32m+[m[32m return 51, "Not found"[m
[32m+[m
[32m+[m[32m host_root = (cfg.root_dir / req.hostname).resolve()[m
[32m+[m[32m path = (host_root / req.path[1:]).resolve()[m
[32m+[m[32m if not str(path).startswith(str(host_root)):[m
[32m+[m[32m return 51, "Not found"[m
[32m+[m
[32m+[m[32m if os.path.isdir(str(path)):[m
[32m+[m[32m path = path / 'index.gmi'[m
[32m+[m
[32m+[m[32m status, meta = check_meta_rules(path, req.hostname)[m
[32m+[m[32m if status and status != 20:[m
[32m+[m[32m return status, meta[m
[32m+[m
[32m+[m[32m if not os.path.exists(str(path)):[m
[32m+[m[32m return 51, "Not found"[m
[32m+[m
[32m+[m[32m return status, meta, (open(path, 'rb').read() if status == 20 else None)[m
[32m+[m
[32m+[m
[32m+[m[32mdef init(capsule):[m
[32m+[m[32m capsule.add('/*', serve_file)[m
[1mdiff --git a/requirements.txt b/requirements.txt[m
[1mnew file mode 100644[m
[1mindex 0000000..8c388fa[m
[1m--- /dev/null[m
[1m+++ b/requirements.txt[m
[36m@@ -0,0 +1 @@[m
[32m+[m[32mpyOpenSSL[m