From f2469eefdc466179eb8b26e0fd13f11c7c0e5246 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 13 Jun 2015 15:12:06 +0530 Subject: [PATCH] Implement serving of static resources --- resources/content-server/empty.html | 11 +++++ src/calibre/srv/content.py | 31 +++++++++++++ src/calibre/srv/handler.py | 6 +++ src/calibre/srv/http_response.py | 2 +- src/calibre/srv/routes.py | 39 +++++++++++++++- src/calibre/srv/tests/base.py | 70 ++++++++++++++++++++++++++++- src/calibre/srv/tests/content.py | 50 +++++++++++++++++++++ 7 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 resources/content-server/empty.html create mode 100644 src/calibre/srv/content.py create mode 100644 src/calibre/srv/tests/content.py diff --git a/resources/content-server/empty.html b/resources/content-server/empty.html new file mode 100644 index 0000000000..8f541eb9e0 --- /dev/null +++ b/resources/content-server/empty.html @@ -0,0 +1,11 @@ + + + + empty + + + + + + + diff --git a/src/calibre/srv/content.py b/src/calibre/srv/content.py new file mode 100644 index 0000000000..0825eef68d --- /dev/null +++ b/src/calibre/srv/content.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python2 +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2015, Kovid Goyal ' + +import os, errno + +from calibre.srv.errors import HTTPNotFound +from calibre.srv.routes import endpoint + +@endpoint('/static/{+what}', auth_required=False, cache_control=24) +def static(ctx, rd, what): + base = P('content-server', allow_user_override=False) + path = os.path.abspath(os.path.join(base, *what.split('/'))) + if not path.startswith(base) or ':' in what: + raise HTTPNotFound('Naughty, naughty!') + path = os.path.relpath(path, base).replace(os.sep, '/') + path = P('content-server/' + path) + try: + return lopen(path, 'rb') + except EnvironmentError as e: + if e.errno == errno.EISDIR or os.path.isdir(path): + raise HTTPNotFound('Cannot get a directory') + raise HTTPNotFound() + +@endpoint('/favicon.png', auth_required=False, cache_control=24) +def favicon(ctx, rd): + return lopen(I('lt.png'), 'rb') diff --git a/src/calibre/srv/handler.py b/src/calibre/srv/handler.py index 07e999824e..85bb7f95b6 100644 --- a/src/calibre/srv/handler.py +++ b/src/calibre/srv/handler.py @@ -6,6 +6,8 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' +from importlib import import_module + from calibre.srv.routes import Router class LibraryBroker(object): @@ -32,6 +34,10 @@ class Handler(object): def __init__(self, libraries, opts): self.router = Router(ctx=Context(libraries, opts), url_prefix=opts.url_prefix) + for module in ('content',): + module = import_module('calibre.srv.' + module) + self.router.load_routes(vars(module).itervalues()) + self.router.finalize() self.router.ctx.url_for = self.router.url_for self.dispatch = self.router.dispatch diff --git a/src/calibre/srv/http_response.py b/src/calibre/srv/http_response.py index ebee920967..609283afa4 100644 --- a/src/calibre/srv/http_response.py +++ b/src/calibre/srv/http_response.py @@ -522,7 +522,7 @@ class HTTPConnection(HTTPRequest): mt = guess_type(output.name)[0] if mt: if mt in {'text/plain', 'text/html', 'application/javascript', 'text/css'}: - mt =+ '; charset=UTF-8' + mt += '; charset=UTF-8' outheaders['Content-Type'] = mt elif isinstance(output, (bytes, type(''))): output = dynamic_output(output, outheaders) diff --git a/src/calibre/srv/routes.py b/src/calibre/srv/routes.py index 4a0dc3bf73..413ca4ef95 100644 --- a/src/calibre/srv/routes.py +++ b/src/calibre/srv/routes.py @@ -6,24 +6,38 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' -import httplib, sys, inspect, re +import httplib, sys, inspect, re, time, numbers from itertools import izip from operator import attrgetter from calibre.srv.errors import HTTPSimpleResponse, HTTPNotFound, RouteError +from calibre.srv.utils import http_date default_methods = frozenset(('HEAD', 'GET')) def route_key(route): return route.partition('{')[0].rstrip('/') -def endpoint(route, methods=default_methods, types=None, auth_required=True, android_workaround=False): +def endpoint(route, + methods=default_methods, + types=None, + auth_required=True, + android_workaround=False, + + # Manage the HTTP caching + # Set to None or 'no-cache' to prevent caching of this endpoint + # Set to a number to cache for at most number hours + # Set to a tuple (cache_type, max_age) to explicitly set the + # Cache-Control header + cache_control=False +): def annotate(f): f.route = route.rstrip('/') or '/' f.types = types or {} f.methods = methods f.auth_required = auth_required f.android_workaround = android_workaround + f.cache_control = cache_control f.is_endpoint = True return f return annotate @@ -207,6 +221,27 @@ class Router(object): self.init_session(endpoint_, data) ans = endpoint_(self.ctx, data, *args) self.finalize_session(endpoint_, data, ans) + outheaders = data.outheaders + + cc = endpoint_.cache_control + if cc is not False and 'Cache-Control' not in data.outheaders: + if cc is None or cc == 'no-cache': + outheaders['Expires'] = http_date(10000.0) # A date in the past + outheaders['Cache-Control'] = 'no-cache, must-revalidate' + outheaders['Pragma'] = 'no-cache' + elif isinstance(cc, numbers.Number): + cc = int(60 * 60 * cc) + outheaders['Cache-Control'] = 'public, max-age=%d' % cc + if cc == 0: + cc -= 100000 + outheaders['Expires'] = http_date(cc + time.time()) + else: + ctype, max_age = cc + max_age = int(60 * 60 * max_age) + outheaders['Cache-Control'] = '%s, max-age=%d' % (ctype, max_age) + if max_age == 0: + max_age -= 100000 + outheaders['Expires'] = http_date(max_age + time.time()) return ans def url_for(self, route, **kwargs): diff --git a/src/calibre/srv/tests/base.py b/src/calibre/srv/tests/base.py index 702d9d1393..37e0954dc0 100644 --- a/src/calibre/srv/tests/base.py +++ b/src/calibre/srv/tests/base.py @@ -7,11 +7,15 @@ __license__ = 'GPL v3' __copyright__ = '2011, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import unittest, time, httplib +import unittest, time, httplib, shutil, gc, tempfile, atexit, os +from io import BytesIO +from functools import partial from threading import Thread from calibre.srv.utils import ServerLog +rmtree = partial(shutil.rmtree, ignore_errors=True) + class BaseTest(unittest.TestCase): longMessage = True @@ -19,6 +23,51 @@ class BaseTest(unittest.TestCase): ae = unittest.TestCase.assertEqual +class LibraryBaseTest(BaseTest): + + def setUp(self): + from calibre.utils.recycle_bin import nuke_recycle + nuke_recycle() + self.library_path = self.mkdtemp() + self.create_db(self.library_path) + + def tearDown(self): + from calibre.utils.recycle_bin import restore_recyle + restore_recyle() + gc.collect(), gc.collect() + try: + shutil.rmtree(self.library_path) + except EnvironmentError: + # Try again in case something transient has a file lock on windows + gc.collect(), gc.collect() + time.sleep(2) + shutil.rmtree(self.library_path) + + def mkdtemp(self): + ans = tempfile.mkdtemp(prefix='db_test_') + atexit.register(rmtree, ans) + return ans + + def create_db(self, library_path): + from calibre.db.legacy import create_backend + from calibre.db.cache import Cache + d = os.path.dirname + src = os.path.join(d(d(d(os.path.abspath(__file__)))), 'db', 'tests', 'metadata.db') + dest = os.path.join(library_path, 'metadata.db') + shutil.copy2(src, dest) + db = Cache(create_backend(library_path)) + db.init() + db.set_cover({1:I('lt.png', data=True), 2:I('polish.png', data=True)}) + db.add_format(1, 'FMT1', BytesIO(b'book1fmt1'), run_hooks=False) + db.add_format(1, 'FMT2', BytesIO(b'book1fmt2'), run_hooks=False) + db.add_format(2, 'FMT1', BytesIO(b'book2fmt1'), run_hooks=False) + db.backend.conn.close() + return dest + + def create_server(self, *args, **kwargs): + args = (self.library_path ,) + args + return LibraryServer(*args, **kwargs) + class TestServer(Thread): daemon = True @@ -64,3 +113,22 @@ class TestServer(Thread): def change_handler(self, handler): from calibre.srv.http_response import create_http_handler self.loop.handler = create_http_handler(handler) + +class LibraryServer(TestServer): + + def __init__(self, library_path, libraries=(), plugins=(), specialize=lambda x:None, **kwargs): + Thread.__init__(self, name='ServerMain') + from calibre.srv.opts import Options + from calibre.srv.loop import ServerLoop + from calibre.srv.handler import Handler + from calibre.srv.http_response import create_http_handler + opts = Options(**kwargs) + self.libraries = libraries or (library_path,) + self.handler = Handler(libraries, opts) + self.loop = ServerLoop( + create_http_handler(self.handler.dispatch), + opts=opts, + plugins=plugins, + log=ServerLog(level=ServerLog.WARN), + ) + specialize(self) diff --git a/src/calibre/srv/tests/content.py b/src/calibre/srv/tests/content.py new file mode 100644 index 0000000000..6d0947a801 --- /dev/null +++ b/src/calibre/srv/tests/content.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python2 +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2015, Kovid Goyal ' + +import httplib + +from calibre.srv.tests.base import LibraryBaseTest + +class ContentTest(LibraryBaseTest): + + def test_static(self): + 'Test serving of static content' + with self.create_server() as server: + conn = server.connect() + + def missing(url, body=b''): + conn.request('GET', url) + r = conn.getresponse() + self.ae(r.status, httplib.NOT_FOUND) + self.ae(r.read(), body) + + missing('/static/missing.xxx') + missing('/static/../out.html', b'Naughty, naughty!') + missing('/static/C:/out.html', b'Naughty, naughty!') + + def test_response(r): + self.assertIn(b'max-age=', r.getheader('Cache-Control')) + self.assertIn(b'public', r.getheader('Cache-Control')) + self.assertIsNotNone(r.getheader('Expires')) + self.assertIsNotNone(r.getheader('ETag')) + self.assertIsNotNone(r.getheader('Content-Type')) + + def test(src, url): + raw = P(src, data=True) + conn.request('GET', url) + r = conn.getresponse() + self.ae(r.status, httplib.OK) + self.ae(r.read(), raw) + test_response(r) + conn.request('GET', url, headers={'If-None-Match':r.getheader('ETag')}) + r = conn.getresponse() + self.ae(r.status, httplib.NOT_MODIFIED) + self.ae(b'', r.read()) + + test('content-server/empty.html', '/static/empty.html') + test('images/lt.png', '/favicon.png')