This commit is contained in:
2021-03-26 18:25:03 +01:00
parent 7476870acc
commit fdbd865889
11 changed files with 297 additions and 136 deletions

View File

@@ -6,7 +6,7 @@ from urllib.parse import urlparse
from client.httpclient import FORMAT, HTTPClient from client.httpclient import FORMAT, HTTPClient
from httplib import parser from httplib import parser
from httplib.exceptions import InvalidResponse, InvalidStatusLine, UnsupportedEncoding from httplib.exceptions import InvalidResponse, InvalidStatusLine, UnsupportedEncoding
from httplib.message import Message from httplib.message import ClientMessage as Message
from httplib.retriever import PreambleRetriever from httplib.retriever import PreambleRetriever
sockets: Dict[str, HTTPClient] = {} sockets: Dict[str, HTTPClient] = {}
@@ -29,12 +29,12 @@ class AbstractCommand(ABC):
uri: str uri: str
host: str host: str
path: str path: str
port: Tuple[str, int] port: int
def __init__(self, uri: str, port): def __init__(self, uri: str, port):
self.uri = uri self.uri = uri
self.host, _, self.path = parser.parse_uri(uri) self.host, _, self.path = parser.parse_uri(uri)
self.port = port self.port = int(port)
@property @property
@abstractmethod @abstractmethod

View File

@@ -10,7 +10,7 @@ from client.command import AbstractCommand, GetCommand
from client.httpclient import HTTPClient, FORMAT from client.httpclient import HTTPClient, FORMAT
from httplib import parser from httplib import parser
from httplib.exceptions import InvalidResponse from httplib.exceptions import InvalidResponse
from httplib.message import Message from httplib.message import ClientMessage as Message
from httplib.retriever import Retriever from httplib.retriever import Retriever

View File

@@ -33,6 +33,10 @@ class HTTPServerException(Exception):
""" Base class for HTTP Server exceptions """ """ Base class for HTTP Server exceptions """
status_code: str status_code: str
message: str message: str
body: str
def __init__(self, body=""):
self.body = body
class BadRequest(HTTPServerException): class BadRequest(HTTPServerException):
@@ -66,3 +70,28 @@ class NotFound(HTTPServerException):
""" Resource not found """ """ Resource not found """
status_code = 404 status_code = 404
message = "Not Found" message = "Not Found"
class Forbidden(HTTPServerException):
""" Request not allowed """
status_code = 403
message = "Forbidden"
class Conflict(HTTPServerException):
""" Conflict in the current state of the target resource """
status_code = 409
message = "Conflict"
class HTTPVersionNotSupported(HTTPServerException):
""" The server does not support the major version HTTP used in the request message """
status_code = 505
message = "HTTP Version Not Supported"
class InvalidRequestLine(BadRequest):
""" Request start-line is invalid """
def __init__(self, line):
self.request_line = line

View File

@@ -3,6 +3,8 @@ import socket
from io import BufferedReader from io import BufferedReader
from typing import Tuple from typing import Tuple
from httplib.exceptions import BadRequest
BUFSIZE = 4096 BUFSIZE = 4096
TIMEOUT = 3 TIMEOUT = 3
FORMAT = "UTF-8" FORMAT = "UTF-8"
@@ -61,17 +63,28 @@ class HTTPSocket:
def read(self, size=BUFSIZE, blocking=True) -> bytes: def read(self, size=BUFSIZE, blocking=True) -> bytes:
if blocking: if blocking:
return self.file.read(size) buffer = self.file.read(size)
else:
buffer = self.file.read1(size)
return self.file.read1(size) if len(buffer) == 0:
raise ConnectionAbortedError
return buffer
def read_line(self): def read_line(self):
return str(self.read_bytes_line(), FORMAT) try:
line = str(self.read_bytes_line(), FORMAT)
except UnicodeDecodeError:
# Expected UTF-8
raise BadRequest()
return line
def read_bytes_line(self) -> bytes: def read_bytes_line(self) -> bytes:
line = self.file.readline(MAXLINE + 1) line = self.file.readline(MAXLINE + 1)
if len(line) > MAXLINE: if len(line) > MAXLINE:
raise InvalidResponse("Line too long") raise InvalidResponse("Line too long")
elif len(line) == 0:
raise ConnectionAbortedError
return line return line

View File

