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')