diff --git a/src/calibre/srv/errors.py b/src/calibre/srv/errors.py index b1f5849915..64dfd0ee61 100644 --- a/src/calibre/srv/errors.py +++ b/src/calibre/srv/errors.py @@ -17,3 +17,11 @@ class MaxSizeExceeded(Exception): def __init__(self, prefix, size, limit): Exception.__init__(self, prefix + (' %d > maximum %d' % (size, limit))) + +class HTTP404(Exception): + pass + +class IfNoneMatch(Exception): + def __init__(self, etag=None): + Exception.__init__(self, '') + self.etag = etag diff --git a/src/calibre/srv/http.py b/src/calibre/srv/http.py index 7ab8c11003..74d31aee59 100644 --- a/src/calibre/srv/http.py +++ b/src/calibre/srv/http.py @@ -13,9 +13,10 @@ from urllib import unquote from functools import partial from calibre import as_unicode -from calibre.srv.errors import MaxSizeExceeded, NonHTTPConnRequest +from calibre.srv.errors import ( + MaxSizeExceeded, NonHTTPConnRequest, HTTP404, IfNoneMatch) from calibre.srv.respond import finalize_output, generate_static_output -from calibre.srv.utils import MultiDict +from calibre.srv.utils import MultiDict, http_date HTTP1 = 'HTTP/1.0' HTTP11 = 'HTTP/1.1' @@ -436,7 +437,7 @@ class HTTPPair(object): self.flushed_write(msg.encode('ascii')) return True - def simple_response(self, status_code, msg=""): + def simple_response(self, status_code, msg="", read_remaining_input=False): abort = status_code in (httplib.REQUEST_ENTITY_TOO_LARGE, httplib.REQUEST_URI_TOO_LONG) if abort: self.close_connection = True @@ -448,13 +449,32 @@ class HTTPPair(object): buf = [ '%s %d %s' % (self.reponse_protocol, status_code, httplib.responses[status_code]), "Content-Length: %s" % len(msg), - "Content-Type: text/plain; charset=UTF-8" + "Content-Type: text/plain; charset=UTF-8", + "Date: " + http_date(), ] if abort and self.reponse_protocol is HTTP11: buf.append("Connection: close") buf.append('') buf = [(x + '\r\n').encode('ascii') for x in buf] buf.append(msg) + if read_remaining_input: + self.input_reader.read() + self.flushed_write(b''.join(buf)) + + def send_not_modified(self, etag=None): + buf = [ + '%s %d %s' % (self.reponse_protocol, httplib.NOT_MODIFIED, httplib.responses[httplib.NOT_MODIFIED]), + "Content-Length: 0", + "Date: " + http_date(), + ] + if etag is not None: + buf.append('ETag: ' + etag) + for header in ('Expires', 'Cache-Control', 'Vary'): + val = self.outheaders.get(header) + if val: + buf.append(header + ': ' + val) + buf.append('') + buf = [(x + '\r\n').encode('ascii') for x in buf] self.flushed_write(b''.join(buf)) def flushed_write(self, data): @@ -475,13 +495,24 @@ class HTTPPair(object): else: self.input_reader = FixedSizeReader(self.conn.socket_file, self.request_content_length) - output = self.handle_request(self) - if self.status_code is None: - raise Exception('Request handler did not set status_code') + try: + output = self.handle_request(self) + except HTTP404 as e: + self.simple_response(httplib.NOT_FOUND, e.message, read_remaining_input=True) + return # Read and discard any remaining body from the HTTP request self.input_reader.read() + if self.status_code is None: + raise Exception('Request handler did not set status_code') - self.status_code, output = finalize_output(output, self.inheaders, self.outheaders, self.status_code) + try: + self.status_code, output = finalize_output(output, self.inheaders, self.outheaders, self.status_code, self.response_protocol is HTTP1, self.method) + except IfNoneMatch as e: + if self.method in ('GET', 'HEAD'): + self.send_not_modified(e.etag) + else: + self.simple_response(httplib.PRECONDITION_FAILED) + return self.send_headers() diff --git a/src/calibre/srv/respond.py b/src/calibre/srv/respond.py index 68cd6791f8..4b949fa77c 100644 --- a/src/calibre/srv/respond.py +++ b/src/calibre/srv/respond.py @@ -10,6 +10,7 @@ import os, hashlib, shutil, httplib, zlib, struct, time from io import DEFAULT_BUFFER_SIZE, BytesIO from calibre import force_unicode +from calibre.srv.errors import IfNoneMatch def acceptable_encoding(val, allowed=frozenset({'gzip'})): def enc(x): @@ -75,7 +76,7 @@ class FileSystemOutputFile(object): pos = output.tell() output.seek(0, os.SEEK_END) self.content_length = output.tell() - pos - self.etag = hashlib.sha1(force_unicode(output.name or '') + str(os.fstat(output.fileno()).st_mtime)).hexdigest() + self.etag = hashlib.sha1(type('')(os.fstat(output.fileno()).st_mtime) + force_unicode(output.name or '')).hexdigest() output.seek(pos) self.accept_ranges = True @@ -141,7 +142,10 @@ def generate_static_output(cache, gso_lock, name, generator): ans = cache[name] = StaticGeneratedOutput(generator()) return ans -def finalize_output(output, inheaders, outheaders, status_code): +def parse_if_none_match(val): + return {x.strip() for x in val.split(',')} + +def finalize_output(output, inheaders, outheaders, status_code, is_http1, method): ct = outheaders.get('Content-Type', '') compressible = not ct or ct.startswith('text/') or ct.startswith('image/svg') or ct.startswith('application/json') if isinstance(output, file): @@ -153,20 +157,29 @@ def finalize_output(output, inheaders, outheaders, status_code): else: output = GeneratedOutput(output, outheaders) compressible = (status_code == httplib.OK and compressible and output.content_length > 1024 and - acceptable_encoding(inheaders.get('Accept-Encoding', ''))) - accept_ranges = not compressible and output.accept_ranges is not None and status_code == httplib.OK + acceptable_encoding(inheaders.get('Accept-Encoding', '')) and not is_http1) + accept_ranges = (not compressible and output.accept_ranges is not None and status_code == httplib.OK and + not is_http1) + ranges = None - for header in 'Accept-Ranges Content-Encoding Transfer-Encoding ETag'.split(): + for header in ('Accept-Ranges', 'Content-Encoding', 'Transfer-Encoding', 'ETag', 'Content-Length'): outheaders.pop('header', all=True) - # TODO: If-None-Match, Ranges, If-Range + none_match = parse_if_none_match(inheaders.get('If-None-Match', '')) + matched = '*' in none_match or (output.etag and output.etag in none_match) + if matched: + raise IfNoneMatch(output.etag) - if output.etag: + # TODO: Ranges, If-Range + + if output.etag and method in ('GET', 'HEAD'): outheaders.set('ETag', output.etag, replace=True) if accept_ranges: outheaders.set('Accept-Ranges', 'bytes', replace=True) elif compressible: outheaders.set('Content-Encoding', 'gzip', replace=True) + if output.content_length is not None and not compressible and not ranges: + outheaders.set('Content-Length', '%d' % output.content_length, replace=True) if compressible or output.content_length is None: outheaders.set('Transfer-Encoding', 'chunked', replace=True) diff --git a/src/calibre/srv/utils.py b/src/calibre/srv/utils.py index f021b8e71a..4e7679a9af 100644 --- a/src/calibre/srv/utils.py +++ b/src/calibre/srv/utils.py @@ -8,8 +8,12 @@ __copyright__ = '2015, Kovid Goyal ' from urlparse import parse_qs import repr as reprlib +from email.utils import formatdate -class MultiDict(dict): +def http_date(timeval=None): + return type('')(formatdate(timeval=timeval, usegmt=True)) + +class MultiDict(dict): # {{{ def __setitem__(self, key, val): vals = dict.get(self, key, []) @@ -75,3 +79,4 @@ class MultiDict(dict): def pretty(self, leading_whitespace=''): return leading_whitespace + ('\n' + leading_whitespace).join('%s: %s' % (k, v) for k, v in self.items()) +# }}}