@@ -1,18 +1,35 @@
from abc import ABC
from typing import Dict from typing import Dict
from urllib.parse import SplitResult
class Message: class Message(ABC):
version: str version: str
status: int
msg: str
headers: Dict[str, str] headers: Dict[str, str]
raw: str raw: str
body: bytes body: bytes
def __init__(self, version: str, status: int, msg: str, headers: Dict[str, str], raw=None, body: bytes = None): def __init__(self, version: str, headers: Dict[str, str], raw=None, body: bytes = None):
self.version = version self.version = version
self.status = status
self.msg = msg
self.headers = headers self.headers = headers
self.raw = raw self.raw = raw
self.body = body self.body = body
class ClientMessage(Message):
status: int
msg: str
def __init__(self, version: str, status: int, msg: str, headers: Dict[str, str], raw=None, body: bytes = None):
super().__init__(version, headers, raw, body)
self.status = status
class ServerMessage(Message):
method: str
target: SplitResult
def __init__(self, version: str, method: str, target, headers: Dict[str, str], raw=None, body: bytes = None):
super().__init__(version, headers, raw, body)
self.method = method
self.target = target

View File

@@ -3,7 +3,7 @@ import os.path
import re import re
from urllib.parse import urlparse, urlsplit from urllib.parse import urlparse, urlsplit
from httplib.exceptions import InvalidStatusLine, InvalidResponse, BadRequest from httplib.exceptions import InvalidStatusLine, InvalidResponse, BadRequest, InvalidRequestLine
from httplib.httpsocket import HTTPSocket from httplib.httpsocket import HTTPSocket
@@ -48,7 +48,7 @@ def parse_status_line(line: str):
if len(split) < 3: if len(split) < 3:
raise InvalidStatusLine(line) # TODO fix exception raise InvalidStatusLine(line) # TODO fix exception
(http_version, status, reason) = split http_version, status, reason = split
if not _is_valid_http_version(http_version): if not _is_valid_http_version(http_version):
raise InvalidStatusLine(line) raise InvalidStatusLine(line)
@@ -63,11 +63,12 @@ def parse_status_line(line: str):
return version, status, reason return version, status, reason
def parse_request_line(client: HTTPSocket): def parse_request_line(line: str):
line, (method, target, version) = _get_start_line(client) split = list(filter(None, line.rstrip().split(" ", 2)))
if len(split) < 3:
logging.debug("Parsed request-line=%r, method=%r, target=%r, version=%r", line, method, target, version) raise InvalidRequestLine(line)
method, target, version = split
if method not in ("CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT", "TRACE"): if method not in ("CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT", "TRACE"):
raise BadRequest() raise BadRequest()
@@ -146,7 +147,7 @@ def parse_request_headers(client: HTTPSocket):
def get_headers(client: HTTPSocket): def get_headers(client: HTTPSocket):
headers = [] headers = []
# first header after the status-line may not contain a space # first header after the status-line may not start with a space
while True: while True:
line = client.read_line() line = client.read_line()
if line[0].isspace(): if line[0].isspace():
@@ -181,21 +182,28 @@ def get_headers(client: HTTPSocket):
def parse_headers(lines): def parse_headers(lines):
headers = [] headers = []
# first header after the status-line may not contain a space
for line in lines:
if line[0].isspace():
continue
else:
break
for line in lines: try:
if line in ("\r\n", "\n", " "): # first header after the start-line may not start with a space
break line = next(lines)
while True:
if line[0].isspace():
continue
else:
break
if line[0].isspace(): while True:
headers[-1] = headers[-1].rstrip("\r\n") if line in ("\r\n", "\n", ""):
break
headers.append(line.lstrip()) if line[0].isspace():
headers[-1] = headers[-1].rstrip("\r\n")
headers.append(line.lstrip())
line = next(lines)
except StopIteration:
# No more lines to be parsed
pass
result = {} result = {}
header_str = "".join(headers) header_str = "".join(headers)

View File

@@ -44,25 +44,36 @@ class Retriever(ABC):
class PreambleRetriever(Retriever): class PreambleRetriever(Retriever):
client: HTTPSocket client: HTTPSocket
buffer: [] _buffer: []
@property
def buffer(self):
tmp_buffer = self._buffer
self._buffer = []
return tmp_buffer
def __init__(self, client: HTTPSocket): def __init__(self, client: HTTPSocket):
super().__init__(client) super().__init__(client)
self.client = client self.client = client
self.buffer = [] self._buffer = []
def retrieve(self): def retrieve(self):
line = self.client.read_line() line = self.client.read_line()
while True: while True:
self.buffer.append(line) self._buffer.append(line)
if line in ("\r\n", "\n", ""): if line in ("\r\n", "\n", ""):
break return line
yield line yield line
line = self.client.read_line() line = self.client.read_line()
def reset_buffer(self, line):
self._buffer.clear()
self._buffer.append(line)
class ContentLengthRetriever(Retriever): class ContentLengthRetriever(Retriever):
length: int length: int

