From 5372ebb8be5a5e6669129b6dc78fa6f333b6e186 Mon Sep 17 00:00:00 2001 From: Camil Staps Date: Fri, 4 Mar 2016 23:09:53 +0100 Subject: Project 1: Config system; ETags; error pages --- project1/proj1_s4498062/content/error/403.html | 3 ++ project1/proj1_s4498062/content/error/404.html | 5 ++ project1/proj1_s4498062/webhttp/composer.py | 54 +++++++++++++++------- project1/proj1_s4498062/webhttp/config.py | 12 +++++ project1/proj1_s4498062/webhttp/message.py | 45 ++++++++++++++++-- project1/proj1_s4498062/webhttp/regexes.py | 2 + project1/proj1_s4498062/webhttp/resource.py | 20 ++++++-- project1/proj1_s4498062/webhttp/server.py | 63 +++++++++++++++----------- project1/proj1_s4498062/webserver.py | 19 ++++---- project1/proj1_s4498062/webtests.py | 31 ++++++++++++- 10 files changed, 195 insertions(+), 59 deletions(-) create mode 100644 project1/proj1_s4498062/content/error/403.html create mode 100644 project1/proj1_s4498062/content/error/404.html create mode 100644 project1/proj1_s4498062/webhttp/config.py diff --git a/project1/proj1_s4498062/content/error/403.html b/project1/proj1_s4498062/content/error/403.html new file mode 100644 index 0000000..0bd6083 --- /dev/null +++ b/project1/proj1_s4498062/content/error/403.html @@ -0,0 +1,3 @@ +

403

+ +
x0
diff --git a/project1/proj1_s4498062/content/error/404.html b/project1/proj1_s4498062/content/error/404.html new file mode 100644 index 0000000..c07ba10 --- /dev/null +++ b/project1/proj1_s4498062/content/error/404.html @@ -0,0 +1,5 @@ +

404

