1
# Copyright (c) 2010-2011 OpenStack, LLC.
3
# Licensed under the Apache License, Version 2.0 (the "License");
4
# you may not use this file except in compliance with the License.
5
# You may obtain a copy of the License at
7
# http://www.apache.org/licenses/LICENSE-2.0
9
# Unless required by applicable law or agreed to in writing, software
10
# distributed under the License is distributed on an "AS IS" BASIS,
11
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
13
# See the License for the specific language governing permissions and
14
# limitations under the License.
17
This StaticWeb WSGI middleware will serve container data as a static web site
18
with index file and error file resolution and optional file listings. This mode
19
is normally only active for anonymous requests. If you want to use it with
20
authenticated requests, set the ``X-Web-Mode: true`` header on the request.
22
The ``staticweb`` filter should be added to the pipeline in your
23
``/etc/swift/proxy-server.conf`` file just after any auth middleware. Also, the
24
configuration section for the ``staticweb`` middleware itself needs to be
31
pipeline = healthcheck cache swauth staticweb proxy-server
36
use = egg:swift#staticweb
37
# Seconds to cache container x-container-meta-web-* header values.
39
# You can override the default log routing for this filter here:
40
# set log_name = staticweb
41
# set log_facility = LOG_LOCAL0
42
# set log_level = INFO
43
# set access_log_name = staticweb
44
# set access_log_facility = LOG_LOCAL0
45
# set access_log_level = INFO
46
# set log_headers = False
48
Any publicly readable containers (for example, ``X-Container-Read: .r:*``, see
49
`acls`_ for more information on this) will be checked for
50
X-Container-Meta-Web-Index and X-Container-Meta-Web-Error header values::
52
X-Container-Meta-Web-Index <index.name>
53
X-Container-Meta-Web-Error <error.name.suffix>
55
If X-Container-Meta-Web-Index is set, any <index.name> files will be served
56
without having to specify the <index.name> part. For instance, setting
57
``X-Container-Meta-Web-Index: index.html`` will be able to serve the object
58
.../pseudo/path/index.html with just .../pseudo/path or .../pseudo/path/
60
If X-Container-Meta-Web-Error is set, any errors (currently just 401
61
Unauthorized and 404 Not Found) will instead serve the
62
.../<status.code><error.name.suffix> object. For instance, setting
63
``X-Container-Meta-Web-Error: error.html`` will serve .../404error.html for
64
requests for paths not found.
66
For psuedo paths that have no <index.name>, this middleware can serve HTML file
67
listings if you set the ``X-Container-Meta-Web-Listings: true`` metadata item
70
If listings are enabled, the listings can have a custom style sheet by setting
71
the X-Container-Meta-Web-Listings-CSS header. For instance, setting
72
``X-Container-Meta-Web-Listings-CSS: listing.css`` will make listings link to
73
the .../listing.css style sheet. If you "view source" in your browser on a
74
listing page, you will see the well defined document structure that can be
77
Example usage of this middleware via ``st``:
79
Make the container publicly readable::
81
st post -r '.r:*' container
83
You should be able to get objects directly, but no index.html resolution or
86
Set an index file directive::
88
st post -m 'web-index:index.html' container
90
You should be able to hit paths that have an index.html without needing to
91
type the index.html part.
95
st post -m 'web-listings: true' container
97
Now you should see object listings for paths and pseudo paths that have no
100
Enable a custom listings style sheet::
102
st post -m 'web-listings-css:listings.css' container
106
st post -m 'web-error:error.html' container
108
Now 401's should load 401error.html, 404's should load 404error.html, etc.
113
import simplejson as json
119
from urllib import unquote, quote
121
from webob import Response, Request
122
from webob.exc import HTTPMovedPermanently, HTTPNotFound
124
from swift.common.utils import cache_from_env, get_logger, human_readable, \
125
split_path, TRUE_VALUES
128
class StaticWeb(object):
130
The Static Web WSGI middleware filter; serves container data as a static
131
web site. See `staticweb`_ for an overview.
133
:param app: The next WSGI application/filter in the paste.deploy pipeline.
134
:param conf: The filter configuration dict.
137
def __init__(self, app, conf):
138
#: The next WSGI application/filter in the paste.deploy pipeline.
140
#: The filter configuration dict.
142
#: The seconds to cache the x-container-meta-web-* headers.,
143
self.cache_timeout = int(conf.get('cache_timeout', 300))
144
#: Logger for this filter.
145
self.logger = get_logger(conf, log_route='staticweb')
147
for key in ('log_facility', 'log_name', 'log_level'):
148
value = conf.get('access_' + key, conf.get(key, None))
150
access_log_conf[key] = value
151
#: Web access logger for this filter.
152
self.access_logger = get_logger(access_log_conf,
153
log_route='staticweb-access')
154
#: Indicates whether full HTTP headers should be logged or not.
155
self.log_headers = conf.get('log_headers') == 'True'
156
# Results from the last call to self._start_response.
157
self._response_status = None
158
self._response_headers = None
159
self._response_exc_info = None
160
# Results from the last call to self._get_container_info.
161
self._index = self._error = self._listings = self._listings_css = None
163
def _start_response(self, status, headers, exc_info=None):
165
Saves response info without sending it to the remote client.
166
Uses the same semantics as the usual WSGI start_response.
168
self._response_status = status
169
self._response_headers = headers
170
self._response_exc_info = exc_info
172
def _error_response(self, response, env, start_response):
174
Sends the error response to the remote client, possibly resolving a
175
custom error response body based on x-container-meta-web-error.
177
:param response: The error response we should default to sending.
178
:param env: The original request WSGI environment.
179
:param start_response: The WSGI start_response hook.
181
self._log_response(env, self._get_status_int())
183
start_response(self._response_status, self._response_headers,
184
self._response_exc_info)
186
save_response_status = self._response_status
187
save_response_headers = self._response_headers
188
save_response_exc_info = self._response_exc_info
189
tmp_env = self._get_escalated_env(env)
190
tmp_env['REQUEST_METHOD'] = 'GET'
191
tmp_env['PATH_INFO'] = '/%s/%s/%s/%s%s' % (self.version, self.account,
192
self.container, self._get_status_int(), self._error)
193
resp = self.app(tmp_env, self._start_response)
194
if self._get_status_int() // 100 == 2:
195
start_response(save_response_status, self._response_headers,
196
self._response_exc_info)
198
start_response(save_response_status, save_response_headers,
199
save_response_exc_info)
202
def _get_status_int(self):
204
Returns the HTTP status int from the last called self._start_response
207
return int(self._response_status.split(' ', 1)[0])
209
def _get_escalated_env(self, env):
211
Returns a new fresh WSGI environment with escalated privileges to do
212
backend checks, listings, etc. that the remote user wouldn't be able to
215
new_env = {'REQUEST_METHOD': 'GET',
216
'HTTP_USER_AGENT': '%s StaticWeb' % env.get('HTTP_USER_AGENT')}
217
for name in ('eventlet.posthooks', 'HTTP_X_CF_TRANS_ID', 'REMOTE_USER',
218
'SCRIPT_NAME', 'SERVER_NAME', 'SERVER_PORT',
219
'SERVER_PROTOCOL', 'swift.cache'):
221
new_env[name] = env[name]
224
def _get_container_info(self, env, start_response):
226
Retrieves x-container-meta-web-index, x-container-meta-web-error,
227
x-container-meta-web-listings, and x-container-meta-web-listings-css
228
from memcache or from the cluster and stores the result in memcache and
229
in self._index, self._error, self._listings, and self._listings_css.
231
:param env: The WSGI environment dict.
232
:param start_response: The WSGI start_response hook.
234
self._index = self._error = self._listings = self._listings_css = None
235
memcache_client = cache_from_env(env)
237
memcache_key = '/staticweb/%s/%s/%s' % (self.version, self.account,
239
cached_data = memcache_client.get(memcache_key)
241
(self._index, self._error, self._listings,
242
self._listings_css) = cached_data
244
tmp_env = self._get_escalated_env(env)
245
tmp_env['REQUEST_METHOD'] = 'HEAD'
246
req = Request.blank('/%s/%s/%s' % (self.version, self.account,
247
self.container), environ=tmp_env)
248
resp = req.get_response(self.app)
249
if resp.status_int // 100 == 2:
251
resp.headers.get('x-container-meta-web-index', '').strip()
253
resp.headers.get('x-container-meta-web-error', '').strip()
255
resp.headers.get('x-container-meta-web-listings', '').strip()
256
self._listings_css = \
257
resp.headers.get('x-container-meta-web-listings-css',
260
memcache_client.set(memcache_key,
261
(self._index, self._error, self._listings,
263
timeout=self.cache_timeout)
265
def _listing(self, env, start_response, prefix=None):
267
Sends an HTML object listing to the remote client.
269
:param env: The original WSGI environment dict.
270
:param start_response: The original WSGI start_response hook.
271
:param prefix: Any prefix desired for the container listing.
273
if self._listings not in TRUE_VALUES:
274
resp = HTTPNotFound()(env, self._start_response)
275
return self._error_response(resp, env, start_response)
276
tmp_env = self._get_escalated_env(env)
277
tmp_env['REQUEST_METHOD'] = 'GET'
278
tmp_env['PATH_INFO'] = \
279
'/%s/%s/%s' % (self.version, self.account, self.container)
280
tmp_env['QUERY_STRING'] = 'delimiter=/&format=json'
282
tmp_env['QUERY_STRING'] += '&prefix=%s' % quote(prefix)
285
resp = self.app(tmp_env, self._start_response)
286
if self._get_status_int() // 100 != 2:
287
return self._error_response(resp, env, start_response)
288
listing = json.loads(''.join(resp))
290
resp = HTTPNotFound()(env, self._start_response)
291
return self._error_response(resp, env, start_response)
292
headers = {'Content-Type': 'text/html'}
293
body = '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 ' \
294
'Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">\n' \
297
' <title>Listing of %s</title>\n' % \
298
cgi.escape(env['PATH_INFO'])
299
if self._listings_css:
300
body += ' <link rel="stylesheet" type="text/css" ' \
301
'href="%s%s" />\n' % \
302
('../' * prefix.count('/'), quote(self._listings_css))
304
body += ' <style type="text/css">\n' \
305
' h1 {font-size: 1em; font-weight: bold;}\n' \
306
' th {text-align: left; padding: 0px 1em 0px 1em;}\n' \
307
' td {padding: 0px 1em 0px 1em;}\n' \
308
' a {text-decoration: none;}\n' \
310
body += ' </head>\n' \
312
' <h1 id="title">Listing of %s</h1>\n' \
313
' <table id="listing">\n' \
314
' <tr id="heading">\n' \
315
' <th class="colname">Name</th>\n' \
316
' <th class="colsize">Size</th>\n' \
317
' <th class="coldate">Date</th>\n' \
319
cgi.escape(env['PATH_INFO'])
321
body += ' <tr id="parent" class="item">\n' \
322
' <td class="colname"><a href="../">../</a></td>\n' \
323
' <td class="colsize"> </td>\n' \
324
' <td class="coldate"> </td>\n' \
328
subdir = item['subdir']
330
subdir = subdir[len(prefix):]
331
body += ' <tr class="item subdir">\n' \
332
' <td class="colname"><a href="%s">%s</a></td>\n' \
333
' <td class="colsize"> </td>\n' \
334
' <td class="coldate"> </td>\n' \
336
(quote(subdir), cgi.escape(subdir))
341
name = name[len(prefix):]
342
body += ' <tr class="item %s">\n' \
343
' <td class="colname"><a href="%s">%s</a></td>\n' \
344
' <td class="colsize">%s</td>\n' \
345
' <td class="coldate">%s</td>\n' \
347
(' '.join('type-' + cgi.escape(t.lower(), quote=True)
348
for t in item['content_type'].split('/')),
349
quote(name), cgi.escape(name),
350
human_readable(item['bytes']),
351
cgi.escape(item['last_modified']).split('.')[0].
353
body += ' </table>\n' \
356
resp = Response(headers=headers, body=body)
357
self._log_response(env, resp.status_int)
358
return resp(env, start_response)
360
def _handle_container(self, env, start_response):
362
Handles a possible static web request for a container.
364
:param env: The original WSGI environment dict.
365
:param start_response: The original WSGI start_response hook.
367
self._get_container_info(env, start_response)
368
if not self._listings and not self._index:
369
return self.app(env, start_response)
370
if env['PATH_INFO'][-1] != '/':
371
resp = HTTPMovedPermanently(
372
location=(env['PATH_INFO'] + '/'))
373
self._log_response(env, resp.status_int)
374
return resp(env, start_response)
376
return self._listing(env, start_response)
378
tmp_env['HTTP_USER_AGENT'] = \
379
'%s StaticWeb' % env.get('HTTP_USER_AGENT')
380
tmp_env['PATH_INFO'] += self._index
381
resp = self.app(tmp_env, self._start_response)
382
status_int = self._get_status_int()
383
if status_int == 404:
384
return self._listing(env, start_response)
385
elif self._get_status_int() // 100 not in (2, 3):
386
return self._error_response(resp, env, start_response)
387
start_response(self._response_status, self._response_headers,
388
self._response_exc_info)
391
def _handle_object(self, env, start_response):
393
Handles a possible static web request for an object. This object could
394
resolve into an index or listing request.
396
:param env: The original WSGI environment dict.
397
:param start_response: The original WSGI start_response hook.
400
tmp_env['HTTP_USER_AGENT'] = \
401
'%s StaticWeb' % env.get('HTTP_USER_AGENT')
402
resp = self.app(tmp_env, self._start_response)
403
status_int = self._get_status_int()
404
if status_int // 100 in (2, 3):
405
return self.app(env, start_response)
406
if status_int != 404:
407
return self._error_response(resp, env, start_response)
408
self._get_container_info(env, start_response)
409
if not self._listings and not self._index:
410
return self.app(env, start_response)
414
tmp_env['HTTP_USER_AGENT'] = \
415
'%s StaticWeb' % env.get('HTTP_USER_AGENT')
416
if tmp_env['PATH_INFO'][-1] != '/':
417
tmp_env['PATH_INFO'] += '/'
418
tmp_env['PATH_INFO'] += self._index
419
resp = self.app(tmp_env, self._start_response)
420
status_int = self._get_status_int()
421
if status_int // 100 in (2, 3):
422
if env['PATH_INFO'][-1] != '/':
423
resp = HTTPMovedPermanently(
424
location=env['PATH_INFO'] + '/')
425
self._log_response(env, resp.status_int)
426
return resp(env, start_response)
427
start_response(self._response_status, self._response_headers,
428
self._response_exc_info)
430
if status_int == 404:
431
if env['PATH_INFO'][-1] != '/':
432
tmp_env = self._get_escalated_env(env)
433
tmp_env['REQUEST_METHOD'] = 'GET'
434
tmp_env['PATH_INFO'] = '/%s/%s/%s' % (self.version,
435
self.account, self.container)
436
tmp_env['QUERY_STRING'] = 'limit=1&format=json&delimiter' \
437
'=/&limit=1&prefix=%s' % quote(self.obj + '/')
438
resp = self.app(tmp_env, self._start_response)
439
if self._get_status_int() // 100 != 2 or \
440
not json.loads(''.join(resp)):
441
resp = HTTPNotFound()(env, self._start_response)
442
return self._error_response(resp, env, start_response)
443
resp = HTTPMovedPermanently(location=env['PATH_INFO'] +
445
self._log_response(env, resp.status_int)
446
return resp(env, start_response)
447
return self._listing(env, start_response, self.obj)
449
def __call__(self, env, start_response):
451
Main hook into the WSGI paste.deploy filter/app pipeline.
453
:param env: The WSGI environment dict.
454
:param start_response: The WSGI start_response hook.
456
env['staticweb.start_time'] = time.time()
458
(self.version, self.account, self.container, self.obj) = \
459
split_path(env['PATH_INFO'], 2, 4, True)
461
return self.app(env, start_response)
462
memcache_client = cache_from_env(env)
464
if env['REQUEST_METHOD'] in ('PUT', 'POST'):
465
if not self.obj and self.container:
466
memcache_key = '/staticweb/%s/%s/%s' % \
467
(self.version, self.account, self.container)
468
memcache_client.delete(memcache_key)
469
return self.app(env, start_response)
470
if (env['REQUEST_METHOD'] not in ('HEAD', 'GET') or
471
(env.get('REMOTE_USER') and
472
env.get('HTTP_X_WEB_MODE', 'f').lower() not in TRUE_VALUES) or
473
(not env.get('REMOTE_USER') and
474
env.get('HTTP_X_WEB_MODE', 't').lower() not in TRUE_VALUES)):
475
return self.app(env, start_response)
477
return self._handle_object(env, start_response)
479
return self._handle_container(env, start_response)
480
return self.app(env, start_response)
482
def _log_response(self, env, status_int):
484
Logs an access line for StaticWeb responses; use when the next app in
485
the pipeline will not be handling the final response to the remote
488
Assumes that the request and response bodies are 0 bytes or very near 0
489
so no bytes transferred are tracked or logged.
491
This does mean that the listings responses that actually do transfer
492
content will not be logged with any bytes transferred, but in counter
493
to that the full bytes for the underlying listing will be logged by the
494
proxy even if the remote client disconnects early for the StaticWeb
497
I didn't think the extra complexity of getting the bytes transferred
498
exactly correct for these requests was worth it, but perhaps someone
499
else will think it is.
501
To get things exact, this filter would need to use an
502
eventlet.posthooks logger like the proxy does and any log processing
503
systems would need to ignore some (but not all) proxy requests made by
504
StaticWeb if they were just interested in the bytes transferred to the
507
trans_time = '%.4f' % (time.time() -
508
env.get('staticweb.start_time', time.time()))
509
the_request = quote(unquote(env['PATH_INFO']))
510
if env.get('QUERY_STRING'):
511
the_request = the_request + '?' + env['QUERY_STRING']
512
# remote user for zeus
513
client = env.get('HTTP_X_CLUSTER_CLIENT_IP')
514
if not client and 'HTTP_X_FORWARDED_FOR' in env:
515
# remote user for other lbs
516
client = env['HTTP_X_FORWARDED_FOR'].split(',')[0].strip()
517
logged_headers = None
519
logged_headers = '\n'.join('%s: %s' % (k, v)
520
for k, v in req.headers.items())
521
self.access_logger.info(' '.join(quote(str(x)) for x in (
523
env.get('REMOTE_ADDR', '-'),
524
time.strftime('%d/%b/%Y/%H/%M/%S', time.gmtime()),
525
env['REQUEST_METHOD'],
527
env['SERVER_PROTOCOL'],
529
env.get('HTTP_REFERER', '-'),
530
env.get('HTTP_USER_AGENT', '-'),
531
env.get('HTTP_X_AUTH_TOKEN', '-'),
534
env.get('HTTP_ETAG', '-'),
535
env.get('HTTP_X_CF_TRANS_ID', '-'),
536
logged_headers or '-',
540
def filter_factory(global_conf, **local_conf):
541
""" Returns a Static Web WSGI filter for use with paste.deploy. """
542
conf = global_conf.copy()
543
conf.update(local_conf)
545
def staticweb_filter(app):
546
return StaticWeb(app, conf)
547
return staticweb_filter