Skip to content

Commit b80e2b2

Browse files
committed
fix(web): issue2551356. Add etag header for not-modified (304) request.
When a 304 is returned to a conditional request for a static file, print an ETag for the response. ETag was always sent with a 200 response. This also adds initial support for if-none-match conditional requests for static files. Changes: Refactors the if-modified-since code out to a method. It moves a file stat call from serve_static_file to _serve_file so that an etag can be generated by both serve_static_file and serve_file which call _serve_file. Tests added. This does not test the codepath where serve_file pulls content from the database rather than from a local file on disk. Test mocking _serve_file changed to account for 5th argument to serve_file BREAKING CHANGE: function signature for client.py-Client::_serve_file() now has 5 not 4 parameters (added etag param). Since this is a "hidden" method I am not too worried about it.
1 parent 0c94f9e commit b80e2b2

File tree

3 files changed

+178
-46
lines changed

3 files changed

+178
-46
lines changed

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ Fixed:
3636
- issue2551289 - Invalid REST Accept header with post/put performs
3737
change before returning 406. Error before making any changes to the
3838
db if we can't respond with requested format. (John Rouillard)
39+
- issue2551356 - Add etag header when If-Modified-Since GET request
40+
returns not-modified (304). Breaking change to function signature
41+
for client.py-Client::_serve_file(). (John Rouillard)
3942

4043
Features:
4144

roundup/cgi/client.py

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1949,7 +1949,7 @@ def serve_file(self, designator, dre=dre):
19491949

19501950
lmt = klass.get(nodeid, 'activity').timestamp()
19511951

1952-
self._serve_file(lmt, mime_type, content, filename)
1952+
self._serve_file(lmt, None, mime_type, content, filename)
19531953

19541954
def serve_static_file(self, file):
19551955
""" Serve up the file named from the templates dir
@@ -1989,9 +1989,6 @@ def serve_static_file(self, file):
19891989
if filename is None: # we didn't find a filename
19901990
raise NotFound(file)
19911991

1992-
# last-modified time
1993-
lmt = os.stat(filename)[stat.ST_MTIME]
1994-
19951992
# detemine meta-type
19961993
file = str(file)
19971994
mime_type = mimetypes.guess_type(file)[0]
@@ -2009,28 +2006,59 @@ def serve_static_file(self, file):
20092006
self.additional_headers['Cache-Control'] = \
20102007
self.Cache_Control[mime_type]
20112008

2012-
self._serve_file(lmt, mime_type, '', filename)
2009+
self._serve_file(None, None, mime_type, '', filename)
20132010

2014-
def _serve_file(self, lmt, mime_type, content=None, filename=None):
2015-
""" guts of serve_file() and serve_static_file()
2016-
"""
2011+
def _serve_file(self, lmt, etag, mime_type, content=None, filename=None):
2012+
"""guts of serve_file() and serve_static_file()
20172013
2018-
# spit out headers
2019-
self.additional_headers['Last-Modified'] = email.utils.formatdate(lmt,
2020-
usegmt=True)
2014+
if lmt or etag are None, derive them from file filename.
20212015
2022-
ims = None
2023-
# see if there's an if-modified-since...
2024-
# used if this is run behind a non-caching http proxy
2025-
if hasattr(self.request, 'headers'):
2026-
ims = self.request.headers.get('if-modified-since')
2027-
elif 'HTTP_IF_MODIFIED_SINCE' in self.env:
2028-
# cgi will put the header in the env var
2029-
ims = self.env['HTTP_IF_MODIFIED_SINCE']
2030-
if ims:
2031-
ims = email.utils.parsedate(ims)[:6]
2032-
lmtt = time.gmtime(lmt)[:6]
2033-
if lmtt <= ims:
2016+
Handles if-modified-since and if-none-match etag
2017+
conditional gets.
2018+
2019+
It produces an raw etag header without encoding suffix.
2020+
But it adds Accept-Encoding to the vary header.
2021+
2022+
"""
2023+
if filename:
2024+
stat_info = os.stat(filename)
2025+
2026+
if lmt is None:
2027+
# last-modified time
2028+
lmt = stat_info[stat.ST_MTIME]
2029+
if etag is None:
2030+
# FIXME: maybe etag should depend on encoding.
2031+
# it is an apache compatible etag without encoding.
2032+
etag = '"%x-%x-%x"' % (stat_info[stat.ST_INO],
2033+
stat_info[stat.ST_SIZE],
2034+
stat_info[stat.ST_MTIME])
2035+
2036+
# spit out headers for conditional request
2037+
self.setHeader("ETag", etag)
2038+
self.additional_headers['Last-Modified'] = \
2039+
email.utils.formatdate(lmt, usegmt=True)
2040+
2041+
inm = None
2042+
# ETag is a more strict check than modified date. Use etag
2043+
# check if available. Skip testing modified data.
2044+
if hasattr(self.request, 'headers'):
2045+
inm = self.request.headers.get('if-none-match')
2046+
elif 'HTTP_IF_NONE_MATCH' in self.env:
2047+
# maybe the cgi will put the header in the env var
2048+
inm = self.env['HTTP_ETAG']
2049+
if inm and etag == inm:
2050+
# because we can compress, always set Accept-Encoding
2051+
# value. Otherwise caches can serve up the wrong info
2052+
# if their cached copy has no compression.
2053+
self.setVary("Accept-Encoding")
2054+
'''
2055+
to solve issue2551356 I may need to determine
2056+
the content encoding.
2057+
if (self.determine_content_encoding()):
2058+
'''
2059+
raise NotModified
2060+
2061+
if self.if_not_modified_since(lmt):
20342062
# because we can compress, always set Accept-Encoding
20352063
# value. Otherwise caches can serve up the wrong info
20362064
# if their cached copy has no compression.
@@ -2051,6 +2079,27 @@ def _serve_file(self, lmt, mime_type, content=None, filename=None):
20512079
self.additional_headers['Content-Length'] = str(len(content))
20522080
self.write(content)
20532081

2082+
def if_not_modified_since(self, lmt):
2083+
ims = None
2084+
# see if there's an if-modified-since...
2085+
if hasattr(self.request, 'headers'):
2086+
ims = self.request.headers.get('if-modified-since')
2087+
elif 'HTTP_IF_MODIFIED_SINCE' in self.env:
2088+
# cgi will put the header in the env var
2089+
ims = self.env['HTTP_IF_MODIFIED_SINCE']
2090+
2091+
if ims:
2092+
datestamp = email.utils.parsedate(ims)
2093+
if datestamp is not None:
2094+
ims = datestamp[:6]
2095+
else:
2096+
# set to beginning of time so whole file will be sent
2097+
ims = (0, 0, 0, 0, 0, 0)
2098+
lmtt = time.gmtime(lmt)[:6]
2099+
return lmtt <= ims
2100+
2101+
return False
2102+
20542103
def send_error_to_admin(self, subject, html, txt):
20552104
"""Send traceback information to admin via email.
20562105
We send both, the formatted html (with more information) and

