[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=<YOUR-INSTALL-PATH>/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
[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
[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/plain
This content has been proxied by September (ba2dc).