View File

@@ -1,25 +1,39 @@
import logging import mimetypes
import os
import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, Tuple from datetime import datetime
from urllib.parse import urlparse from time import mktime
from typing import Dict
from wsgiref.handlers import format_date_time
from client.httpclient import FORMAT, HTTPClient from client.httpclient import FORMAT
from httplib import parser from httplib.exceptions import NotFound, Conflict, Forbidden
from httplib.exceptions import InvalidResponse, InvalidStatusLine, UnsupportedEncoding from httplib.message import ServerMessage as Message
from httplib.message import Message
from httplib.retriever import PreambleRetriever root = os.path.join(os.path.dirname(sys.argv[0]), "public")
status_message = {
200: "OK",
201: "Created",
202: "Accepted",
304: "Not Modified",
400: "Bad Request",
404: "Not Found",
500: "Internal Server Error",
}
def create(method: str, message: Message): def create(message: Message):
if message.method == "GET":
if method == "GET": return GetCommand(message)
return GetCommand(url, port) elif message.method == "HEAD":
elif method == "HEAD": return HeadCommand(message)
return HeadCommand(url, port) elif message.method == "POST":
elif method == "POST": return PostCommand(message)
return PostCommand(url, port) elif message.method == "PUT":
elif method == "PUT": return PutCommand(message)
return PutCommand(url, port)
else: else:
raise ValueError() raise ValueError()
@@ -27,8 +41,10 @@ def create(method: str, message: Message):
class AbstractCommand(ABC): class AbstractCommand(ABC):
path: str path: str
headers: Dict[str, str] headers: Dict[str, str]
msg: Message
def __init(self): def __init__(self, message: Message):
self.msg = message
pass pass
@property @property
@@ -36,63 +52,133 @@ class AbstractCommand(ABC):
def command(self): def command(self):
pass pass
def _get_date(self):
now = datetime.now()
stamp = mktime(now.timetuple())
return format_date_time(stamp)
class AbstractWithBodyCommand(AbstractCommand, ABC): @abstractmethod
def execute(self):
pass
def _build_message(self, message: str) -> bytes: def _build_message(self, status: int, content_type: str, body: bytes):
body = input(f"Enter {self.command} data: ").encode(FORMAT) message = f"HTTP/1.1 {status} {status_message[status]}\r\n"
print() message += self._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 += "; charset=UTF-8"
message += "\r\n"
elif content_length > 0:
message += f"Content-Type: application/octet-stream"
message += "Content-Type: text/plain\r\n"
message += f"Content-Length: {len(body)}\r\n"
message += "\r\n" message += "\r\n"
message = message.encode(FORMAT) message = message.encode(FORMAT)
message += body if content_length > 0:
message += b"\r\n" message += body
message += b"\r\n"
return message return message
def _get_path(self, check=True):
norm_path = os.path.normpath(self.msg.target.path)
if norm_path == "/":
path = root + "/index.html"
else:
path = root + norm_path
if check and not os.path.exists(path):
raise NotFound()
return path
class AbstractModifyCommand(AbstractCommand, ABC):
@property
@abstractmethod
def _file_mode(self):
pass
def execute(self):
path = self._get_path(False)
dir = os.path.dirname(path)
if not os.path.exists(dir):
raise Forbidden("Target directory does not exists!")
if os.path.exists(dir) and not os.path.isdir(dir):
raise Forbidden("Target directory is an existing file!")
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!")
class HeadCommand(AbstractCommand): class HeadCommand(AbstractCommand):
def execute(self):
path = self._get_path()
mime = mimetypes.guess_type(path)[0]
return self._build_message(200, mime, b"")
@property @property
def command(self): def command(self):
return "HEAD" return "HEAD"
class GetCommand(AbstractCommand): class GetCommand(AbstractCommand):
def __init__(self, uri: str, port, dir=None):
super().__init__(uri, port)
self.dir = dir
self.filename = None
@property @property
def command(self): def command(self):
return "GET" return "GET"
def _get_preamble(self, retriever): def get_mimetype(self, path):
lines = retriever.retrieve() mime = mimetypes.guess_type(path)[0]
(version, status, msg) = parser.parse_status_line(next(lines))
headers = parser.parse_headers(lines)
logging.debug("---response begin---\r\n%s--- response end---", "".join(retriever.buffer)) if mime:
return mime
return Message(version, status, msg, headers, retriever.buffer) try:
file = open(path, "r", encoding="utf-8")
file.readline()
file.close()
return "text/plain"
except UnicodeDecodeError:
return "application/octet-stream"
def _await_response(self, client, retriever): def execute(self):
msg = self._get_preamble(retriever) path = self._get_path()
mime = self.get_mimetype(path)
from client import response_handler file = open(path, "rb")
self.filename = response_handler.handle(client, msg, self, self.dir) buffer = file.read()
file.close()
return self._build_message(200, mime, buffer)
class PostCommand(AbstractWithBodyCommand): class PostCommand(AbstractModifyCommand):
@property @property
def command(self): def command(self):
return "POST" return "POST"
@property
def _file_mode(self):
return "a"
class PutCommand(AbstractWithBodyCommand):
class PutCommand(AbstractModifyCommand):
@property @property
def command(self): def command(self):
return "PUT" return "PUT"
@property
def _file_mode(self):
return "w"

