Significant refactoring, moving examples to a separate directory

This commit is contained in:
Michael Lazar 2019-08-20 21:17:58 -04:00
parent deeab25604
commit dd76b04d76
5 changed files with 270 additions and 93 deletions

27
examples/server_echo.py Normal file
View File

@ -0,0 +1,27 @@
"""
A simple Gemini server that echos back the request to the client.
"""
import asyncio
import typing
import jetforce
def echo(environ: dict, send_status: typing.Callable) -> typing.Iterator[bytes]:
url = environ["URL"]
send_status(jetforce.STATUS_SUCCESS, "text/gemini")
yield f"Received path: {url}".encode()
if __name__ == "__main__":
parser = jetforce.build_argument_parser()
args = parser.parse_args()
server = jetforce.GeminiServer(
host=args.host,
port=args.port,
certfile=args.certfile,
keyfile=args.keyfile,
hostname=args.hostname,
app=echo,
)
asyncio.run(server.run())

View File

@ -0,0 +1,89 @@
"""
A guestbook application that accepts user messages using the INPUT response type
and stores messages in a simple SQLite database file.
"""
import asyncio
import sqlite3
import typing
import urllib.parse
from datetime import datetime
import jetforce
class GuestbookApplication(jetforce.BaseApplication):
db_file = "guestbook.sql"
def connect_db(self) -> typing.Tuple[sqlite3.Connection, sqlite3.Cursor]:
db = sqlite3.connect(self.db_file, detect_types=sqlite3.PARSE_DECLTYPES)
cursor = db.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS guestbook (
id int PRIMARY KEY,
message text,
created timestamp,
ip_address text);
"""
)
db.commit()
return db, cursor
def __call__(
self, environ: dict, send_status: typing.Callable
) -> typing.Iterator[bytes]:
url = environ["URL"]
url_parts = urllib.parse.urlparse(url)
error_message = self.block_proxy_requests(url, environ["HOSTNAME"])
if error_message:
return send_status(jetforce.STATUS_PROXY_REQUEST_REFUSED, error_message)
if url_parts.path in ("", "/"):
send_status(jetforce.STATUS_SUCCESS, "text/gemini")
yield from self.list_messages()
elif url_parts.path == "/submit":
if url_parts.query:
self.save_message(url_parts.query, environ["REMOTE_ADDR"])
return send_status(jetforce.STATUS_REDIRECT_TEMPORARY, "/")
else:
return send_status(
jetforce.STATUS_INPUT, "Enter your message (max 256 characters)"
)
else:
return send_status(jetforce.STATUS_NOT_FOUND, "Invalid address")
def save_message(self, message: str, ip_address: str) -> None:
message = message[:256]
created = datetime.utcnow()
db, cursor = self.connect_db()
sql = "INSERT INTO guestbook(message, created, ip_address) VALUES (?, ?, ?)"
cursor.execute(sql, (message, created, ip_address))
db.commit()
def list_messages(self) -> typing.Iterator[bytes]:
yield "Guestbook\n=>/submit Leave a Message\n".encode()
db, cursor = self.connect_db()
cursor.execute("SELECT created, message FROM guestbook ORDER BY created DESC")
for row in cursor.fetchall():
yield f"\n[{row[0]:%Y-%m-%d %I:%M %p}]\n{row[1]}\n".encode()
yield "\n...\n".encode()
if __name__ == "__main__":
parser = jetforce.build_argument_parser()
args = parser.parse_args()
app = GuestbookApplication()
server = jetforce.GeminiServer(
host=args.host,
port=args.port,
certfile=args.certfile,
keyfile=args.keyfile,
hostname=args.hostname,
app=app,
)
asyncio.run(server.run())

View File

@ -0,0 +1,49 @@
"""
This is an example of setting up a Gemini server to proxy requests to other
protocols. This application will accept HTTP URLs, download and render them
locally using the `w3m` tool, and render the output to the client as plain text.
"""
import asyncio
import subprocess
import typing
import urllib.parse
import jetforce
class HTTPProxyApplication(jetforce.BaseApplication):
command = [b"w3m", b"-dump"]
def __call__(
self, environ: dict, send_status: typing.Callable
) -> typing.Iterator[bytes]:
url = environ["URL"]
url_parts = urllib.parse.urlparse(url)
if url_parts.scheme not in ("http", "https"):
return send_status(jetforce.STATUS_NOT_FOUND, "Invalid Resource")
try:
command = self.command + [url.encode()]
out = subprocess.run(command, stdout=subprocess.PIPE)
out.check_returncode()
except Exception:
send_status(jetforce.STATUS_CGI_ERROR, "Failed to load URL")
else:
send_status(jetforce.STATUS_SUCCESS, "text/plain")
yield out.stdout
if __name__ == "__main__":
parser = jetforce.build_argument_parser()
args = parser.parse_args()
app = HTTPProxyApplication()
server = jetforce.GeminiServer(
host=args.host,
port=args.port,
certfile=args.certfile,
keyfile=args.keyfile,
hostname=args.hostname,
app=app,
)
asyncio.run(server.run())

View File

@ -66,22 +66,37 @@ STATUS_FUTURE_CERTIFICATE_REJECTED = 64
STATUS_EXPIRED_CERTIFICATE_REJECTED = 65 STATUS_EXPIRED_CERTIFICATE_REJECTED = 65
class EchoApp: class BaseApplication:
""" """
A simple application that echos back the requested path. Base Jetforce application class, analogous to a WSGI app.
At a minimum you must implement the ``__call__()`` method, which will
perform the following tasks:
1. Send the response header by calling ``send_status()``.
2. Optionally, yield the response body as bytes.
""" """
def __init__(self, environ: dict, send_status: typing.Callable) -> None: def __call__(
self.environ = environ self, environ: dict, send_status: typing.Callable
self.send_status = send_status ) -> typing.Iterator[bytes]:
raise NotImplemented
def __iter__(self) -> typing.Iterator[bytes]: @staticmethod
self.send_status(STATUS_SUCCESS, "text/plain") def block_proxy_requests(url: str, hostname: str) -> typing.Optional[str]:
url = self.environ["RAW_URL"] """
yield f"Received path: {url}".encode() Optional method that may be used to restrict request URLs that do not
match your current host and the gemini:// scheme. This may be used if
you don't want to worry about proxying traffic to other servers.
"""
url_parts = urllib.parse.urlparse(url)
if url_parts.scheme and url_parts.scheme != "gemini":
return 'URL scheme must be "gemini://"'
if url_parts.hostname and url_parts.hostname != hostname:
return f'URL hostname must be "{hostname}"'
class StaticDirectoryApp: class StaticDirectoryApplication(BaseApplication):
""" """
Serve a static directory over Gemini. Serve a static directory over Gemini.
@ -90,50 +105,46 @@ class StaticDirectoryApp:
directory listing will be auto-generated. directory listing will be auto-generated.
""" """
def __init__(self, root: str, environ: dict, send_status: typing.Callable) -> None: def __init__(self, directory="/var/gemini"):
self.root = pathlib.Path(root).resolve(strict=True) self.root = pathlib.Path(directory).resolve(strict=True)
self.environ = environ
self.send_status = send_status
self.mimetypes = mimetypes.MimeTypes() self.mimetypes = mimetypes.MimeTypes()
@classmethod def __call__(
def serve_directory(cls, root: str) -> typing.Callable: self, environ: dict, send_status: typing.Callable
""" ) -> typing.Iterator[bytes]:
Return an app that points to the given root directory on the file system. url = environ["URL"]
""" url_parts = urllib.parse.urlparse(url)
url_path = pathlib.Path(url_parts.path.strip("/"))
def build_class(environ: dict, send_status: typing.Callable): error_message = self.block_proxy_requests(url, environ["HOSTNAME"])
return cls(root, environ, send_status) if error_message:
return send_status(STATUS_PROXY_REQUEST_REFUSED, error_message)
return build_class
def __iter__(self) -> typing.Iterator[bytes]:
url_path = pathlib.Path(self.environ["URL"].path.strip("/"))
filename = pathlib.Path(os.path.normpath(str(url_path))) filename = pathlib.Path(os.path.normpath(str(url_path)))
if filename.is_absolute() or str(filename.name).startswith(".."): if filename.is_absolute() or str(filename.name).startswith(".."):
# Guard against breaking out of the directory # Guard against breaking out of the directory
self.send_status(STATUS_NOT_FOUND, "Not Found") return send_status(STATUS_NOT_FOUND, "Not Found")
return
else:
filesystem_path = self.root / filename filesystem_path = self.root / filename
if filesystem_path.is_file(): if filesystem_path.is_file():
mimetype = self.guess_mimetype(filesystem_path.name) mimetype = self.guess_mimetype(filesystem_path.name)
yield from self.load_file(filesystem_path, mimetype) send_status(STATUS_SUCCESS, mimetype)
yield from self.load_file(filesystem_path)
elif filesystem_path.is_dir(): elif filesystem_path.is_dir():
gemini_file = filesystem_path / ".gemini" gemini_file = filesystem_path / ".gemini"
if gemini_file.exists(): if gemini_file.exists():
yield from self.load_file(gemini_file, "text/gemini") send_status(STATUS_SUCCESS, "text/gemini")
yield from self.load_file(gemini_file)
else: else:
send_status(STATUS_SUCCESS, "text/gemini")
yield from self.list_directory(url_path, filesystem_path) yield from self.list_directory(url_path, filesystem_path)
else: else:
self.send_status(STATUS_NOT_FOUND, "Not Found") return send_status(STATUS_NOT_FOUND, "Not Found")
def load_file(self, filesystem_path: pathlib.Path, mimetype: str): def load_file(self, filesystem_path: pathlib.Path):
self.send_status(STATUS_SUCCESS, mimetype)
with filesystem_path.open("rb") as fp: with filesystem_path.open("rb") as fp:
data = fp.read(1024) data = fp.read(1024)
while data: while data:
@ -141,8 +152,6 @@ class StaticDirectoryApp:
data = fp.read(1024) data = fp.read(1024)
def list_directory(self, url_path: pathlib.Path, filesystem_path: pathlib.Path): def list_directory(self, url_path: pathlib.Path, filesystem_path: pathlib.Path):
self.send_status(STATUS_SUCCESS, "text/gemini")
yield f"Directory: /{url_path}\r\n".encode() yield f"Directory: /{url_path}\r\n".encode()
if url_path.parent != url_path: if url_path.parent != url_path:
yield f"=>/{url_path.parent}\t..\r\n".encode() yield f"=>/{url_path.parent}\t..\r\n".encode()
@ -184,7 +193,6 @@ class GeminiRequestHandler:
self.writer: typing.Optional[asyncio.StreamWriter] = None self.writer: typing.Optional[asyncio.StreamWriter] = None
self.received_timestamp: typing.Optional[datetime.datetime] = None self.received_timestamp: typing.Optional[datetime.datetime] = None
self.remote_addr: typing.Optional[str] = None self.remote_addr: typing.Optional[str] = None
self.raw_url: typing.Optional[str] = None
self.url: typing.Optional[urllib.parse.ParseResult] = None self.url: typing.Optional[urllib.parse.ParseResult] = None
self.status: typing.Optional[int] = None self.status: typing.Optional[int] = None
self.meta: typing.Optional[str] = None self.meta: typing.Optional[str] = None
@ -213,19 +221,6 @@ class GeminiRequestHandler:
self.write_status(STATUS_BAD_REQUEST, "Could not understand request line") self.write_status(STATUS_BAD_REQUEST, "Could not understand request line")
return await self.close_connection() return await self.close_connection()
# Discard proxy requests, may revisit this in a later version
if self.url.scheme and self.url.scheme != "gemini":
self.write_status(
STATUS_PROXY_REQUEST_REFUSED, 'URL scheme must be "gemini://"'
)
return await self.close_connection()
elif self.url.hostname and self.url.hostname != self.server.hostname:
self.write_status(
STATUS_PROXY_REQUEST_REFUSED,
f'URL hostname must be "{self.server.hostname}"',
)
return await self.close_connection()
try: try:
environ = self.build_environ() environ = self.build_environ()
app = self.app(environ, self.write_status) app = self.app(environ, self.write_status)
@ -246,7 +241,6 @@ class GeminiRequestHandler:
"SERVER_PORT": self.server.port, "SERVER_PORT": self.server.port,
"REMOTE_ADDR": self.remote_addr, "REMOTE_ADDR": self.remote_addr,
"HOSTNAME": self.server.hostname, "HOSTNAME": self.server.hostname,
"RAW_URL": self.raw_url,
"URL": self.url, "URL": self.url,
} }
@ -261,12 +255,7 @@ class GeminiRequestHandler:
if len(data) > 1024: if len(data) > 1024:
raise ValueError("URL exceeds max length of 1024 bytes") raise ValueError("URL exceeds max length of 1024 bytes")
self.raw_url = data.decode() self.url = data.decode()
self.url = urllib.parse.urlparse(self.raw_url)
if not self.url.netloc:
# URL does not contain a scheme and was not prefixed with // per RFC 1808
# TODO: Suggest spec should enforce // when scheme is omitted
self.url = urllib.parse.urlparse(f"//{self.raw_url}")
def write_status(self, status: int, meta: str) -> None: def write_status(self, status: int, meta: str) -> None:
""" """
@ -323,7 +312,7 @@ class GeminiRequestHandler:
self.server.log_message( self.server.log_message(
f"{self.remote_addr} " f"{self.remote_addr} "
f"[{self.received_timestamp:%d/%b/%Y:%H:%M:%S +0000}] " f"[{self.received_timestamp:%d/%b/%Y:%H:%M:%S +0000}] "
f'"{self.raw_url}" ' f'"{self.url}" '
f"{self.status} " f"{self.status} "
f'"{self.meta}" ' f'"{self.meta}" '
f"{self.response_size}" f"{self.response_size}"
@ -341,16 +330,25 @@ class GeminiServer:
self, self,
host: str, host: str,
port: int, port: int,
ssl_context: ssl.SSLContext, certfile: typing.Optional[str],
keyfile: typing.Optional[str],
hostname: str, hostname: str,
app: typing.Callable, app: typing.Callable,
) -> None: ) -> None:
self.host = host self.host = host
self.port = port self.port = port
self.ssl_context = ssl_context
self.hostname = hostname self.hostname = hostname
self.app = app self.app = app
if not certfile:
certfile, keyfile = self.generate_tls_certificate(hostname)
self.ssl_context = ssl.SSLContext()
self.ssl_context.verify_mode = ssl.CERT_NONE
self.ssl_context.check_hostname = False
self.ssl_context.load_cert_chain(certfile, keyfile)
async def run(self) -> None: async def run(self) -> None:
""" """
The main asynchronous server loop. The main asynchronous server loop.
@ -385,8 +383,8 @@ class GeminiServer:
""" """
print(message, file=sys.stderr) print(message, file=sys.stderr)
@staticmethod
def generate_tls_certificate(hostname: str) -> typing.Tuple[str, str]: def generate_tls_certificate(hostname: str) -> typing.Tuple[str, str]:
""" """
Utility function to generate a self-signed SSL certificate key pair if Utility function to generate a self-signed SSL certificate key pair if
one isn't provided. Results may vary depending on your version of OpenSSL. one isn't provided. Results may vary depending on your version of OpenSSL.
@ -406,9 +404,10 @@ def generate_tls_certificate(hostname: str) -> typing.Tuple[str, str]:
return str(certfile), str(keyfile) return str(certfile), str(keyfile)
def run_server() -> None: def build_argument_parser() -> argparse.ArgumentParser:
""" """
Entry point for running the command line directory server. Construct the default argument parser when launching the server from
the command line.
""" """
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="jetforce", prog="jetforce",
@ -418,25 +417,32 @@ def run_server() -> None:
) )
parser.add_argument("--host", help="Address to bind server to", default="127.0.0.1") parser.add_argument("--host", help="Address to bind server to", default="127.0.0.1")
parser.add_argument("--port", help="Port to bind server to", type=int, default=1965) parser.add_argument("--port", help="Port to bind server to", type=int, default=1965)
parser.add_argument("--tls-certfile", help="TLS certificate file", metavar="FILE") parser.add_argument(
parser.add_argument("--tls-keyfile", help="TLS private key file", metavar="FILE") "--tls-certfile", dest="certfile", help="TLS certificate file", metavar="FILE"
)
parser.add_argument(
"--tls-keyfile", dest="keyfile", help="TLS private key file", metavar="FILE"
)
parser.add_argument("--hostname", help="Server hostname", default="localhost") parser.add_argument("--hostname", help="Server hostname", default="localhost")
return parser
def run_server() -> None:
"""
Entry point for running the command line static directory server.
"""
parser = build_argument_parser()
parser.add_argument("--dir", help="local directory to serve", default="/var/gemini") parser.add_argument("--dir", help="local directory to serve", default="/var/gemini")
args = parser.parse_args() args = parser.parse_args()
certfile, keyfile = args.tls_certfile, args.tls_keyfile app = StaticDirectoryApplication(args.dir)
if not certfile:
certfile, keyfile = generate_tls_certificate(args.hostname)
ssl_context = ssl.SSLContext()
ssl_context.load_cert_chain(certfile, keyfile)
server = GeminiServer( server = GeminiServer(
host=args.host, host=args.host,
port=args.port, port=args.port,
ssl_context=ssl_context, certfile=args.certfile,
keyfile=args.keyfile,
hostname=args.hostname, hostname=args.hostname,
app=StaticDirectoryApp.serve_directory(args.dir), app=app,
) )
asyncio.run(server.run()) asyncio.run(server.run())

View File

@ -41,7 +41,13 @@ def run_client():
parser.add_argument( parser.add_argument(
"--port", help="Optional port to connect to, will default to the URL" "--port", help="Optional port to connect to, will default to the URL"
) )
parser.add_argument("--certfile", help="Optional client certificate")
parser.add_argument("--keyfile", help="Optional client key")
args = parser.parse_args() args = parser.parse_args()
if args.certfile:
context.load_cert_chain(args.certfile, args.keyfile)
fetch(args.url, args.host, args.port) fetch(args.url, args.host, args.port)