Files
CN2021/server/command.py
2021-03-28 17:57:08 +02:00

307 lines
8.0 KiB
Python

import mimetypes
import os
import sys
from abc import ABC, abstractmethod
from datetime import datetime
from httplib import parser
from httplib.exceptions import NotFound, Forbidden, NotModified, BadRequest
from httplib.httpsocket import FORMAT
from httplib.message import RequestMessage as Message
CONTENT_ROOT = os.path.join(os.path.dirname(sys.argv[0]), "public")
status_message = {
200: "OK",
201: "Created",
202: "Accepted",
204: "No Content",
304: "Not Modified",
400: "Bad Request",
404: "Not Found",
500: "Internal Server Error",
}
def create(message: Message):
"""
Creates a Command based on the specified message
@param message: the message to create the Command with.
@return: An instance of `AbstractCommand`
"""
if message.method == "GET":
return GetCommand(message)
elif message.method == "HEAD":
return HeadCommand(message)
elif message.method == "POST":
return PostCommand(message)
elif message.method == "PUT":
return PutCommand(message)
else:
raise ValueError()
class AbstractCommand(ABC):
path: str
msg: Message
def __init__(self, message: Message):
self.msg = message
pass
@property
@abstractmethod
def command(self):
pass
@property
@abstractmethod
def _conditional_headers(self):
"""
The conditional headers specific to this command instance.
"""
pass
@abstractmethod
def execute(self):
"""
Execute the command
"""
pass
def _build_message(self, status: int, content_type: str, body: bytes, extra_headers=None):
"""
Build the response message.
@param status: The response status code
@param content_type: The response content-type header
@param body: The response body, may be empty.
@param extra_headers: Extra headers needed in the response message
@return: The encoded response message
"""
if extra_headers is None:
extra_headers = {}
self._process_conditional_headers()
message = f"HTTP/1.1 {status} {status_message[status]}\r\n"
message += f"Date: {parser.get_date()}\r\n"
content_length = len(body)
message += f"Content-Length: {content_length}\r\n"
if content_type:
message += f"Content-Type: {content_type}"
if content_type.startswith("text"):
message += f"; charset={FORMAT}"
message += "\r\n"
elif content_length > 0:
message += f"Content-Type: application/octet-stream\r\n"
for header in extra_headers:
message += f"{header}: {extra_headers[header]}\r\n"
message += "\r\n"
message = message.encode(FORMAT)
if content_length > 0:
message += body
return message
def _get_path(self, check=True):
"""
Returns the absolute file system path of the resource in the request.
@param check: If True, throws an error if the file doesn't exist
@raise NotFound: if `check` is True and the path doesn't exist
"""
norm_path = os.path.normpath(self.msg.target.path)
if norm_path == "/":
path = CONTENT_ROOT + "/index.html"
else:
path = CONTENT_ROOT + norm_path
if check and not os.path.exists(path):
raise NotFound(path)
return path
def _process_conditional_headers(self):
"""
Processes the conditional headers for this command instance.
"""
for header in self._conditional_headers:
tmp = self.msg.headers.get(header)
if not tmp:
continue
self._conditional_headers[header]()
def _if_modified_since(self):
"""
Processes the if-modified-since header.
@return: True if the header is invalid, and thus shouldn't be taken into account, throws NotModified
if the content isn't modified since the given date.
@raise NotModified: If the date of if-modified-since greater than the modify date of the resource.
"""
date_val = self.msg.headers.get("if-modified-since")
if not date_val:
return True
modified = datetime.utcfromtimestamp(os.path.getmtime(self._get_path(False)))
try:
min_date = datetime.strptime(date_val, '%a, %d %b %Y %H:%M:%S GMT')
except ValueError:
return True
if modified <= min_date:
raise NotModified(f"{modified} <= {min_date}")
return True
def get_mimetype(self, path):
"""
Guess the type of file.
@param path: the path to the file to guess the type of
@return: The mimetype based on the extension, or if that fails, returns "text/plain" if the file is text,
otherwise returns "application/octet-stream"
"""
mime = mimetypes.guess_type(path)[0]
if mime:
return mime
try:
file = open(path, "r", encoding=FORMAT)
file.readline()
file.close()
return "text/plain"
except UnicodeDecodeError:
return "application/octet-stream"
class AbstractModifyCommand(AbstractCommand, ABC):
"""
Base class for commands which modify a resource based on the request.
"""
@property
@abstractmethod
def _file_mode(self):
"""
The mode to open the target resource with. (e.a. 'a' or 'w')
"""
pass
@property
def _conditional_headers(self):
return {}
def execute(self):
path = self._get_path(False)
directory = os.path.dirname(path)
if not os.path.exists(directory):
raise Forbidden("Target directory does not exists!")
if os.path.exists(directory) and not os.path.isdir(directory):
raise Forbidden("Target directory is an existing file!")
exists = os.path.exists(path)
try:
with open(path, mode=f"{self._file_mode}b") as file:
file.write(self.msg.body)
except IsADirectoryError:
raise Forbidden("The target resource is a directory!")
if exists:
status = 204
else:
status = 201
location = parser.urljoin("/", os.path.relpath(path, CONTENT_ROOT))
return self._build_message(status, "text/plain", b"", {"Location": location})
class HeadCommand(AbstractCommand):
"""
A Command instance which represents an HEAD request
"""
@property
def command(self):
return "HEAD"
@property
def _conditional_headers(self):
return {'if-modified-since': self._if_modified_since}
def execute(self):
path = self._get_path()
mime = self.get_mimetype(path)
return self._build_message(200, mime, b"")
class GetCommand(AbstractCommand):
"""
A Command instance which represents a GET request
"""
@property
def command(self):
return "GET"
@property
def _conditional_headers(self):
return {'if-modified-since': self._if_modified_since}
def execute(self):
path = self._get_path()
mime = self.get_mimetype(path)
file = open(path, "rb")
buffer = file.read()
file.close()
return self._build_message(200, mime, buffer)
class PostCommand(AbstractModifyCommand):
"""
A Command instance which represents a POST request
"""
@property
def command(self):
return "POST"
@property
def _file_mode(self):
return "a"
class PutCommand(AbstractModifyCommand):
"""
A Command instance which represents a PUT request
"""
@property
def command(self):
return "PUT"
@property
def _file_mode(self):
return "w"
def execute(self):
if "content-range" in self.msg.headers:
raise BadRequest("PUT request contains Content-Range header")
super().execute()