Better path handling

This commit is contained in:
Michael Lazar 2019-08-05 22:21:28 -04:00
parent 3efbd727dd
commit 94e28235b3
1 changed files with 55 additions and 50 deletions

View File

@ -1,20 +1,23 @@
#!/usr/bin/env python3 #!/usr/bin/env python3.7
import argparse import argparse
import asyncio import asyncio
import datetime import datetime
import mimetypes import mimetypes
import os import pathlib
import ssl import ssl
import sys import sys
import typing import typing
# Fail early to avoid crashing with an obscure error
if sys.version_info < (3, 7):
sys.exit("Fatal Error: jetforce requires Python 3.7+")
__version__ = "0.0.1" __version__ = "0.0.1"
__title__ = "Jetforce Gemini Server" __title__ = "Jetforce Gemini Server"
__author__ = "Michael Lazar" __author__ = "Michael Lazar"
__license__ = "GNU General Public License v3.0" __license__ = "GNU General Public License v3.0"
__copyright__ = "(c) 2019 Michael Lazar" __copyright__ = "(c) 2019 Michael Lazar"
ABOUT = fr""" ABOUT = fr"""
You are now riding on... You are now riding on...
_________ _____________ _________ _____________
@ -27,7 +30,6 @@ An Experimental Gemini Server, v{__version__}
https://github.com/michael-lazar/jetforce https://github.com/michael-lazar/jetforce
""" """
# Gemini response status codes # Gemini response status codes
STATUS_SUCCESS = 2 STATUS_SUCCESS = 2
STATUS_NOT_FOUND = 4 STATUS_NOT_FOUND = 4
@ -62,56 +64,72 @@ class StaticDirectoryApp:
directory listing will be auto-generated. directory listing will be auto-generated.
""" """
root: str = "/var/gemini" def __init__(self, root: str, environ: dict, send_status: typing.Callable) -> None:
self.root = pathlib.Path(root).resolve(strict=True)
def __init__(self, environ: dict, send_status: typing.Callable) -> None:
self.environ = environ self.environ = environ
self.send_status = send_status self.send_status = send_status
self.mimetypes = mimetypes.MimeTypes() self.mimetypes = mimetypes.MimeTypes()
def __iter__(self) -> typing.Iterator[bytes]: @classmethod
filename = self.environ["PATH"] def serve_directory(cls, root: str) -> typing.Callable:
filename = filename.lstrip("/") """
Return an app that points to the given root directory on the file system.
"""
abs_filename = os.path.abspath(os.path.join(self.root, filename)) def build_class(environ: dict, send_status: typing.Callable):
if not abs_filename.startswith(self.root): return cls(root, environ, send_status)
return build_class
def __iter__(self) -> typing.Iterator[bytes]:
url_path = pathlib.Path(self.environ["PATH"].strip("/"))
filesystem_path = (self.root / url_path).resolve()
try:
filesystem_path.relative_to(self.root)
except ValueError:
# Guard against breaking out of the directory # Guard against breaking out of the directory
self.send_status(STATUS_NOT_FOUND, "Not Found") self.send_status(STATUS_NOT_FOUND, "Not Found")
return
elif os.path.isfile(abs_filename): if filesystem_path.is_file():
mimetype = self.guess_mimetype(abs_filename) mimetype = self.guess_mimetype(filesystem_path.name)
yield from self.load_file(abs_filename, mimetype) yield from self.load_file(filesystem_path, mimetype)
elif os.path.isdir(abs_filename): elif filesystem_path.is_dir():
gemini_map_file = os.path.join(abs_filename, ".gemini") gemini_file = filesystem_path / ".gemini"
if os.path.exists(gemini_map_file): if gemini_file.exists():
yield from self.load_file(gemini_map_file, "text/gemini") yield from self.load_file(gemini_file, "text/gemini")
else: else:
yield from self.list_directory(abs_filename) yield from self.list_directory(url_path, filesystem_path)
else: else:
self.send_status(STATUS_NOT_FOUND, "Not Found") self.send_status(STATUS_NOT_FOUND, "Not Found")
def load_file(self, abs_filename: str, mimetype: str): def load_file(self, filesystem_path: pathlib.Path, mimetype: str):
self.send_status(STATUS_SUCCESS, mimetype) self.send_status(STATUS_SUCCESS, mimetype)
with open(abs_filename, "rb") as fp: with filesystem_path.open("rb") as fp:
data = fp.read(1024) data = fp.read(1024)
while data: while data:
yield data yield data
data = fp.read(1024) data = fp.read(1024)
def list_directory(self, abs_folder: str): def list_directory(self, url_path: pathlib.Path, filesystem_path: pathlib.Path):
self.send_status(STATUS_SUCCESS, "text/gemini") self.send_status(STATUS_SUCCESS, "text/gemini")
for filename in os.listdir(abs_folder): yield f"Directory: /{url_path}\r\n".encode()
if filename == ".gemini" or filename.startswith("~"): if url_path.parent != url_path:
yield f"=>/{url_path.parent}\t..\r\n".encode()
for file in sorted(filesystem_path.iterdir()):
if file.name.startswith((".", "~")):
# Skip hidden and temporary files for security reasons
continue continue
abs_filename = os.path.join(abs_folder, filename) elif file.is_dir():
if os.path.isdir(abs_filename): yield f"=>/{url_path / file.name}\t{file.name}/\r\n".encode()
# The directory end slash is necessary for relative paths to work else:
filename += "/" yield f"=>/{url_path / file.name}\t{file.name}\r\n".encode()
yield f"=>{filename}\r\n".encode()
def guess_mimetype(self, filename: str): def guess_mimetype(self, filename: str):
mime, encoding = self.mimetypes.guess_type(filename) mime, encoding = self.mimetypes.guess_type(filename)
@ -176,11 +194,9 @@ class GeminiRequestHandler:
app = self.app(environ, self.write_status) app = self.app(environ, self.write_status)
for data in app: for data in app:
await 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:
await self.flush_status() await self.flush_status()
self.log_request() self.log_request()
@ -269,21 +285,12 @@ class GeminiServer:
request_handler_class = GeminiRequestHandler request_handler_class = GeminiRequestHandler
def __init__( def __init__(
self, self, host: str, port: int, ssl_context: ssl.SSLContext, app: typing.Callable
host: str,
port: int,
ssl_context: typing.Union[tuple, ssl.SSLContext],
app: typing.Callable,
) -> None: ) -> None:
self.host = host self.host = host
self.port = port self.port = port
self.ssl_context = ssl_context
self.app = app self.app = app
if isinstance(ssl_context, tuple):
self.ssl_context = ssl.SSLContext()
self.ssl_context.load_cert_chain(*ssl_context)
else:
self.ssl_context = ssl_context
async def run(self) -> None: async def run(self) -> None:
""" """
@ -328,10 +335,7 @@ 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( parser.add_argument(
"--dir", "--dir", help="local directory to serve files from", default="/var/gemini"
help="local directory to serve files from",
type=str,
default=StaticDirectoryApp.root,
) )
parser.add_argument( parser.add_argument(
"--tls-certfile", "--tls-certfile",
@ -347,13 +351,14 @@ def run_server():
) )
args = parser.parse_args() args = parser.parse_args()
StaticDirectoryApp.root = args.dir ssl_context = ssl.SSLContext()
ssl_context.load_cert_chain(args.tls_certfile, args.tls_keyfile)
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=ssl_context,
app=StaticDirectoryApp, app=StaticDirectoryApp.serve_directory(args.dir),
) )
asyncio.run(server.run()) asyncio.run(server.run())