Adding static directory serving

This commit is contained in:
Michael Lazar 2019-08-04 21:42:27 -04:00
parent 16c2140d2b
commit 8db0ae5217
1 changed files with 95 additions and 10 deletions

View File

@ -2,6 +2,8 @@
import argparse import argparse
import asyncio import asyncio
import datetime import datetime
import mimetypes
import os
import ssl import ssl
import sys import sys
from typing import Any, Callable, Dict, Iterator, Optional, Union from typing import Any, Callable, Dict, Iterator, Optional, Union
@ -14,14 +16,14 @@ __copyright__ = "(c) 2019 Michael Lazar"
ABOUT = fr""" ABOUT = fr"""
You are now running... You are now riding on...
_________ _____________ _________ _____________
______ /______ /___ __/_______________________ ______ /______ /___ __/_______________________
___ _ /_ _ \ __/_ /_ _ __ \_ ___/ ___/ _ \ ___ _ /_ _ \ __/_ /_ _ __ \_ ___/ ___/ _ \
/ /_/ / / __/ /_ _ __/ / /_/ / / / /__ / __/ / /_/ / / __/ /_ _ __/ / /_/ / / / /__ / __/
\____/ \___/\__/ /_/ \____//_/ \___/ \___/ \____/ \___/\__/ /_/ \____//_/ \___/ \___/
An Experimental Gemini Protocol Server, v{__version__} An Experimental Gemini Server, v{__version__}
https://github.com/michael-lazar/jetforce https://github.com/michael-lazar/jetforce
""" """
@ -38,7 +40,7 @@ STATUS_TOO_MANY_REQUESTS = 9
class EchoApp: class EchoApp:
""" """
A demonstration of a simple echo server in Gemini. A simple application that echos back the requested path.
""" """
def __init__(self, environ: dict, send_status: Callable) -> None: def __init__(self, environ: dict, send_status: Callable) -> None:
@ -51,6 +53,76 @@ class EchoApp:
yield f"Received path: {path}".encode() yield f"Received path: {path}".encode()
class StaticDirectoryApp:
"""
Serve a static directory over Gemini.
If a directory contains a hidden file with the name ".gemini", that file
will be returned when the directory path is requested. Otherwise, a
directory listing will be auto-generated.
"""
root: str = "/var/gemini"
def __init__(self, environ: dict, send_status: Callable) -> None:
self.environ = environ
self.send_status = send_status
self.mimetypes = mimetypes.MimeTypes()
def __iter__(self) -> Iterator[bytes]:
filename = self.environ["PATH"]
filename = filename.lstrip("/")
abs_filename = os.path.abspath(os.path.join(self.root, filename))
if not abs_filename.startswith(self.root):
# Guard against breaking out of the directory
self.send_status(STATUS_NOT_FOUND, "Not Found")
elif os.path.isfile(abs_filename):
mimetype = self.guess_mimetype(abs_filename)
yield from self.load_file(abs_filename, mimetype)
elif os.path.isdir(abs_filename):
gemini_map_file = os.path.join(abs_filename, ".gemini")
if os.path.exists(gemini_map_file):
yield from self.load_file(gemini_map_file, "text/gemini")
else:
yield from self.list_directory(abs_filename)
else:
self.send_status(STATUS_NOT_FOUND, "Not Found")
def load_file(self, abs_filename: str, mimetype: str):
self.send_status(STATUS_SUCCESS, mimetype)
with open(abs_filename, "rb") as fp:
data = fp.read(1024)
while data:
yield data
data = fp.read(1024)
def list_directory(self, abs_folder: str):
self.send_status(STATUS_SUCCESS, "text/gemini")
for filename in os.listdir(abs_folder):
if filename == ".gemini" or filename.startswith("~"):
continue
abs_filename = os.path.join(abs_folder, filename)
if os.path.isdir(abs_filename):
# The directory end slash is necessary for relative paths to work
filename += "/"
yield f"=>{filename}\r\n".encode()
def guess_mimetype(self, filename: str):
mime, encoding = self.mimetypes.guess_type(filename)
if encoding:
return f"{mime}; charset={encoding}"
elif mime:
return mime
else:
return "text/plain"
class GeminiRequestHandler: class GeminiRequestHandler:
""" """
Handle a single Gemini Protocol TCP request. Handle a single Gemini Protocol TCP request.
@ -103,14 +175,14 @@ class GeminiRequestHandler:
environ = self.build_environ() environ = self.build_environ()
app = self.app(environ, self.write_status) app = self.app(environ, self.write_status)
for data in app: for data in app:
self.write_body(data) await self.write_body(data)
except Exception as e: except Exception as e:
self.write_status(STATUS_SERVER_ERROR, str(e)) self.write_status(STATUS_SERVER_ERROR, str(e))
raise raise
finally: finally:
self.flush_status() await self.flush_status()
self.log_request() self.log_request()
await writer.drain() await writer.drain()
@ -121,6 +193,8 @@ class GeminiRequestHandler:
return { return {
"SERVER_HOST": self.server.host, "SERVER_HOST": self.server.host,
"SERVER_PORT": self.server.port, "SERVER_PORT": self.server.port,
"CLIENT_IP": self.client_ip,
"CLIENT_PORT": self.client_port,
"PATH": self.path, "PATH": self.path,
} }
@ -153,15 +227,16 @@ class GeminiRequestHandler:
self.mimetype = mimetype self.mimetype = mimetype
self.response_buffer = f"{status}\t{mimetype}\r\n" self.response_buffer = f"{status}\t{mimetype}\r\n"
def write_body(self, data: bytes) -> None: async def write_body(self, data: bytes) -> None:
""" """
Write bytes to the gemini response body. Write bytes to the gemini response body.
""" """
self.flush_status() await self.flush_status()
self.response_size += len(data) self.response_size += len(data)
self.writer.write(data) self.writer.write(data)
await self.writer.drain()
def flush_status(self) -> None: async def flush_status(self) -> None:
""" """
Flush the status line from the internal buffer to the socket stream. Flush the status line from the internal buffer to the socket stream.
""" """
@ -169,6 +244,7 @@ class GeminiRequestHandler:
data = self.response_buffer.encode() data = self.response_buffer.encode()
self.response_size += len(data) self.response_size += len(data)
self.writer.write(data) self.writer.write(data)
await self.writer.drain()
self.response_buffer = None self.response_buffer = None
def log_request(self) -> None: def log_request(self) -> None:
@ -199,6 +275,7 @@ class GeminiServer:
ssl_context: Union[tuple, ssl.SSLContext], ssl_context: Union[tuple, ssl.SSLContext],
app: Callable, app: Callable,
) -> None: ) -> None:
self.host = host self.host = host
self.port = port self.port = port
self.app = app self.app = app
@ -218,7 +295,7 @@ class GeminiServer:
) )
socket_info = server.sockets[0].getsockname() socket_info = server.sockets[0].getsockname()
self.log_message(f"listening on {socket_info[0]}:{socket_info[1]}") self.log_message(f"Listening on {socket_info[0]}:{socket_info[1]}")
async with server: async with server:
await server.serve_forever() await server.serve_forever()
@ -250,6 +327,12 @@ def run_server():
) )
parser.add_argument("--host", help="Server host", default="127.0.0.1") parser.add_argument("--host", help="Server host", default="127.0.0.1")
parser.add_argument("--port", help="Server port", type=int, default=1965) parser.add_argument("--port", help="Server port", type=int, default=1965)
parser.add_argument(
"--dir",
help="Local directory to serve files from",
type=str,
default=StaticDirectoryApp.root,
)
parser.add_argument( parser.add_argument(
"--tls-certfile", "--tls-certfile",
help="TLS certificate file", help="TLS certificate file",
@ -264,11 +347,13 @@ def run_server():
) )
args = parser.parse_args() args = parser.parse_args()
StaticDirectoryApp.root = args.dir
server = GeminiServer( server = GeminiServer(
host=args.host, host=args.host,
port=args.port, port=args.port,
ssl_context=(args.tls_certfile, args.tls_keyfile), ssl_context=(args.tls_certfile, args.tls_keyfile),
app=EchoApp, app=StaticDirectoryApp,
) )
asyncio.run(server.run()) asyncio.run(server.run())