+ +
+ limx → ∞ cos(x) +
diff --git a/project1/proj1_s4498062/webhttp/composer.py b/project1/proj1_s4498062/webhttp/composer.py index 5123d31..f9bc0e0 100644 --- a/project1/proj1_s4498062/webhttp/composer.py +++ b/project1/proj1_s4498062/webhttp/composer.py @@ -4,11 +4,13 @@ This module contains a composer, which can compose responses to HTTP requests from a client. """ +import re import time -import webhttp.message -from webhttp.resource import Resource, FileExistError, FileAccessError -import webhttp.weblogging as logging +from config import config +from message import Response +from resource import Resource, FileExistError, FileAccessError +import weblogging as logging class ResponseComposer: """Class that composes a HTTP response to a HTTP request""" @@ -31,25 +33,43 @@ class ResponseComposer: webhttp.Response: response to request """ - response = webhttp.message.Response() + response = Response() - # Stub code + if re.search(r'\/\.\.?(\/|$)', request.uri): + response.code = 403 + response.set_header('Connection', 'close') + response.body = 'Don\'t even think about it.' + response.set_content_length() + return response + + return self.serve(request.uri, request=request) + + def serve(self, uri, code=200, etag=None, request=None, error_page=False): + response = Response() try: - resource = Resource(request.uri) - response.code = 200 - response.set_header('Content-Length', resource.get_content_length()) + resource = Resource(uri) + req = request + if req != None and ( + resource.etag_match(req.get_header('If-None-Match')) or \ + not resource.etag_match(req.get_header('If-Match') or '*')): + response.code = 304 + else: + response.code = code + response.body = resource.get_content() + response.set_header('ETag', resource.generate_etag()) response.set_header('Connection', 'close') - response.body = resource.get_content() + response.set_header('Content-Type', resource.get_content_type()) + response.set_content_length() except FileExistError: - response.code = 404 - response.set_header("Content-Length", 9) - response.set_header("Connection", "close") - response.body = "Not found" + if not error_page: + return self.serve(config('error404'), code=404, error_page=True) + else: + response.code = code + response.set_header('Connection', 'close') + response.body = 'Error %d' % code + response.set_content_length() except FileAccessError: - response.code = 403 - response.set_header("Content-Length", 13) - response.set_header("Connection", "close") - response.body = "Access denied" + return self.serve(config('error403'), code=403, error_page=True) return response diff --git a/project1/proj1_s4498062/webhttp/config.py b/project1/proj1_s4498062/webhttp/config.py new file mode 100644 index 0000000..35d73cb --- /dev/null +++ b/project1/proj1_s4498062/webhttp/config.py @@ -0,0 +1,12 @@ +from configparser import SafeConfigParser + +__all__ = ['config'] + +scp = SafeConfigParser() + +def config(option=None, section='webhttp', type=lambda x:x): + if option == None: + return scp + else: + return type(scp.get(section, option)) + diff --git a/project1/proj1_s4498062/webhttp/message.py b/project1/proj1_s4498062/webhttp/message.py index 2dc4240..059c765 100644 --- a/project1/proj1_s4498062/webhttp/message.py +++ b/project1/proj1_s4498062/webhttp/message.py @@ -9,12 +9,46 @@ import regexes as r import weblogging as logging reasondict = { - # Dictionary for code reasons - # Format: code : "Reason" + 100 : 'Continue', + 101 : 'Switching Protocols', 200 : 'OK', + 201 : 'Created', + 202 : 'Accepted', + 203 : 'Non-Authoritative Information', + 204 : 'No Content', + 205 : 'Reset Content', + 206 : 'Partial Content', + 300 : 'Multiple Choices', + 301 : 'Moved Permanently', + 302 : 'Found', + 303 : 'See Other', + 304 : 'Not Modified', + 305 : 'Use Proxy', + 307 : 'Temporary Redirect', + 400 : 'Bad Request', + 401 : 'Unauthorized', + 402 : 'Payment Required', 403 : 'Forbidden', 404 : 'Not Found', - 500 : 'Internal Server Error' + 405 : 'Method Not Allowed', + 406 : 'Not Acceptable', + 407 : 'Proxy Authentication Required', + 408 : 'Request Time-out', + 409 : 'Conflict', + 410 : 'Gone', + 411 : 'Length Required', + 412 : 'Precondition Failed', + 413 : 'Request Entity Too Large', + 414 : 'Request-URI Too Large', + 415 : 'Unsupported Media Type', + 416 : 'Requested range not satisfiable', + 417 : 'Expectation Failed', + 500 : 'Internal Server Error', + 501 : 'Not Implemented', + 502 : 'Bad Gateway', + 503 : 'Service Unavailable', + 504 : 'Gateway Time-out', + 505 : 'HTTP Version not supported', } @@ -48,7 +82,7 @@ class Message(object): if name in self.headers: return self.headers[name] else: - return "" + return None def parse_headers(self, msg): for name, value in re.findall(r.MessageHeader, msg): @@ -124,6 +158,9 @@ class Response(Message): def startline(self): return "%s %d %s" % (self.version, self.code, reasondict[self.code]) + + def set_content_length(self): + self.set_header('Content-Length', len(self.body)) def __str__(self): """Convert the Response to a string diff --git a/project1/proj1_s4498062/webhttp/regexes.py b/project1/proj1_s4498062/webhttp/regexes.py index 755bb9f..b1cacc9 100644 --- a/project1/proj1_s4498062/webhttp/regexes.py +++ b/project1/proj1_s4498062/webhttp/regexes.py @@ -149,3 +149,5 @@ FieldContent = regex_opt_r([TEXT_NO_LWS + TEXT + r'*(?!' + LWS + r')']) FieldValue = grp(regex_opt_r([grp(FieldContent), LWS]) + r'*') MessageHeader = grp(grpm(FieldName) + r':' + grpm(FieldValue)) +ETagSplit = grp(r',' + LWS + r'*') + diff --git a/project1/proj1_s4498062/webhttp/resource.py b/project1/proj1_s4498062/webhttp/resource.py index cf2b725..8fefc9c 100644 --- a/project1/proj1_s4498062/webhttp/resource.py +++ b/project1/proj1_s4498062/webhttp/resource.py @@ -3,10 +3,15 @@ This module contains a handler class for resources. """ -import os +import binascii +import hashlib import mimetypes +import os +import re import urlparse +from config import config +import regexes as r class FileExistError(Exception): """Exception which is raised when file does not exist""" @@ -37,7 +42,7 @@ class Resource: if not os.path.exists(self.path): raise FileExistError if os.path.isdir(self.path): - self.path = os.path.join(self.path, "index.html") + self.path = os.path.join(self.path, config('index')) if not os.path.exists(self.path): raise FileAccessError if not os.path.isfile(self.path): @@ -52,9 +57,18 @@ class Resource: str: ETag for the resource """ stat = os.stat(self.path) - etag = "" + m = hashlib.md5() + m.update(str(stat)) + etag = binascii.hexlify(m.digest()) return etag + def etag_match(self, etag): + if etag == None: + return False + my_etag = self.generate_etag() + return etag == '*' or \ + any([tag == my_etag for tag in re.split(r.ETagSplit, etag)]) + def get_content(self): """Get the contents of the resource diff --git a/project1/proj1_s4498062/webhttp/server.py b/project1/proj1_s4498062/webhttp/server.py index b385636..7eeec27 100644 --- a/project1/proj1_s4498062/webhttp/server.py +++ b/project1/proj1_s4498062/webhttp/server.py @@ -3,13 +3,13 @@ This module contains a HTTP server """ -from configparser import SafeConfigParser import threading import socket from composer import ResponseComposer from parser import RequestParser import weblogging as logging +from config import config class ConnectionHandler(threading.Thread): """Connection Handler for HTTP Server""" @@ -30,22 +30,28 @@ class ConnectionHandler(threading.Thread): def handle_connection(self): """Handle a new connection""" - rp = RequestParser() - rc = ResponseComposer(timeout=self.timeout) + self.rp = RequestParser() + self.rc = ResponseComposer(timeout=self.timeout) while True: data = self.conn_socket.recv(4096) if len(data) == 0: break - for req in rp.parse_requests(data): - logging.info("<-- %s" % req.startline()) - resp = rc.compose_response(req) - logging.info("--> %s" % resp.startline()) - sent = self.conn_socket.send(str(resp)) - if sent == 0: - raise RuntimeError('Socket broken') + self.handle_data(data) self.conn_socket.close() + + def handle_data(self, data): + for req in self.rp.parse_requests(data): + logging.info("<-- (%s) %s" % (self.addr[0], req.startline())) + resp = self.rc.compose_response(req) + logging.info("--> (%s) %s" % (self.addr[0], resp.startline())) + self.send(resp) + + def send(self, data): + sent = self.conn_socket.send(str(data)) + if sent == 0: + raise RuntimeError('Socket broken') def run(self): """Run the thread of the connection handler""" @@ -53,12 +59,12 @@ class ConnectionHandler(threading.Thread): self.handle_connection() except socket.error, e: print('ERR ' + str(e)) - + class Server: """HTTP Server""" - def __init__(self, config, **kwargs): + def __init__(self, configfile, **kwargs): """Initialize the HTTP server Args: @@ -66,27 +72,32 @@ class Server: server_port (int): port that the server is listening on timeout (int): seconds until timeout """ - self.read_config(config, **kwargs) + self.read_config(configfile, **kwargs) self.done = False - def read_config(self, config, **kwargs): - self.cp = SafeConfigParser() - self.cp.read(config) - if not self.cp.has_section('webhttp'): - self.cp.add_section('webhttp') - for (name, val) in self.cp.items('webhttp') + kwargs.items(): - setattr(self, name, val) + def read_config(self, configfile, **kwargs): + config().read(configfile) + + if not config().has_section('webhttp'): + config().add_section('webhttp') + + for name, val in kwargs.items(): + if val != None: + config().set('webhttp', name, str(val)) def run(self): """Run the HTTP Server and start listening""" - serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - serversocket.bind((self.hostname, self.port)) - serversocket.listen(5) + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.bind((config('hostname'), config('port', type=int))) + self.socket.listen(config('max_connections', type=int)) while not self.done: - (clientsocket, addr) = serversocket.accept() - ch = ConnectionHandler(clientsocket, addr, self.timeout) - ch.run() + (csocket, addr) = self.socket.accept() + ch = ConnectionHandler(csocket, addr, config('timeout', type=int)) + ch.start() def shutdown(self): """Safely shut down the HTTP server""" + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() self.done = True + diff --git a/project1/proj1_s4498062/webserver.py b/project1/proj1_s4498062/webserver.py index a361d5b..c01df4e 100644 --- a/project1/proj1_s4498062/webserver.py +++ b/project1/proj1_s4498062/webserver.py @@ -13,20 +13,23 @@ if __name__ == '__main__': parser = argparse.ArgumentParser(description="HTTP Server") parser.add_argument('-c', '--config', type=str, default='~/.webpy.ini', help='configuration file') - parser.add_argument('-a', '--address', type=str, default='localhost', + + parser.add_argument('-a', '--address', type=str, default=None, help='address to listen on (default localhost)') - parser.add_argument('-p', '--port', type=int, default=80, - help='port to listen on (default 80)') - parser.add_argument('-t', '--timeout', type=int, default=15, - help='timeout for incoming connections (default 15)') + parser.add_argument('-p', '--port', type=int, default=None, + help='port to listen on (no default)') + parser.add_argument('-t', '--timeout', type=int, default=None, + help='timeout for incoming connections (no default)') + parser.add_argument('-l', '--log', type=str, default='info', help='log level (debug, info, warning, error, critical)') parser.add_argument('-lf', '--log-file', type=str, default=None, help='log file (default stdout)') + args = parser.parse_args() # Logging - fmt = '[%(asctime)s] %(process)d %(levelname)s %(message)s' + fmt = '[%(asctime)s] %(levelname)s %(message)s' logging.basicConfig(format=fmt, level=getattr(logging, args.log.upper())) if args.log_file != None: logger = logging.getLogger(webhttp.weblogging.name) @@ -36,8 +39,8 @@ if __name__ == '__main__': # Start server config = os.path.expanduser(os.path.expandvars(args.config)) - server = webhttp.server.Server(config=config, - address=args.address, port=args.port, timeout=args.timeout) + server = webhttp.server.Server(configfile=config, + hostname=args.address, port=args.port, timeout=args.timeout) try: server.run() except KeyboardInterrupt: diff --git a/project1/proj1_s4498062/webtests.py b/project1/proj1_s4498062/webtests.py index a869de1..5e45acf 100644 --- a/project1/proj1_s4498062/webtests.py +++ b/project1/proj1_s4498062/webtests.py @@ -1,7 +1,9 @@ +import os.path import unittest import socket import sys +from webhttp.config import config import webhttp.message import webhttp.parser @@ -22,6 +24,8 @@ class TestGetRequests(unittest.TestCase): ('Connection', 'close') ] + config().read(os.path.expanduser('~/.webpy.ini')) + def tearDown(self): """Clean up after testing""" self.client_socket.shutdown(socket.SHUT_RDWR) @@ -59,7 +63,23 @@ class TestGetRequests(unittest.TestCase): """GET for an existing single resource followed by a GET for that same resource with caching utilized on the client/tester side """ - pass + response = self.request('GET', '/test', self.default_headers) + + response = self.request('GET', '/test', self.default_headers + \ + [('If-None-Match', 'invalid-etag')]) + self.assertEqual(response.code, 200, 'If-None-Match returns 200') + + response = self.request('GET', '/test', self.default_headers + \ + [('If-None-Match', response.get_header('ETag'))]) + self.assertEqual(response.code, 304, 'If-None-Match returns 304') + + response = self.request('GET', '/test', self.default_headers + \ + [('If-Match', response.get_header('ETag'))]) + self.assertEqual(response.code, 200, 'If-Match returns 200') + + response = self.request('GET', '/test', self.default_headers + \ + [('If-Match', 'invalid-etag')]) + self.assertEqual(response.code, 304, 'If-Match returns 304') def test_extisting_index_file(self): """GET for a directory with an existing index.html file""" @@ -72,6 +92,11 @@ class TestGetRequests(unittest.TestCase): self.assertEqual(response.code, 403) self.assertTrue(response.body) + def test_error_page(self): + r1 = self.request('GET', '/test/nonexistant', self.default_headers) + r2 = self.request('GET', config('error404'), self.default_headers) + self.assertEqual(r1.body, r2.body) + def test_persistent_close(self): """Multiple GETs over the same (persistent) connection with the last GET prompting closing the connection, the connection should be closed. @@ -91,6 +116,10 @@ class TestGetRequests(unittest.TestCase): """ pass + def test_doubledot(self): + response = self.request('GET', '/../test', self.default_headers) + self.assertEquals(response.code, 403) + if __name__ == "__main__": # Parse command line arguments -- cgit v1.2.3