View File

@@ -37,7 +37,6 @@ class HTTPServer:
def __do_start(self): def __do_start(self):
# Create socket # Create socket
self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server.bind((self.address, self.port)) self.server.bind((self.address, self.port))
@@ -56,6 +55,8 @@ class HTTPServer:
continue continue
conn, addr = self.server.accept() conn, addr = self.server.accept()
conn.settimeout(5)
logging.info("New connection: %s", addr[0]) logging.info("New connection: %s", addr[0])
self._dispatch_queue.put((conn, addr)) self._dispatch_queue.put((conn, addr))
logging.debug("Dispatched connection %s", addr) logging.debug("Dispatched connection %s", addr)

View File

@@ -1,7 +1,7 @@
import logging import logging
import mimetypes
import os import os
import sys import sys
import time
from datetime import datetime from datetime import datetime
from socket import socket from socket import socket
from time import mktime from time import mktime
@@ -10,9 +10,12 @@ from urllib.parse import ParseResultBytes, ParseResult
from wsgiref.handlers import format_date_time from wsgiref.handlers import format_date_time
from httplib import parser from httplib import parser
from httplib.exceptions import MethodNotAllowed, BadRequest, UnsupportedEncoding, NotImplemented, NotFound from httplib.exceptions import MethodNotAllowed, BadRequest, UnsupportedEncoding, NotImplemented, NotFound, \
HTTPVersionNotSupported
from httplib.httpsocket import HTTPSocket, FORMAT from httplib.httpsocket import HTTPSocket, FORMAT
from httplib.retriever import Retriever from httplib.message import ServerMessage as Message
from httplib.retriever import Retriever, PreambleRetriever
from server import command
METHODS = ("GET", "HEAD", "PUT", "POST") METHODS = ("GET", "HEAD", "PUT", "POST")
@@ -25,13 +28,28 @@ class RequestHandler:
self.conn = HTTPSocket(conn, host) self.conn = HTTPSocket(conn, host)
def listen(self): def listen(self):
logging.debug("Parsing request line")
(method, target, version) = parser.parse_request_line(self.conn)
headers = parser.parse_request_headers(self.conn)
self._validate_request(method, target, version, headers) retriever = PreambleRetriever(self.conn)
logging.debug("Parsed request-line: method: %s, target: %r", method, target) while True:
line = self.conn.read_line()
if line in ("\r\n", "\r", "\n"):
continue
retriever.reset_buffer(line)
self._handle_message(retriever, line)
def _handle_message(self, retriever, line):
lines = retriever.retrieve()
(method, target, version) = parser.parse_request_line(line)
headers = parser.parse_headers(lines)
message = Message(version, method, target, headers, retriever.buffer)
logging.debug("---request begin---\r\n%s---request end---", "".join(message.raw))
self._validate_request(message)
body = b"" body = b""
if self._has_body(headers): if self._has_body(headers):
@@ -44,8 +62,13 @@ class RequestHandler:
for buffer in retriever.retrieve(): for buffer in retriever.retrieve():
body += buffer body += buffer
message.body = body
# completed message # completed message
self._handle_message(method, target.path, body)
cmd = command.create(message)
msg = cmd.execute()
self.conn.conn.sendall(msg)
def _check_request_line(self, method: str, target: Union[ParseResultBytes, ParseResult], version): def _check_request_line(self, method: str, target: Union[ParseResultBytes, ParseResult], version):
@@ -53,30 +76,34 @@ class RequestHandler:
raise MethodNotAllowed(METHODS) raise MethodNotAllowed(METHODS)
if version not in ("1.0", "1.1"): if version not in ("1.0", "1.1"):
raise BadRequest() raise HTTPVersionNotSupported()
# only origin-form and absolute-form are allowed # only origin-form and absolute-form are allowed
if target.scheme not in ("", "http"): if target.scheme not in ("", "http"):
# Only http is supported... # Only http is supported...
raise BadRequest() raise BadRequest()
if target.netloc != "" and target.netloc != self.conn.host and target.netloc != self.conn.host.split(":")[0]: if target.netloc != "" and target.netloc != self.conn.host and target.netloc != self.conn.host.split(":")[0]:
raise NotFound() raise NotFound()
if target.path == "" or target.path[0] != "/": if target.path == "" or target.path[0] != "/":
raise NotFound() raise NotFound()
norm_path = os.path.normpath(target.path) def _validate_request(self, msg):
if not os.path.exists(self.root + norm_path): if msg.version == "1.1" and "host" not in msg.headers:
raise NotFound()
def _validate_request(self, method, target, version, headers):
if version == "1.1" and "host" not in headers:
raise BadRequest() raise BadRequest()
self._check_request_line(method, target, version) self._check_request_line(msg.method, msg.target, msg.version)
def _has_body(self, headers): def _has_body(self, headers):
return "transfer-encoding" in headers or "content-encoding" in headers
if "transfer-encoding" in headers:
return True
if "content-length" in headers and int(headers["content-length"]) > 0:
return True
return False
@staticmethod @staticmethod
def _get_date(): def _get_date():
@@ -84,38 +111,6 @@ class RequestHandler:
stamp = mktime(now.timetuple()) stamp = mktime(now.timetuple())
return format_date_time(stamp) return format_date_time(stamp)
def _handle_message(self, method: str, target, body: bytes):
date = self._get_date()
if method == "GET":
if target == "/":
path = self.root + "/index.html"
else:
path = self.root + target
mime = mimetypes.guess_type(path)[0]
if mime.startswith("text"):
file = open(path, "rb", FORMAT)
else:
file = open(path, "rb")
buffer = file.read()
file.close()
message = "HTTP/1.1 200 OK\r\n"
message += date + "\r\n"
if mime:
message += f"Content-Type: {mime}"
if mime.startswith("text"):
message += "; charset=UTF-8"
message += "\r\n"
message += f"Content-Length: {len(buffer)}\r\n"
message += "\r\n"
message = message.encode(FORMAT)
message += buffer
message += b"\r\n"
logging.debug("Sending: %r", message)
self.conn.conn.sendall(message)
@staticmethod @staticmethod
def send_error(client: socket, code, message): def send_error(client: socket, code, message):
message = f"HTTP/1.1 {code} {message}\r\n" message = f"HTTP/1.1 {code} {message}\r\n"

