=> 5ab0ba40b8019dff5ce05faaf923eb1a6a160241
[1mdiff --git a/README.md b/README.md[m [1mindex be0fcb5..0b1540a 100644[m [1m--- a/README.md[m [1m+++ b/README.md[m [36m@@ -23,6 +23,7 @@[m [mCreate the following service file and save it as _~/.config/systemd/user/gmcapsu[m [Service][m Type=simple[m ExecStart=/gmcapsuled[m [32m+[m[32m ExecReload=/bin/kill -HUP $MAINPID[m Restart=always[m Environment="PYTHONUNBUFFERED=1"[m StandardOutput=syslog[m [36m@@ -46,6 +47,12 @@[m [mThe log can be viewed via journalctl (or syslog):[m [m ## Change log[m [m [32m+[m[32m### v0.5[m [32m+[m [32m+[m[32m* Extension modules can register new protocols in addition to the built-in Gemini and Titan.[m [32m+[m[32m* SIGHUP causes the configuration file to be reloaded and workers to be restarted. The listening socket remains open, so the socket and TLS parameters cannot be changed.[m [32m+[m[32m* API change: Extension modules get initialized separately in each worker thread. Instead of a `Capsule`, the extension module `init` method is passed a `WorkerContext`. `Capsule` is no longer available as global state.[m [32m+[m ### v0.4[m [m * Added built-in module "rewrite" that matches regular expressions against the request path and can rewrite the path or return a custom status for redirection, "Gone" messages, or other exceptional situations.[m [1mdiff --git a/docs/api.rst b/docs/api.rst[m [1mindex 41024fe..75db2ed 100644[m [1m--- a/docs/api.rst[m [1m+++ b/docs/api.rst[m [36m@@ -12,6 +12,7 @@[m [mThese classes and functions are available to extension modules by importing[m Cache[m gemini.Request[m gemini.Identity[m [32m+[m[32m WorkerContext[m [m [m Classes[m [36m@@ -29,7 +30,7 @@[m [mCapsule[m :members:[m [m Cache[m [31m-------[m [32m+[m[32m-----[m .. autoclass:: gmcapsule.Cache[m :members:[m [m [36m@@ -43,6 +44,11 @@[m [mIdentity[m .. autoclass:: gmcapsule.gemini.Identity[m :members:[m [m [32m+[m[32mWorkerContext[m [32m+[m[32m-------------[m [32m+[m[32m.. autoclass:: gmcapsule.WorkerContext[m [32m+[m[32m :members:[m [32m+[m [m Functions[m *********[m [1mdiff --git a/gmcapsule/__init__.py b/gmcapsule/__init__.py[m [1mindex 7a98b98..ebbeeb5 100644[m [1m--- a/gmcapsule/__init__.py[m [1m+++ b/gmcapsule/__init__.py[m [36m@@ -419,16 +419,16 @@[m [mInitialization[m [m Each extension module is required to have an initialization function:[m [m [31m-.. py:function:: extmod.init(capsule)[m [32m+[m[32m.. py:function:: extmod.init(context)[m [m Initialize the extension module.[m [m This is called once immediately after the extension has been loaded.[m [m [31m- :param capsule: Server instance. The extension can use this to access[m [32m+[m[32m :param context: Worker context. The extension can use this to access[m configuration parameters, install caches, and register entry[m [31m- points.[m [31m- :type capsule: gmcapsule.Capsule[m [32m+[m[32m points and custom scheme handlers.[m [32m+[m[32m :type context: gmcapsule.WorkerContext[m [m [m Requests[m [36m@@ -481,13 +481,13 @@[m [mimport shlex[m import subprocess[m from pathlib import Path[m [m [31m-from .gemini import Server, Cache[m [32m+[m[32mfrom .gemini import Server, Cache, WorkerContext[m from .markdown import to_gemtext as markdown_to_gemtext[m [m [m __version__ = '0.5.0'[m __all__ = [[m [31m- 'Config', 'Cache',[m [32m+[m[32m 'Config', 'Cache', 'WorkerContext',[m 'get_mime_type', 'markdown_to_gemtext'[m ][m [m [1mdiff --git a/gmcapsule/gemini.py b/gmcapsule/gemini.py[m [1mindex a7026a0..4744387 100644[m [1m--- a/gmcapsule/gemini.py[m [1m+++ b/gmcapsule/gemini.py[m [36m@@ -304,6 +304,7 @@[m [mclass WorkerContext:[m for hostname in self.hostnames:[m self.entrypoints[proto][hostname] = [][m self.caches = [][m [32m+[m[32m self.protocols = {}[m self.is_quiet = False[m [m def config(self):[m [36m@@ -344,6 +345,18 @@[m [mclass WorkerContext:[m """[m self.caches.append(cache)[m [m [32m+[m[32m def add_protocol(self, scheme, handler):[m [32m+[m[32m """[m [32m+[m[32m Registers a new protocol handler.[m [32m+[m [32m+[m[32m Args:[m [32m+[m[32m scheme (str): URL scheme of the protocol.[m [32m+[m[32m handler (callable): Handler to be called when a request with the[m [32m+[m[32m specified scheme is received. The handler must return the[m [32m+[m[32m response to be sent to the client (bytes).[m [32m+[m[32m """[m [32m+[m[32m self.protocols[scheme] = handler[m [32m+[m def add(self, path, entrypoint, hostname=None, protocol='gemini'):[m """[m Register a URL entry point.[m [36m@@ -470,16 +483,142 @@[m [mclass WorkerContext:[m raise GeminiError(50, 'Permanent failure')[m [m [m [32m+[m[32mclass RequestData:[m [32m+[m[32m """Encapsules data about an incoming request, before it has been fully parsed."""[m [32m+[m [32m+[m[32m def __init__(self, worker, stream, buffered_data, from_addr, identity, request):[m [32m+[m[32m self.worker = worker[m [32m+[m[32m self.stream = stream[m [32m+[m[32m self.buffered_data = buffered_data[m [32m+[m[32m self.from_addr = from_addr[m [32m+[m[32m self.identity = identity[m [32m+[m[32m self.request = request[m [32m+[m [32m+[m [32m+[m[32mdef handle_gemini_or_titan_request(request_data):[m [32m+[m[32m worker = request_data.worker[m [32m+[m[32m stream = request_data.stream[m [32m+[m[32m data = request_data.buffered_data[m [32m+[m[32m from_addr = request_data.from_addr[m [32m+[m[32m identity = request_data.identity[m [32m+[m[32m request = request_data.request[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+[m[32m if request.startswith('titan:'):[m [32m+[m[32m if identity is None and worker.cfg.require_upload_identity():[m [32m+[m[32m report_error(stream, 60, "Client certificate required for upload")[m [32m+[m[32m return[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 worker.log(f'Receiving Titan content: {expected_size}')[m [32m+[m[32m max_upload_size = worker.cfg.max_upload_size()[m [32m+[m[32m if expected_size > max_upload_size and 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 = safe_recv(stream, 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, "Bad request")[m [32m+[m[32m return[m [32m+[m [32m+[m[32m url = urlparse(request)[m [32m+[m[32m path = url.path[m [32m+[m[32m if path == '':[m [32m+[m[32m path = '/'[m [32m+[m[32m hostname = url.hostname[m [32m+[m [32m+[m[32m if url.port != None and url.port != worker.port:[m [32m+[m[32m report_error(stream, 59, "Invalid port number")[m [32m+[m[32m return[m [32m+[m[32m if not stream.get_servername():[m [32m+[m[32m # Server name indication is required.[m [32m+[m[32m report_error(stream, 59, "Missing TLS server name indication")[m [32m+[m[32m return[m [32m+[m[32m if stream.get_servername().decode() != hostname:[m [32m+[m[32m report_error(stream, 53, "Proxy request refused")[m [32m+[m[32m return[m [32m+[m [32m+[m[32m try:[m [32m+[m[32m request = 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 if '?' in request else None,[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 response, from_cache = worker.context.call_entrypoint(request)[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, meta = response[m [32m+[m[32m response = ''[m [32m+[m[32m else:[m [32m+[m[32m status, meta, response = response[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 safe_sendall(stream, f'{status} {meta}\r\n'.encode('utf-8'))[m [32m+[m[32m safe_sendall(stream, response_data)[m [32m+[m [32m+[m[32m # Save to cache.[m [32m+[m[32m if not from_cache and status == 20 and \[m [32m+[m[32m (type(response_data) == bytes or type(response_data) == bytearray):[m [32m+[m[32m for cache in worker.context.caches:[m [32m+[m[32m if cache.save(hostname + path, meta, response_data):[m [32m+[m[32m break[m [32m+[m [32m+[m[32m # Close handles.[m [32m+[m[32m if hasattr(response_data, 'close'):[m [32m+[m[32m response_data.close()[m [32m+[m [32m+[m[32m except GeminiError as error:[m [32m+[m[32m report_error(stream, error.status, str(error))[m [32m+[m[32m return[m [32m+[m [32m+[m class Worker(threading.Thread):[m """Thread that handles incoming requests from clients."""[m [m def __init__(self, id, cfg, work_queue, shutdown_event):[m [31m- #super().__init__(target=Worker._run, args=(self,)) # mp[m [32m+[m[32m #super().__init__(target=Worker._run, args=(self,)) # multiprocessing[m super().__init__()[m self.id = id[m self.cfg = cfg[m self.port = cfg.port()[m self.context = WorkerContext(self.cfg, shutdown_event)[m [32m+[m[32m self.context.add_protocol('gemini', handle_gemini_or_titan_request)[m [32m+[m[32m self.context.add_protocol('titan', handle_gemini_or_titan_request)[m self.context.set_quiet(id > 0)[m self.jobs = work_queue[m [m [36m@@ -520,9 +659,6 @@[m [mclass Worker(threading.Thread):[m MAX_LEN = 1024[m MAX_RECV = MAX_LEN + 2 # includes terminator "\r\n"[m request = None[m [31m- expected_size = 0[m [31m- req_token = None[m [31m- req_mime = None[m incoming = safe_recv(stream, MAX_RECV)[m [m try:[m [36m@@ -547,117 +683,26 @@[m [mclass Worker(threading.Thread):[m return[m [m if not request or len(data) > MAX_RECV:[m [31m- report_error(stream, 59, "Invalid request")[m [31m- return[m [31m- if not (request.startswith('gemini:') or request.startswith('titan:')):[m [31m- report_error(stream, 59, "Unsupported protocol")[m [32m+[m[32m report_error(stream, 59, "Bad request")[m return[m [m cl_cert = stream.get_peer_certificate()[m identity = Identity(cl_cert) if cl_cert else None[m [m [31m- if request.startswith('titan:'):[m [31m- if identity is None and self.cfg.require_upload_identity():[m [31m- report_error(stream, 60, "Client certificate required for upload")[m [31m- return[m [31m-[m [31m- # Read the rest of the data.[m [31m- parms = request.split(';')[m [31m- request = parms[0][m [31m- for p in parms:[m [31m- if p.startswith('size='):[m [31m- expected_size = int(p[5:])[m [31m- elif p.startswith('token='):[m [31m- req_token = p[6:][m [31m- elif p.startswith('mime='):[m [31m- req_mime = p[5:][m [31m- self.log(f'Receiving Titan content: {expected_size}')[m [31m- max_upload_size = self.cfg.max_upload_size()[m [31m- if expected_size > max_upload_size and max_upload_size > 0:[m [31m- report_error(stream, 59, "Maximum content length exceeded")[m [31m- return[m [31m- while len(data) < expected_size:[m [31m- incoming = safe_recv(stream, 65536)[m [31m- if len(incoming) == 0:[m [31m- break[m [31m- data += incoming[m [31m- if len(data) != expected_size:[m [31m- report_error(stream, 59, "Invalid content length")[m [32m+[m[32m for scheme, handler in self.context.protocols.items():[m [32m+[m[32m if request.startswith(scheme + ':'):[m [32m+[m[32m self.log(request)[m [32m+[m[32m response_data = handler(RequestData(self,[m [32m+[m[32m stream, data, from_addr,[m [32m+[m[32m identity, request))[m [32m+[m[32m if not response_data is None:[m [32m+[m[32m safe_sendall(stream, response_data)[m [32m+[m[32m # Close handles.[m [32m+[m[32m if hasattr(response_data, 'close'):[m [32m+[m[32m response_data.close()[m return[m [31m- else:[m [31m- # No Payload in Gemini.[m [31m- if len(data):[m [31m- report_error(stream, 59, "Gemini disallows request content")[m [31m- return[m [31m-[m [31m- self.log(request)[m [31m-[m [31m- url = urlparse(request)[m [31m- path = url.path[m [31m- if path == '':[m [31m- path = '/'[m [31m- hostname = url.hostname[m [m [31m- if url.port != None and url.port != self.port:[m [31m- report_error(stream, 59, "Invalid port number")[m [31m- return[m [31m- if not stream.get_servername():[m [31m- # Server name indication is required.[m [31m- report_error(stream, 59, "Missing TLS server name indication")[m [31m- return[m [31m- if stream.get_servername().decode() != hostname:[m [31m- report_error(stream, 53, "Proxy request refused")[m [31m- return[m [31m-[m [31m- try:[m [31m- request = Request([m [31m- identity,[m [31m- remote_address=from_addr,[m [31m- scheme=url.scheme,[m [31m- hostname=hostname,[m [31m- path=path,[m [31m- query=url.query if '?' in request else None,[m [31m- content_token=req_token,[m [31m- content_mime=req_mime,[m [31m- content=data if len(data) else None[m [31m- )[m [31m- response, from_cache = self.context.call_entrypoint(request)[m [31m-[m [31m- # Determine status code, meta line, and body content.[m [31m- if type(response) == tuple:[m [31m- if len(response) == 2:[m [31m- status, meta = response[m [31m- response = ''[m [31m- else:[m [31m- status, meta, response = response[m [31m- else:[m [31m- status = 20[m [31m- meta = 'text/gemini; charset=utf-8'[m [31m-[m [31m- if response == None:[m [31m- response_data = b''[m [31m- elif type(response) == str:[m [31m- response_data = response.encode('utf-8')[m [31m- else:[m [31m- response_data = response[m [31m-[m [31m- safe_sendall(stream, f'{status} {meta}\r\n'.encode('utf-8'))[m [31m- safe_sendall(stream, response_data)[m [31m-[m [31m- # Save to cache.[m [31m- if not from_cache and status == 20 and \[m [31m- (type(response_data) == bytes or type(response_data) == bytearray):[m [31m- for cache in self.context.caches:[m [31m- if cache.save(hostname + path, meta, response_data):[m [31m- break[m [31m-[m [31m- # Close handles.[m [31m- if hasattr(response_data, 'close'):[m [31m- response_data.close()[m [31m-[m [31m- except GeminiError as error:[m [31m- report_error(stream, error.status, str(error))[m [31m- return[m [32m+[m[32m report_error(stream, 59, "Unsupported protocol")[m [m [m _server_instance = None[m
text/gemini; charset=utf-8
This content has been proxied by September (ba2dc).