# Copyright (c) 2017 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or # implied. # See the License for the specific language governing permissions and # limitations under the License. import json import six from xml.etree.cElementTree import Element, SubElement, tostring from swift.common.constraints import valid_api_version from swift.common.http import HTTP_NO_CONTENT from swift.common.request_helpers import get_param from swift.common.swob import HTTPException, HTTPNotAcceptable, Request, \ RESPONSE_REASONS, HTTPBadRequest #: Mapping of query string ``format=`` values to their corresponding #: content-type values. FORMAT2CONTENT_TYPE = {'plain': 'text/plain', 'json': 'application/json', 'xml': 'application/xml'} #: Maximum size of a valid JSON container listing body. If we receive #: a container listing response larger than this, assume it's a staticweb #: response and pass it on to the client. # Default max object length is 1024, default container listing limit is 1e4; # add a fudge factor for things like hash, last_modified, etc. MAX_CONTAINER_LISTING_CONTENT_LENGTH = 1024 * 10000 * 2 def get_listing_content_type(req): """ Determine the content type to use for an account or container listing response. :param req: request object :returns: content type as a string (e.g. text/plain, application/json) :raises HTTPNotAcceptable: if the requested content type is not acceptable :raises HTTPBadRequest: if the 'format' query param is provided and not valid UTF-8 """ query_format = get_param(req, 'format') if query_format: req.accept = FORMAT2CONTENT_TYPE.get( query_format.lower(), FORMAT2CONTENT_TYPE['plain']) try: out_content_type = req.accept.best_match( ['text/plain', 'application/json', 'application/xml', 'text/xml']) except ValueError: raise HTTPBadRequest(request=req, body='Invalid Accept header') if not out_content_type: raise HTTPNotAcceptable(request=req) return out_content_type def account_to_xml(listing, account_name): doc = Element('account', name=account_name.decode('utf-8')) doc.text = '\n' for record in listing: if 'subdir' in record: name = record.pop('subdir') sub = SubElement(doc, 'subdir', name=name) else: sub = SubElement(doc, 'container') for field in ('name', 'count', 'bytes', 'last_modified'): SubElement(sub, field).text = six.text_type( record.pop(field)) sub.tail = '\n' return tostring(doc, encoding='UTF-8').replace( "", '', 1) def container_to_xml(listing, base_name): doc = Element('container', name=base_name.decode('utf-8')) for record in listing: if 'subdir' in record: name = record.pop('subdir') sub = SubElement(doc, 'subdir', name=name) SubElement(sub, 'name').text = name else: sub = SubElement(doc, 'object') for field in ('name', 'hash', 'bytes', 'content_type', 'last_modified'): SubElement(sub, field).text = six.text_type( record.pop(field)) return tostring(doc, encoding='UTF-8').replace( "", '', 1) def listing_to_text(listing): def get_lines(): for item in listing: if 'name' in item: yield item['name'].encode('utf-8') + b'\n' else: yield item['subdir'].encode('utf-8') + b'\n' return b''.join(get_lines()) class ListingFilter(object): def __init__(self, app): self.app = app def __call__(self, env, start_response): req = Request(env) try: # account and container only version, acct, cont = req.split_path(2, 3) except ValueError: return self.app(env, start_response) if not valid_api_version(version) or req.method not in ('GET', 'HEAD'): return self.app(env, start_response) # OK, definitely have an account/container request. # Get the desired content-type, then force it to a JSON request. try: out_content_type = get_listing_content_type(req) except HTTPException as err: return err(env, start_response) params = req.params params['format'] = 'json' req.params = params status, headers, resp_iter = req.call_application(self.app) header_to_index = {} resp_content_type = resp_length = None for i, (header, value) in enumerate(headers): header = header.lower() if header == 'content-type': header_to_index[header] = i resp_content_type = value.partition(';')[0] elif header == 'content-length': header_to_index[header] = i resp_length = int(value) if not status.startswith('200 '): start_response(status, headers) return resp_iter if resp_content_type != 'application/json': start_response(status, headers) return resp_iter if resp_length is None or \ resp_length > MAX_CONTAINER_LISTING_CONTENT_LENGTH: start_response(status, headers) return resp_iter def set_header(header, value): if value is None: del headers[header_to_index[header]] else: headers[header_to_index[header]] = ( headers[header_to_index[header]][0], str(value)) if req.method == 'HEAD': set_header('content-type', out_content_type + '; charset=utf-8') set_header('content-length', None) # don't know, can't determine start_response(status, headers) return resp_iter body = b''.join(resp_iter) try: listing = json.loads(body) # Do a couple sanity checks if not isinstance(listing, list): raise ValueError if not all(isinstance(item, dict) for item in listing): raise ValueError except ValueError: # Static web listing that's returning invalid JSON? # Just pass it straight through; that's about all we *can* do. start_response(status, headers) return [body] try: if out_content_type.endswith('/xml'): if cont: body = container_to_xml(listing, cont) else: body = account_to_xml(listing, acct) elif out_content_type == 'text/plain': body = listing_to_text(listing) # else, json -- we continue down here to be sure we set charset except KeyError: # listing was in a bad format -- funky static web listing?? start_response(status, headers) return [body] if not body: status = '%s %s' % (HTTP_NO_CONTENT, RESPONSE_REASONS[HTTP_NO_CONTENT][0]) set_header('content-type', out_content_type + '; charset=utf-8') set_header('content-length', len(body)) start_response(status, headers) return [body] def filter_factory(global_conf, **local_conf): return ListingFilter