View File

@@ -5,7 +5,7 @@ import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from httplib.exceptions import HTTPServerException, InternalServerError from httplib.exceptions import HTTPServerException, InternalServerError
from server.RequestHandler import RequestHandler from server.requesthandler import RequestHandler
THREAD_LIMIT = 128 THREAD_LIMIT = 128
@@ -62,15 +62,16 @@ class Worker:
def _handle_client(self, conn: socket.socket, addr): def _handle_client(self, conn: socket.socket, addr):
try: try:
logging.debug("Handling client: %s", addr)
handler = RequestHandler(conn, self.host) handler = RequestHandler(conn, self.host)
handler.listen() handler.listen()
except HTTPServerException as e: except HTTPServerException as e:
logging.debug("HTTP Exception:", exc_info=e)
RequestHandler.send_error(conn, e.status_code, e.message) RequestHandler.send_error(conn, e.status_code, e.message)
except socket.timeout:
logging.debug("Socket for client %s timed out", addr)
except Exception as e: except Exception as e:
RequestHandler.send_error(conn, InternalServerError.status_code, InternalServerError.message)
logging.debug("Internal error", exc_info=e) logging.debug("Internal error", exc_info=e)
RequestHandler.send_error(conn, InternalServerError.status_code, InternalServerError.message)
conn.shutdown(socket.SHUT_RDWR) conn.shutdown(socket.SHUT_RDWR)
conn.close() conn.close()