test/test_cgi.py

Lines changed: 103 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from roundup.anypy.cgi_ import cgi
2020
from roundup.cgi import client, actions, exceptions
21-
from roundup.cgi.exceptions import FormError, NotFound, Redirect
21+
from roundup.cgi.exceptions import FormError, NotFound, Redirect, NotModified
2222
from roundup.exceptions import UsageError, Reject
2323
from roundup.cgi.templating import HTMLItem, HTMLRequest, NoTemplate
2424
from roundup.cgi.templating import HTMLProperty, _HTMLItem, anti_csrf_nonce
@@ -1942,6 +1942,7 @@ def _make_client(self, form, classname='user', nodeid='1',
19421942
if nodeid is not None:
19431943
cl.nodeid = nodeid
19441944
cl.db = self.db
1945+
cl.request = MockNull()
19451946
cl.db.Otk = cl.db.getOTKManager()
19461947
#cl.db.Otk = MockNull()
19471948
#cl.db.Otk.data = {}
@@ -2382,6 +2383,83 @@ def testRegisterActionUnusedUserCheck(self):
23822383
if os.path.exists(SENDMAILDEBUG):
23832384
os.remove(SENDMAILDEBUG)
23842385

2386+
def testserve_static_files_cache_headers(self):
2387+
"""Note for headers the real headers class is case
2388+
insensitive.
2389+
"""
2390+
# make a client instance
2391+
cl = self._make_client({})
2392+
# Make local copy in cl to not modify value in class
2393+
cl.Cache_Control = copy.copy (cl.Cache_Control)
2394+
2395+
# TEMPLATES dir is searched by default. So this file exists.
2396+
# Check the returned values.
2397+
cl.serve_static_file("style.css")
2398+
2399+
# gather the conditional request headers from the 200 response
2400+
inm = cl.additional_headers['ETag']
2401+
ims = cl.additional_headers['Last-Modified']
2402+
2403+
2404+
# loop over all header value possibilities that will
2405+
# result in not modified.
2406+
for headers in [
2407+
{'if-none-match' : inm},
2408+
{'if-modified-since' : ims},
2409+
{'if-none-match' : inm, 'if-modified-since' : ims },
2410+
{'if-none-match' : inm, 'if-modified-since' : "fake" },
2411+
{'if-none-match' : "fake", 'if-modified-since' : ims },
2412+
]:
2413+
print(headers)
2414+
2415+
# Request same file with if-modified-since header
2416+
# expect NotModified with same ETag and Last-Modified headers.
2417+
cl.request.headers = headers
2418+
cl.response_code = None
2419+
cl.additional_headers = {}
2420+
2421+
with self.assertRaises(NotModified) as cm:
2422+
cl.serve_static_file("style.css")
2423+
2424+
self.assertEqual(cm.exception.args, ())
2425+
2426+
self.assertEqual(cl.response_code, None)
2427+
self.assertEqual(cl.additional_headers['ETag'], inm)
2428+
self.assertEqual(cl.additional_headers['Last-Modified'], ims)
2429+
2430+
2431+
## run two cases that should not return NotModified
2432+
for headers in [
2433+
{},
2434+
{'if-none-match' : "fake", 'if-modified-since' : "fake" },
2435+
]:
2436+
cl.request.headers = headers
2437+
cl.response_code = None
2438+
cl.additional_headers = {}
2439+
2440+
cl.serve_static_file("style.css")
2441+
2442+
self.assertEqual(cl.response_code, None)
2443+
self.assertEqual(cl.additional_headers['ETag'], inm)
2444+
self.assertEqual(cl.additional_headers['Last-Modified'], ims)
2445+
2446+
## test pure cgi case
2447+
# headers attribute does not exist
2448+
cl.request = None
2449+
cl.response_code = None
2450+
cl.additional_headers = {}
2451+
2452+
cl.env["HTTP_IF_MODIFIED_SINCE"] = ims
2453+
2454+
with self.assertRaises(NotModified) as cm:
2455+
cl.serve_static_file("style.css")
2456+
2457+
self.assertEqual(cm.exception.args, ())
2458+
2459+
self.assertEqual(cl.response_code, None)
2460+
self.assertEqual(cl.additional_headers['ETag'], inm)
2461+
self.assertEqual(cl.additional_headers['Last-Modified'], ims)
2462+
23852463
def testserve_static_files(self):
23862464
# make a client instance
23872465
cl = self._make_client({})
@@ -2390,8 +2468,8 @@ def testserve_static_files(self):
23902468

23912469
# hijack _serve_file so I can see what is found
23922470
output = []
2393-
def my_serve_file(a, b, c, d):
2394-
output.append((a,b,c,d))
2471+
def my_serve_file(a, b, c, d, e):
2472+
output.append((a,b,c,d,e))
23952473
cl._serve_file = my_serve_file
23962474

23972475
# check case where file is not found.
@@ -2401,8 +2479,9 @@ def my_serve_file(a, b, c, d):
24012479
# TEMPLATES dir is searched by default. So this file exists.
24022480
# Check the returned values.
24032481
cl.serve_static_file("issue.index.html")
2404-
self.assertEqual(output[0][1], "text/html")
2405-
self.assertEqual(output[0][3],
2482+
print(output)
2483+
self.assertEqual(output[0][2], "text/html")
2484+
self.assertEqual(output[0][4],
24062485
normpath('_test_cgi_form/html/issue.index.html'))
24072486
del output[0] # reset output buffer
24082487

@@ -2415,8 +2494,8 @@ def my_serve_file(a, b, c, d):
24152494
# explicitly allow html directory
24162495
cl.instance.config['STATIC_FILES'] = 'html -'
24172496
cl.serve_static_file("issue.index.html")
2418-
self.assertEqual(output[0][1], "text/html")
2419-
self.assertEqual(output[0][3],
2497+
self.assertEqual(output[0][2], "text/html")
2498+
self.assertEqual(output[0][4],
24202499
normpath('_test_cgi_form/html/issue.index.html'))
24212500
del output[0] # reset output buffer
24222501

@@ -2425,15 +2504,15 @@ def my_serve_file(a, b, c, d):
24252504

24262505
# find file in first directory
24272506
cl.serve_static_file("messagesummary.py")
2428-
self.assertEqual(output[0][1], "text/x-python")
2429-
self.assertEqual(output[0][3],
2507+
self.assertEqual(output[0][2], "text/x-python")
2508+
self.assertEqual(output[0][4],
24302509
normpath( "_test_cgi_form/detectors/messagesummary.py"))
24312510
del output[0] # reset output buffer
24322511

24332512
# find file in second directory
24342513
cl.serve_static_file("README.txt")
2435-
self.assertEqual(output[0][1], "text/plain")
2436-
self.assertEqual(output[0][3],
2514+
self.assertEqual(output[0][2], "text/plain")
2515+
self.assertEqual(output[0][4],
24372516
normpath("_test_cgi_form/extensions/README.txt"))
24382517
del output[0] # reset output buffer
24392518

@@ -2448,25 +2527,25 @@ def my_serve_file(a, b, c, d):
24482527
f = open('_test_cgi_form/detectors/README.txt', 'a').close()
24492528
# find file now in first directory
24502529
cl.serve_static_file("README.txt")
2451-
self.assertEqual(output[0][1], "text/plain")
2452-
self.assertEqual(output[0][3],
2530+
self.assertEqual(output[0][2], "text/plain")
2531+
self.assertEqual(output[0][4],
24532532
normpath("_test_cgi_form/detectors/README.txt"))
24542533
del output[0] # reset output buffer
24552534

24562535
cl.instance.config['STATIC_FILES'] = ' detectors extensions '
24572536
# make sure lack of trailing - allows searching TEMPLATES
24582537
cl.serve_static_file("issue.index.html")
2459-
self.assertEqual(output[0][1], "text/html")
2460-
self.assertEqual(output[0][3],
2538+
self.assertEqual(output[0][2], "text/html")
2539+
self.assertEqual(output[0][4],
24612540
normpath("_test_cgi_form/html/issue.index.html"))
24622541
del output[0] # reset output buffer
24632542

24642543
# Make STATIC_FILES a single element.
24652544
cl.instance.config['STATIC_FILES'] = 'detectors'
24662545
# find file now in first directory
24672546
cl.serve_static_file("messagesummary.py")
2468-
self.assertEqual(output[0][1], "text/x-python")
2469-
self.assertEqual(output[0][3],
2547+
self.assertEqual(output[0][2], "text/x-python")
2548+
self.assertEqual(output[0][4],
24702549
normpath("_test_cgi_form/detectors/messagesummary.py"))
24712550
del output[0] # reset output buffer
24722551

@@ -2475,8 +2554,8 @@ def my_serve_file(a, b, c, d):
24752554
f = open('_test_cgi_form/detectors/css/README.css', 'a').close()
24762555
# use subdir in filename
24772556
cl.serve_static_file("css/README.css")
2478-
self.assertEqual(output[0][1], "text/css")
2479-
self.assertEqual(output[0][3],
2557+
self.assertEqual(output[0][2], "text/css")
2558+
self.assertEqual(output[0][4],
24802559
normpath("_test_cgi_form/detectors/css/README.css"))
24812560
del output[0] # reset output buffer
24822561

@@ -2486,18 +2565,19 @@ def my_serve_file(a, b, c, d):
24862565
os.mkdir('_test_cgi_form/html/css')
24872566
f = open('_test_cgi_form/html/css/README1.css', 'a').close()
24882567
cl.serve_static_file("README1.css")
2489-
self.assertEqual(output[0][1], "text/css")
2490-
self.assertEqual(output[0][3],
2568+
self.assertEqual(output[0][2], "text/css")
2569+
self.assertEqual(output[0][4],
24912570
normpath("_test_cgi_form/html/css/README1.css"))
24922571
self.assertTrue( "Cache-Control" in cl.additional_headers )
24932572
self.assertEqual( cl.additional_headers,
24942573
{'Cache-Control': 'public, max-age=3600'} )
2574+
print(cl.additional_headers)
24952575
del output[0] # reset output buffer
24962576

24972577
cl.Cache_Control['README1.css'] = 'public, max-age=60'
24982578
cl.serve_static_file("README1.css")
2499-
self.assertEqual(output[0][1], "text/css")
2500-
self.assertEqual(output[0][3],
2579+
self.assertEqual(output[0][2], "text/css")
2580+
self.assertEqual(output[0][4],
25012581
normpath("_test_cgi_form/html/css/README1.css"))
25022582
self.assertTrue( "Cache-Control" in cl.additional_headers )
25032583
self.assertEqual( cl.additional_headers,

0 commit comments

Comments
 (0)