Statistics
| Branch: | Tag: | Revision:

root / snf-pithos-app / pithos / api / util.py @ 3248f20a

History | View | Annotate | Download (41.2 kB)

1
# Copyright 2011-2012 GRNET S.A. All rights reserved.
2
#
3
# Redistribution and use in source and binary forms, with or
4
# without modification, are permitted provided that the following
5
# conditions are met:
6
#
7
#   1. Redistributions of source code must retain the above
8
#      copyright notice, this list of conditions and the following
9
#      disclaimer.
10
#
11
#   2. Redistributions in binary form must reproduce the above
12
#      copyright notice, this list of conditions and the following
13
#      disclaimer in the documentation and/or other materials
14
#      provided with the distribution.
15
#
16
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
# POSSIBILITY OF SUCH DAMAGE.
28
#
29
# The views and conclusions contained in the software and
30
# documentation are those of the authors and should not be
31
# interpreted as representing official policies, either expressed
32
# or implied, of GRNET S.A.
33

    
34
from functools import wraps
35
from datetime import datetime
36
from urllib import quote, unquote
37

    
38
from django.http import HttpResponse, Http404, HttpResponseForbidden
39
from django.template.loader import render_to_string
40
from django.utils import simplejson as json
41
from django.utils.http import http_date, parse_etags
42
from django.utils.encoding import smart_unicode, smart_str
43
from django.core.files.uploadhandler import FileUploadHandler
44
from django.core.files.uploadedfile import UploadedFile
45
from django.core.urlresolvers import reverse
46

    
47
from snf_django.lib.api.parsedate import parse_http_date_safe, parse_http_date
48
from snf_django.lib import api
49
from snf_django.lib.api import faults, utils
50

    
51
from pithos.api.settings import (BACKEND_DB_MODULE, BACKEND_DB_CONNECTION,
52
                                 BACKEND_BLOCK_MODULE, BACKEND_BLOCK_PATH,
53
                                 BACKEND_BLOCK_UMASK,
54
                                 BACKEND_QUEUE_MODULE, BACKEND_QUEUE_HOSTS,
55
                                 BACKEND_QUEUE_EXCHANGE,
56
                                 ASTAKOSCLIENT_POOLSIZE,
57
                                 SERVICE_TOKEN,
58
                                 ASTAKOS_BASE_URL,
59
                                 BACKEND_ACCOUNT_QUOTA, BACKEND_CONTAINER_QUOTA,
60
                                 BACKEND_VERSIONING,
61
                                 BACKEND_FREE_VERSIONING,
62
                                 BACKEND_POOL_ENABLED, BACKEND_POOL_SIZE,
63
                                 BACKEND_BLOCK_SIZE, BACKEND_HASH_ALGORITHM,
64
                                 RADOS_STORAGE, RADOS_POOL_BLOCKS,
65
                                 RADOS_POOL_MAPS, TRANSLATE_UUIDS,
66
                                 PUBLIC_URL_SECURITY, PUBLIC_URL_ALPHABET,
67
                                 COOKIE_NAME, BASE_HOST, UPDATE_MD5)
68
from pithos.api.resources import resources
69
from pithos.backends import connect_backend
70
from pithos.backends.base import (NotAllowedError, QuotaError, ItemNotExists,
71
                                  VersionNotExists)
72

    
73
from synnefo.lib import join_urls
74

    
75
from astakosclient import AstakosClient
76
from astakosclient.errors import NoUserName, NoUUID
77

    
78
import logging
79
import re
80
import hashlib
81
import uuid
82
import decimal
83

    
84
logger = logging.getLogger(__name__)
85

    
86

    
87
def json_encode_decimal(obj):
88
    if isinstance(obj, decimal.Decimal):
89
        return str(obj)
90
    raise TypeError(repr(obj) + " is not JSON serializable")
91

    
92

    
93
def rename_meta_key(d, old, new):
94
    if old not in d:
95
        return
96
    d[new] = d[old]
97
    del(d[old])
98

    
99

    
100
def printable_header_dict(d):
101
    """Format a meta dictionary for printing out json/xml.
102

103
    Convert all keys to lower case and replace dashes with underscores.
104
    Format 'last_modified' timestamp.
105
    """
106

    
107
    if 'last_modified' in d and d['last_modified']:
108
        d['last_modified'] = utils.isoformat(
109
            datetime.fromtimestamp(d['last_modified']))
110
    return dict([(k.lower().replace('-', '_'), v) for k, v in d.iteritems()])
111

    
112

    
113
def format_header_key(k):
114
    """Convert underscores to dashes and capitalize intra-dash strings."""
115
    return '-'.join([x.capitalize() for x in k.replace('_', '-').split('-')])
116

    
117

    
118
def get_header_prefix(request, prefix):
119
    """Get all prefix-* request headers in a dict.
120
       Reformat keys with format_header_key()."""
121

    
122
    prefix = 'HTTP_' + prefix.upper().replace('-', '_')
123
    # TODO: Document or remove '~' replacing.
124
    return dict([(format_header_key(k[5:]), v.replace('~', ''))
125
                for k, v in request.META.iteritems()
126
                if k.startswith(prefix) and len(k) > len(prefix)])
127

    
128

    
129
def check_meta_headers(meta):
130
    if len(meta) > 90:
131
        raise faults.BadRequest('Too many headers.')
132
    for k, v in meta.iteritems():
133
        if len(k) > 128:
134
            raise faults.BadRequest('Header name too large.')
135
        if len(v) > 256:
136
            raise faults.BadRequest('Header value too large.')
137

    
138

    
139
def get_account_headers(request):
140
    meta = get_header_prefix(request, 'X-Account-Meta-')
141
    check_meta_headers(meta)
142
    groups = {}
143
    for k, v in get_header_prefix(request, 'X-Account-Group-').iteritems():
144
        n = k[16:].lower()
145
        if '-' in n or '_' in n:
146
            raise faults.BadRequest('Bad characters in group name')
147
        groups[n] = v.replace(' ', '').split(',')
148
        while '' in groups[n]:
149
            groups[n].remove('')
150
    return meta, groups
151

    
152

    
153
def put_account_headers(response, meta, groups, policy):
154
    if 'count' in meta:
155
        response['X-Account-Container-Count'] = meta['count']
156
    if 'bytes' in meta:
157
        response['X-Account-Bytes-Used'] = meta['bytes']
158
    response['Last-Modified'] = http_date(int(meta['modified']))
159
    for k in [x for x in meta.keys() if x.startswith('X-Account-Meta-')]:
160
        response[smart_str(
161
            k, strings_only=True)] = smart_str(meta[k], strings_only=True)
162
    if 'until_timestamp' in meta:
163
        response['X-Account-Until-Timestamp'] = http_date(
164
            int(meta['until_timestamp']))
165
    for k, v in groups.iteritems():
166
        k = smart_str(k, strings_only=True)
167
        k = format_header_key('X-Account-Group-' + k)
168
        v = smart_str(','.join(v), strings_only=True)
169
        response[k] = v
170
    for k, v in policy.iteritems():
171
        response[smart_str(format_header_key('X-Account-Policy-' + k),
172
                 strings_only=True)] = smart_str(v, strings_only=True)
173

    
174

    
175
def get_container_headers(request):
176
    meta = get_header_prefix(request, 'X-Container-Meta-')
177
    check_meta_headers(meta)
178
    policy = dict([(k[19:].lower(), v.replace(' ', '')) for k, v in
179
                  get_header_prefix(request,
180
                                    'X-Container-Policy-').iteritems()])
181
    return meta, policy
182

    
183

    
184
def put_container_headers(request, response, meta, policy):
185
    if 'count' in meta:
186
        response['X-Container-Object-Count'] = meta['count']
187
    if 'bytes' in meta:
188
        response['X-Container-Bytes-Used'] = meta['bytes']
189
    response['Last-Modified'] = http_date(int(meta['modified']))
190
    for k in [x for x in meta.keys() if x.startswith('X-Container-Meta-')]:
191
        response[smart_str(
192
            k, strings_only=True)] = smart_str(meta[k], strings_only=True)
193
    l = [smart_str(x, strings_only=True) for x in meta['object_meta']
194
         if x.startswith('X-Object-Meta-')]
195
    response['X-Container-Object-Meta'] = ','.join([x[14:] for x in l])
196
    response['X-Container-Block-Size'] = request.backend.block_size
197
    response['X-Container-Block-Hash'] = request.backend.hash_algorithm
198
    if 'until_timestamp' in meta:
199
        response['X-Container-Until-Timestamp'] = http_date(
200
            int(meta['until_timestamp']))
201
    for k, v in policy.iteritems():
202
        response[smart_str(format_header_key('X-Container-Policy-' + k),
203
                           strings_only=True)] = smart_str(v, strings_only=True)
204

    
205

    
206
def get_object_headers(request):
207
    content_type = request.META.get('CONTENT_TYPE', None)
208
    meta = get_header_prefix(request, 'X-Object-Meta-')
209
    check_meta_headers(meta)
210
    if request.META.get('HTTP_CONTENT_ENCODING'):
211
        meta['Content-Encoding'] = request.META['HTTP_CONTENT_ENCODING']
212
    if request.META.get('HTTP_CONTENT_DISPOSITION'):
213
        meta['Content-Disposition'] = request.META['HTTP_CONTENT_DISPOSITION']
214
    if request.META.get('HTTP_X_OBJECT_MANIFEST'):
215
        meta['X-Object-Manifest'] = request.META['HTTP_X_OBJECT_MANIFEST']
216
    return content_type, meta, get_sharing(request), get_public(request)
217

    
218

    
219
def put_object_headers(response, meta, restricted=False, token=None):
220
    response['ETag'] = meta['hash'] if not UPDATE_MD5 else meta['checksum']
221
    response['Content-Length'] = meta['bytes']
222
    response.override_serialization = True
223
    response['Content-Type'] = meta.get('type', 'application/octet-stream')
224
    response['Last-Modified'] = http_date(int(meta['modified']))
225
    if not restricted:
226
        response['X-Object-Hash'] = meta['hash']
227
        response['X-Object-UUID'] = meta['uuid']
228
        if TRANSLATE_UUIDS:
229
            meta['modified_by'] = \
230
                retrieve_displayname(token, meta['modified_by'])
231
        response['X-Object-Modified-By'] = smart_str(
232
            meta['modified_by'], strings_only=True)
233
        response['X-Object-Version'] = meta['version']
234
        response['X-Object-Version-Timestamp'] = http_date(
235
            int(meta['version_timestamp']))
236
        for k in [x for x in meta.keys() if x.startswith('X-Object-Meta-')]:
237
            response[smart_str(
238
                k, strings_only=True)] = smart_str(meta[k], strings_only=True)
239
        for k in (
240
            'Content-Encoding', 'Content-Disposition', 'X-Object-Manifest',
241
            'X-Object-Sharing', 'X-Object-Shared-By', 'X-Object-Allowed-To',
242
                'X-Object-Public'):
243
            if k in meta:
244
                response[k] = smart_str(meta[k], strings_only=True)
245
    else:
246
        for k in ('Content-Encoding', 'Content-Disposition'):
247
            if k in meta:
248
                response[k] = smart_str(meta[k], strings_only=True)
249

    
250

    
251
def update_manifest_meta(request, v_account, meta):
252
    """Update metadata if the object has an X-Object-Manifest."""
253

    
254
    if 'X-Object-Manifest' in meta:
255
        etag = ''
256
        bytes = 0
257
        try:
258
            src_container, src_name = split_container_object_string(
259
                '/' + meta['X-Object-Manifest'])
260
            objects = request.backend.list_objects(
261
                request.user_uniq, v_account,
262
                src_container, prefix=src_name, virtual=False)
263
            for x in objects:
264
                src_meta = request.backend.get_object_meta(
265
                    request.user_uniq, v_account, src_container, x[0],
266
                    'pithos', x[1])
267
                etag += (src_meta['hash'] if not UPDATE_MD5 else
268
                         src_meta['checksum'])
269
                bytes += src_meta['bytes']
270
        except:
271
            # Ignore errors.
272
            return
273
        meta['bytes'] = bytes
274
        md5 = hashlib.md5()
275
        md5.update(etag)
276
        meta['checksum'] = md5.hexdigest().lower()
277

    
278

    
279
def is_uuid(str):
280
    if str is None:
281
        return False
282
    try:
283
        uuid.UUID(str)
284
    except ValueError:
285
        return False
286
    else:
287
        return True
288

    
289

    
290
##########################
291
# USER CATALOG utilities #
292
##########################
293

    
294
def retrieve_displayname(token, uuid, fail_silently=True):
295
    astakos = AstakosClient(ASTAKOS_BASE_URL, retry=2, use_pool=True,
296
                            logger=logger)
297
    try:
298
        displayname = astakos.get_username(token, uuid)
299
    except NoUserName:
300
        if not fail_silently:
301
            raise ItemNotExists(uuid)
302
        else:
303
            # just return the uuid
304
            return uuid
305
    return displayname
306

    
307

    
308
def retrieve_displaynames(token, uuids, return_dict=False, fail_silently=True):
309
    astakos = AstakosClient(ASTAKOS_BASE_URL, retry=2, use_pool=True,
310
                            logger=logger)
311
    catalog = astakos.get_usernames(token, uuids) or {}
312
    missing = list(set(uuids) - set(catalog))
313
    if missing and not fail_silently:
314
        raise ItemNotExists('Unknown displaynames: %s' % ', '.join(missing))
315
    return catalog if return_dict else [catalog.get(i) for i in uuids]
316

    
317

    
318
def retrieve_uuid(token, displayname):
319
    if is_uuid(displayname):
320
        return displayname
321

    
322
    astakos = AstakosClient(ASTAKOS_BASE_URL, retry=2, use_pool=True,
323
                            logger=logger)
324
    try:
325
        uuid = astakos.get_uuid(token, displayname)
326
    except NoUUID:
327
        raise ItemNotExists(displayname)
328
    return uuid
329

    
330

    
331
def retrieve_uuids(token, displaynames, return_dict=False, fail_silently=True):
332
    astakos = AstakosClient(ASTAKOS_BASE_URL, retry=2, use_pool=True,
333
                            logger=logger)
334
    catalog = astakos.get_uuids(token, displaynames) or {}
335
    missing = list(set(displaynames) - set(catalog))
336
    if missing and not fail_silently:
337
        raise ItemNotExists('Unknown uuids: %s' % ', '.join(missing))
338
    return catalog if return_dict else [catalog.get(i) for i in displaynames]
339

    
340

    
341
def replace_permissions_displayname(token, holder):
342
    if holder == '*':
343
        return holder
344
    try:
345
        # check first for a group permission
346
        account, group = holder.split(':', 1)
347
    except ValueError:
348
        return retrieve_uuid(token, holder)
349
    else:
350
        return ':'.join([retrieve_uuid(token, account), group])
351

    
352

    
353
def replace_permissions_uuid(token, holder):
354
    if holder == '*':
355
        return holder
356
    try:
357
        # check first for a group permission
358
        account, group = holder.split(':', 1)
359
    except ValueError:
360
        return retrieve_displayname(token, holder)
361
    else:
362
        return ':'.join([retrieve_displayname(token, account), group])
363

    
364

    
365
def update_sharing_meta(request, permissions, v_account,
366
                        v_container, v_object, meta):
367
    if permissions is None:
368
        return
369
    allowed, perm_path, perms = permissions
370
    if len(perms) == 0:
371
        return
372

    
373
    # replace uuid with displayname
374
    if TRANSLATE_UUIDS:
375
        perms['read'] = [replace_permissions_uuid(
376
            getattr(request, 'token', None), x)
377
            for x in perms.get('read', [])]
378
        perms['write'] = [replace_permissions_uuid(
379
            getattr(request, 'token', None), x)
380
            for x in perms.get('write', [])]
381

    
382
    ret = []
383

    
384
    r = ','.join(perms.get('read', []))
385
    if r:
386
        ret.append('read=' + r)
387
    w = ','.join(perms.get('write', []))
388
    if w:
389
        ret.append('write=' + w)
390
    meta['X-Object-Sharing'] = '; '.join(ret)
391
    if '/'.join((v_account, v_container, v_object)) != perm_path:
392
        meta['X-Object-Shared-By'] = perm_path
393
    if request.user_uniq != v_account:
394
        meta['X-Object-Allowed-To'] = allowed
395

    
396

    
397
def update_public_meta(public, meta):
398
    if not public:
399
        return
400
    meta['X-Object-Public'] = join_urls(
401
        BASE_HOST, reverse('pithos.api.public.public_demux', args=(public,)))
402

    
403

    
404
def validate_modification_preconditions(request, meta):
405
    """Check that the modified timestamp conforms with the preconditions set."""
406

    
407
    if 'modified' not in meta:
408
        return  # TODO: Always return?
409

    
410
    if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE')
411
    if if_modified_since is not None:
412
        if_modified_since = parse_http_date_safe(if_modified_since)
413
    if (if_modified_since is not None
414
            and int(meta['modified']) <= if_modified_since):
415
        raise faults.NotModified('Resource has not been modified')
416

    
417
    if_unmodified_since = request.META.get('HTTP_IF_UNMODIFIED_SINCE')
418
    if if_unmodified_since is not None:
419
        if_unmodified_since = parse_http_date_safe(if_unmodified_since)
420
    if (if_unmodified_since is not None
421
            and int(meta['modified']) > if_unmodified_since):
422
        raise faults.PreconditionFailed('Resource has been modified')
423

    
424

    
425
def validate_matching_preconditions(request, meta):
426
    """Check that the ETag conforms with the preconditions set."""
427

    
428
    etag = meta['hash'] if not UPDATE_MD5 else meta['checksum']
429
    if not etag:
430
        etag = None
431

    
432
    if_match = request.META.get('HTTP_IF_MATCH')
433
    if if_match is not None:
434
        if etag is None:
435
            raise faults.PreconditionFailed('Resource does not exist')
436
        if (if_match != '*'
437
                and etag not in [x.lower() for x in parse_etags(if_match)]):
438
            raise faults.PreconditionFailed('Resource ETag does not match')
439

    
440
    if_none_match = request.META.get('HTTP_IF_NONE_MATCH')
441
    if if_none_match is not None:
442
        # TODO: If this passes, must ignore If-Modified-Since header.
443
        if etag is not None:
444
            if (if_none_match == '*'
445
                    or etag in [x.lower() for x in parse_etags(if_none_match)]):
446
                # TODO: Continue if an If-Modified-Since header is present.
447
                if request.method in ('HEAD', 'GET'):
448
                    raise faults.NotModified('Resource ETag matches')
449
                raise faults.PreconditionFailed(
450
                    'Resource exists or ETag matches')
451

    
452

    
453
def split_container_object_string(s):
454
    if not len(s) > 0 or s[0] != '/':
455
        raise ValueError
456
    s = s[1:]
457
    pos = s.find('/')
458
    if pos == -1 or pos == len(s) - 1:
459
        raise ValueError
460
    return s[:pos], s[(pos + 1):]
461

    
462

    
463
def copy_or_move_object(request, src_account, src_container, src_name,
464
                        dest_account, dest_container, dest_name,
465
                        move=False, delimiter=None):
466
    """Copy or move an object."""
467

    
468
    if 'ignore_content_type' in request.GET and 'CONTENT_TYPE' in request.META:
469
        del(request.META['CONTENT_TYPE'])
470
    content_type, meta, permissions, public = get_object_headers(request)
471
    src_version = request.META.get('HTTP_X_SOURCE_VERSION')
472
    try:
473
        if move:
474
            version_id = request.backend.move_object(
475
                request.user_uniq, src_account, src_container, src_name,
476
                dest_account, dest_container, dest_name,
477
                content_type, 'pithos', meta, False, permissions, delimiter)
478
        else:
479
            version_id = request.backend.copy_object(
480
                request.user_uniq, src_account, src_container, src_name,
481
                dest_account, dest_container, dest_name,
482
                content_type, 'pithos', meta, False, permissions,
483
                src_version, delimiter)
484
    except NotAllowedError:
485
        raise faults.Forbidden('Not allowed')
486
    except (ItemNotExists, VersionNotExists):
487
        raise faults.ItemNotFound('Container or object does not exist')
488
    except ValueError:
489
        raise faults.BadRequest('Invalid sharing header')
490
    except QuotaError, e:
491
        raise faults.RequestEntityTooLarge('Quota error: %s' % e)
492
    if public is not None:
493
        try:
494
            request.backend.update_object_public(
495
                request.user_uniq, dest_account,
496
                dest_container, dest_name, public)
497
        except NotAllowedError:
498
            raise faults.Forbidden('Not allowed')
499
        except ItemNotExists:
500
            raise faults.ItemNotFound('Object does not exist')
501
    return version_id
502

    
503

    
504
def get_int_parameter(p):
505
    if p is not None:
506
        try:
507
            p = int(p)
508
        except ValueError:
509
            return None
510
        if p < 0:
511
            return None
512
    return p
513

    
514

    
515
def get_content_length(request):
516
    content_length = get_int_parameter(request.META.get('CONTENT_LENGTH'))
517
    if content_length is None:
518
        raise faults.LengthRequired('Missing or invalid Content-Length header')
519
    return content_length
520

    
521

    
522
def get_range(request, size):
523
    """Parse a Range header from the request.
524

525
    Either returns None, when the header is not existent or should be ignored,
526
    or a list of (offset, length) tuples - should be further checked.
527
    """
528

    
529
    ranges = request.META.get('HTTP_RANGE', '').replace(' ', '')
530
    if not ranges.startswith('bytes='):
531
        return None
532

    
533
    ret = []
534
    for r in (x.strip() for x in ranges[6:].split(',')):
535
        p = re.compile('^(?P<offset>\d*)-(?P<upto>\d*)$')
536
        m = p.match(r)
537
        if not m:
538
            return None
539
        offset = m.group('offset')
540
        upto = m.group('upto')
541
        if offset == '' and upto == '':
542
            return None
543

    
544
        if offset != '':
545
            offset = int(offset)
546
            if upto != '':
547
                upto = int(upto)
548
                if offset > upto:
549
                    return None
550
                ret.append((offset, upto - offset + 1))
551
            else:
552
                ret.append((offset, size - offset))
553
        else:
554
            length = int(upto)
555
            ret.append((size - length, length))
556

    
557
    return ret
558

    
559

    
560
def get_content_range(request):
561
    """Parse a Content-Range header from the request.
562

563
    Either returns None, when the header is not existent or should be ignored,
564
    or an (offset, length, total) tuple - check as length, total may be None.
565
    Returns (None, None, None) if the provided range is '*/*'.
566
    """
567

    
568
    ranges = request.META.get('HTTP_CONTENT_RANGE', '')
569
    if not ranges:
570
        return None
571

    
572
    p = re.compile('^bytes (?P<offset>\d+)-(?P<upto>\d*)/(?P<total>(\d+|\*))$')
573
    m = p.match(ranges)
574
    if not m:
575
        if ranges == 'bytes */*':
576
            return (None, None, None)
577
        return None
578
    offset = int(m.group('offset'))
579
    upto = m.group('upto')
580
    total = m.group('total')
581
    if upto != '':
582
        upto = int(upto)
583
    else:
584
        upto = None
585
    if total != '*':
586
        total = int(total)
587
    else:
588
        total = None
589
    if (upto is not None and offset > upto) or \
590
        (total is not None and offset >= total) or \
591
            (total is not None and upto is not None and upto >= total):
592
        return None
593

    
594
    if upto is None:
595
        length = None
596
    else:
597
        length = upto - offset + 1
598
    return (offset, length, total)
599

    
600

    
601
def get_sharing(request):
602
    """Parse an X-Object-Sharing header from the request.
603

604
    Raises BadRequest on error.
605
    """
606

    
607
    permissions = request.META.get('HTTP_X_OBJECT_SHARING')
608
    if permissions is None:
609
        return None
610

    
611
    # TODO: Document or remove '~' replacing.
612
    permissions = permissions.replace('~', '')
613

    
614
    ret = {}
615
    permissions = permissions.replace(' ', '')
616
    if permissions == '':
617
        return ret
618
    for perm in (x for x in permissions.split(';')):
619
        if perm.startswith('read='):
620
            ret['read'] = list(set(
621
                [v.replace(' ', '').lower() for v in perm[5:].split(',')]))
622
            if '' in ret['read']:
623
                ret['read'].remove('')
624
            if '*' in ret['read']:
625
                ret['read'] = ['*']
626
            if len(ret['read']) == 0:
627
                raise faults.BadRequest(
628
                    'Bad X-Object-Sharing header value: invalid length')
629
        elif perm.startswith('write='):
630
            ret['write'] = list(set(
631
                [v.replace(' ', '').lower() for v in perm[6:].split(',')]))
632
            if '' in ret['write']:
633
                ret['write'].remove('')
634
            if '*' in ret['write']:
635
                ret['write'] = ['*']
636
            if len(ret['write']) == 0:
637
                raise faults.BadRequest(
638
                    'Bad X-Object-Sharing header value: invalid length')
639
        else:
640
            raise faults.BadRequest(
641
                'Bad X-Object-Sharing header value: missing prefix')
642

    
643
    # replace displayname with uuid
644
    if TRANSLATE_UUIDS:
645
        try:
646
            ret['read'] = [replace_permissions_displayname(
647
                getattr(request, 'token', None), x)
648
                for x in ret.get('read', [])]
649
            ret['write'] = [replace_permissions_displayname(
650
                getattr(request, 'token', None), x)
651
                for x in ret.get('write', [])]
652
        except ItemNotExists, e:
653
            raise faults.BadRequest(
654
                'Bad X-Object-Sharing header value: unknown account: %s' % e)
655

    
656
    # Keep duplicates only in write list.
657
    dups = [x for x in ret.get(
658
        'read', []) if x in ret.get('write', []) and x != '*']
659
    if dups:
660
        for x in dups:
661
            ret['read'].remove(x)
662
        if len(ret['read']) == 0:
663
            del(ret['read'])
664

    
665
    return ret
666

    
667

    
668
def get_public(request):
669
    """Parse an X-Object-Public header from the request.
670

671
    Raises BadRequest on error.
672
    """
673

    
674
    public = request.META.get('HTTP_X_OBJECT_PUBLIC')
675
    if public is None:
676
        return None
677

    
678
    public = public.replace(' ', '').lower()
679
    if public == 'true':
680
        return True
681
    elif public == 'false' or public == '':
682
        return False
683
    raise faults.BadRequest('Bad X-Object-Public header value')
684

    
685

    
686
def raw_input_socket(request):
687
    """Return the socket for reading the rest of the request."""
688

    
689
    server_software = request.META.get('SERVER_SOFTWARE')
690
    if server_software and server_software.startswith('mod_python'):
691
        return request._req
692
    if 'wsgi.input' in request.environ:
693
        return request.environ['wsgi.input']
694
    raise NotImplemented('Unknown server software')
695

    
696
MAX_UPLOAD_SIZE = 5 * (1024 * 1024 * 1024)  # 5GB
697

    
698

    
699
def socket_read_iterator(request, length=0, blocksize=4096):
700
    """Return a maximum of blocksize data read from the socket in each iteration
701

702
    Read up to 'length'. If 'length' is negative, will attempt a chunked read.
703
    The maximum ammount of data read is controlled by MAX_UPLOAD_SIZE.
704
    """
705

    
706
    sock = raw_input_socket(request)
707
    if length < 0:  # Chunked transfers
708
        # Small version (server does the dechunking).
709
        if (request.environ.get('mod_wsgi.input_chunked', None)
710
                or request.META['SERVER_SOFTWARE'].startswith('gunicorn')):
711
            while length < MAX_UPLOAD_SIZE:
712
                data = sock.read(blocksize)
713
                if data == '':
714
                    return
715
                yield data
716
            raise faults.BadRequest('Maximum size is reached')
717

    
718
        # Long version (do the dechunking).
719
        data = ''
720
        while length < MAX_UPLOAD_SIZE:
721
            # Get chunk size.
722
            if hasattr(sock, 'readline'):
723
                chunk_length = sock.readline()
724
            else:
725
                chunk_length = ''
726
                while chunk_length[-1:] != '\n':
727
                    chunk_length += sock.read(1)
728
                chunk_length.strip()
729
            pos = chunk_length.find(';')
730
            if pos >= 0:
731
                chunk_length = chunk_length[:pos]
732
            try:
733
                chunk_length = int(chunk_length, 16)
734
            except Exception:
735
                raise faults.BadRequest('Bad chunk size')
736
                                 # TODO: Change to something more appropriate.
737
            # Check if done.
738
            if chunk_length == 0:
739
                if len(data) > 0:
740
                    yield data
741
                return
742
            # Get the actual data.
743
            while chunk_length > 0:
744
                chunk = sock.read(min(chunk_length, blocksize))
745
                chunk_length -= len(chunk)
746
                if length > 0:
747
                    length += len(chunk)
748
                data += chunk
749
                if len(data) >= blocksize:
750
                    ret = data[:blocksize]
751
                    data = data[blocksize:]
752
                    yield ret
753
            sock.read(2)  # CRLF
754
        raise faults.BadRequest('Maximum size is reached')
755
    else:
756
        if length > MAX_UPLOAD_SIZE:
757
            raise faults.BadRequest('Maximum size is reached')
758
        while length > 0:
759
            data = sock.read(min(length, blocksize))
760
            if not data:
761
                raise faults.BadRequest()
762
            length -= len(data)
763
            yield data
764

    
765

    
766
class SaveToBackendHandler(FileUploadHandler):
767
    """Handle a file from an HTML form the django way."""
768

    
769
    def __init__(self, request=None):
770
        super(SaveToBackendHandler, self).__init__(request)
771
        self.backend = request.backend
772

    
773
    def put_data(self, length):
774
        if len(self.data) >= length:
775
            block = self.data[:length]
776
            self.file.hashmap.append(self.backend.put_block(block))
777
            self.checksum_compute.update(block)
778
            self.data = self.data[length:]
779

    
780
    def new_file(self, field_name, file_name, content_type,
781
                 content_length, charset=None):
782
        self.checksum_compute = NoChecksum() if not UPDATE_MD5 else Checksum()
783
        self.data = ''
784
        self.file = UploadedFile(
785
            name=file_name, content_type=content_type, charset=charset)
786
        self.file.size = 0
787
        self.file.hashmap = []
788

    
789
    def receive_data_chunk(self, raw_data, start):
790
        self.data += raw_data
791
        self.file.size += len(raw_data)
792
        self.put_data(self.request.backend.block_size)
793
        return None
794

    
795
    def file_complete(self, file_size):
796
        l = len(self.data)
797
        if l > 0:
798
            self.put_data(l)
799
        self.file.etag = self.checksum_compute.hexdigest()
800
        return self.file
801

    
802

    
803
class ObjectWrapper(object):
804
    """Return the object's data block-per-block in each iteration.
805

806
    Read from the object using the offset and length provided
807
    in each entry of the range list.
808
    """
809

    
810
    def __init__(self, backend, ranges, sizes, hashmaps, boundary):
811
        self.backend = backend
812
        self.ranges = ranges
813
        self.sizes = sizes
814
        self.hashmaps = hashmaps
815
        self.boundary = boundary
816
        self.size = sum(self.sizes)
817

    
818
        self.file_index = 0
819
        self.block_index = 0
820
        self.block_hash = -1
821
        self.block = ''
822

    
823
        self.range_index = -1
824
        self.offset, self.length = self.ranges[0]
825

    
826
    def __iter__(self):
827
        return self
828

    
829
    def part_iterator(self):
830
        if self.length > 0:
831
            # Get the file for the current offset.
832
            file_size = self.sizes[self.file_index]
833
            while self.offset >= file_size:
834
                self.offset -= file_size
835
                self.file_index += 1
836
                file_size = self.sizes[self.file_index]
837

    
838
            # Get the block for the current position.
839
            self.block_index = int(self.offset / self.backend.block_size)
840
            if self.block_hash != \
841
                    self.hashmaps[self.file_index][self.block_index]:
842
                self.block_hash = self.hashmaps[
843
                    self.file_index][self.block_index]
844
                try:
845
                    self.block = self.backend.get_block(self.block_hash)
846
                except ItemNotExists:
847
                    raise faults.ItemNotFound('Block does not exist')
848

    
849
            # Get the data from the block.
850
            bo = self.offset % self.backend.block_size
851
            bs = self.backend.block_size
852
            if (self.block_index == len(self.hashmaps[self.file_index]) - 1 and
853
                    self.sizes[self.file_index] % self.backend.block_size):
854
                bs = self.sizes[self.file_index] % self.backend.block_size
855
            bl = min(self.length, bs - bo)
856
            data = self.block[bo:bo + bl]
857
            self.offset += bl
858
            self.length -= bl
859
            return data
860
        else:
861
            raise StopIteration
862

    
863
    def next(self):
864
        if len(self.ranges) == 1:
865
            return self.part_iterator()
866
        if self.range_index == len(self.ranges):
867
            raise StopIteration
868
        try:
869
            if self.range_index == -1:
870
                raise StopIteration
871
            return self.part_iterator()
872
        except StopIteration:
873
            self.range_index += 1
874
            out = []
875
            if self.range_index < len(self.ranges):
876
                # Part header.
877
                self.offset, self.length = self.ranges[self.range_index]
878
                self.file_index = 0
879
                if self.range_index > 0:
880
                    out.append('')
881
                out.append('--' + self.boundary)
882
                out.append('Content-Range: bytes %d-%d/%d' % (
883
                    self.offset, self.offset + self.length - 1, self.size))
884
                out.append('Content-Transfer-Encoding: binary')
885
                out.append('')
886
                out.append('')
887
                return '\r\n'.join(out)
888
            else:
889
                # Footer.
890
                out.append('')
891
                out.append('--' + self.boundary + '--')
892
                out.append('')
893
                return '\r\n'.join(out)
894

    
895

    
896
def object_data_response(request, sizes, hashmaps, meta, public=False):
897
    """Get the HttpResponse object for replying with the object's data."""
898

    
899
    # Range handling.
900
    size = sum(sizes)
901
    ranges = get_range(request, size)
902
    if ranges is None:
903
        ranges = [(0, size)]
904
        ret = 200
905
    else:
906
        check = [True for offset, length in ranges if
907
                 length <= 0 or length > size or
908
                 offset < 0 or offset >= size or
909
                 offset + length > size]
910
        if len(check) > 0:
911
            raise faults.RangeNotSatisfiable(
912
                'Requested range exceeds object limits')
913
        ret = 206
914
        if_range = request.META.get('HTTP_IF_RANGE')
915
        if if_range:
916
            try:
917
                # Modification time has passed instead.
918
                last_modified = parse_http_date(if_range)
919
                if last_modified != meta['modified']:
920
                    ranges = [(0, size)]
921
                    ret = 200
922
            except ValueError:
923
                if if_range != meta['checksum']:
924
                    ranges = [(0, size)]
925
                    ret = 200
926

    
927
    if ret == 206 and len(ranges) > 1:
928
        boundary = uuid.uuid4().hex
929
    else:
930
        boundary = ''
931
    wrapper = ObjectWrapper(request.backend, ranges, sizes, hashmaps, boundary)
932
    response = HttpResponse(wrapper, status=ret)
933
    put_object_headers(
934
        response, meta, restricted=public,
935
        token=getattr(request, 'token', None))
936
    if ret == 206:
937
        if len(ranges) == 1:
938
            offset, length = ranges[0]
939
            response[
940
                'Content-Length'] = length  # Update with the correct length.
941
            response['Content-Range'] = 'bytes %d-%d/%d' % (
942
                offset, offset + length - 1, size)
943
        else:
944
            del(response['Content-Length'])
945
            response['Content-Type'] = 'multipart/byteranges; boundary=%s' % (
946
                boundary,)
947
    return response
948

    
949

    
950
def put_object_block(request, hashmap, data, offset):
951
    """Put one block of data at the given offset."""
952

    
953
    bi = int(offset / request.backend.block_size)
954
    bo = offset % request.backend.block_size
955
    bl = min(len(data), request.backend.block_size - bo)
956
    if bi < len(hashmap):
957
        hashmap[bi] = request.backend.update_block(hashmap[bi], data[:bl], bo)
958
    else:
959
        hashmap.append(request.backend.put_block(('\x00' * bo) + data[:bl]))
960
    return bl  # Return ammount of data written.
961

    
962

    
963
def hashmap_md5(backend, hashmap, size):
964
    """Produce the MD5 sum from the data in the hashmap."""
965

    
966
    # TODO: Search backend for the MD5 of another object
967
    #       with the same hashmap and size...
968
    md5 = hashlib.md5()
969
    bs = backend.block_size
970
    for bi, hash in enumerate(hashmap):
971
        data = backend.get_block(hash)  # Blocks come in padded.
972
        if bi == len(hashmap) - 1:
973
            data = data[:size % bs]
974
        md5.update(data)
975
    return md5.hexdigest().lower()
976

    
977

    
978
def simple_list_response(request, l):
979
    if request.serialization == 'text':
980
        return '\n'.join(l) + '\n'
981
    if request.serialization == 'xml':
982
        return render_to_string('items.xml', {'items': l})
983
    if request.serialization == 'json':
984
        return json.dumps(l)
985

    
986

    
987
from pithos.backends.util import PithosBackendPool
988

    
989
if RADOS_STORAGE:
990
    BLOCK_PARAMS = {'mappool': RADOS_POOL_MAPS,
991
                    'blockpool': RADOS_POOL_BLOCKS, }
992
else:
993
    BLOCK_PARAMS = {'mappool': None,
994
                    'blockpool': None, }
995

    
996
BACKEND_KWARGS = dict(
997
    db_module=BACKEND_DB_MODULE,
998
    db_connection=BACKEND_DB_CONNECTION,
999
    block_module=BACKEND_BLOCK_MODULE,
1000
    block_path=BACKEND_BLOCK_PATH,
1001
    block_umask=BACKEND_BLOCK_UMASK,
1002
    block_size=BACKEND_BLOCK_SIZE,
1003
    hash_algorithm=BACKEND_HASH_ALGORITHM,
1004
    queue_module=BACKEND_QUEUE_MODULE,
1005
    queue_hosts=BACKEND_QUEUE_HOSTS,
1006
    queue_exchange=BACKEND_QUEUE_EXCHANGE,
1007
    astakos_url=ASTAKOS_BASE_URL,
1008
    service_token=SERVICE_TOKEN,
1009
    astakosclient_poolsize=ASTAKOSCLIENT_POOLSIZE,
1010
    free_versioning=BACKEND_FREE_VERSIONING,
1011
    block_params=BLOCK_PARAMS,
1012
    public_url_security=PUBLIC_URL_SECURITY,
1013
    public_url_alphabet=PUBLIC_URL_ALPHABET,
1014
    account_quota_policy=BACKEND_ACCOUNT_QUOTA,
1015
    container_quota_policy=BACKEND_CONTAINER_QUOTA,
1016
    container_versioning_policy=BACKEND_VERSIONING)
1017

    
1018
_pithos_backend_pool = PithosBackendPool(size=BACKEND_POOL_SIZE,
1019
                                         **BACKEND_KWARGS)
1020

    
1021

    
1022
def get_backend():
1023
    if BACKEND_POOL_ENABLED:
1024
        backend = _pithos_backend_pool.pool_get()
1025
    else:
1026
        backend = connect_backend(**BACKEND_KWARGS)
1027
    backend.serials = []
1028
    backend.messages = []
1029
    return backend
1030

    
1031

    
1032
def update_request_headers(request):
1033
    # Handle URL-encoded keys and values.
1034
    meta = dict([(
1035
        k, v) for k, v in request.META.iteritems() if k.startswith('HTTP_')])
1036
    for k, v in meta.iteritems():
1037
        try:
1038
            k.decode('ascii')
1039
            v.decode('ascii')
1040
        except UnicodeDecodeError:
1041
            raise faults.BadRequest('Bad character in headers.')
1042
        if '%' in k or '%' in v:
1043
            del(request.META[k])
1044
            request.META[unquote(k)] = smart_unicode(unquote(
1045
                v), strings_only=True)
1046

    
1047

    
1048
def update_response_headers(request, response):
1049
    # URL-encode unicode in headers.
1050
    meta = response.items()
1051
    for k, v in meta:
1052
        if (k.startswith('X-Account-') or k.startswith('X-Container-') or
1053
                k.startswith('X-Object-') or k.startswith('Content-')):
1054
            del(response[k])
1055
            response[quote(k)] = quote(v, safe='/=,:@; ')
1056

    
1057

    
1058
def get_pithos_usage(token):
1059
    """Get Pithos Usage from astakos."""
1060
    astakos = AstakosClient(ASTAKOS_BASE_URL, retry=2, use_pool=True,
1061
                            logger=logger)
1062
    quotas = astakos.get_quotas(token)['system']
1063
    pithos_resources = [r['name'] for r in resources]
1064
    map(quotas.pop, filter(lambda k: k not in pithos_resources, quotas.keys()))
1065
    return quotas.popitem()[-1]  # assume only one resource
1066

    
1067

    
1068
def api_method(http_method=None, token_required=True, user_required=True,
1069
               logger=None, format_allowed=False, serializations=None,
1070
               strict_serlization=False, lock_container_path=False):
1071
    serializations = serializations or ['json', 'xml']
1072

    
1073
    def decorator(func):
1074
        @api.api_method(http_method=http_method, token_required=token_required,
1075
                        user_required=user_required,
1076
                        logger=logger, format_allowed=format_allowed,
1077
                        astakos_url=ASTAKOS_BASE_URL,
1078
                        serializations=serializations,
1079
                        strict_serlization=strict_serlization)
1080
        @wraps(func)
1081
        def wrapper(request, *args, **kwargs):
1082
            # The args variable may contain up to (account, container, object).
1083
            if len(args) > 1 and len(args[1]) > 256:
1084
                raise faults.BadRequest("Container name too large")
1085
            if len(args) > 2 and len(args[2]) > 1024:
1086
                raise faults.BadRequest('Object name too large.')
1087

    
1088
            success_status = False
1089
            try:
1090
                # Add a PithosBackend as attribute of the request object
1091
                request.backend = get_backend()
1092
                request.backend.pre_exec(lock_container_path)
1093

    
1094
                # Many API method expect thet X-Auth-Token in request,token
1095
                request.token = request.x_auth_token
1096
                update_request_headers(request)
1097
                response = func(request, *args, **kwargs)
1098
                update_response_headers(request, response)
1099

    
1100
                success_status = True
1101
                return response
1102
            finally:
1103
                # Always close PithosBackend connection
1104
                if getattr(request, "backend", None) is not None:
1105
                    request.backend.post_exec(success_status)
1106
                    request.backend.close()
1107
        return wrapper
1108
    return decorator
1109

    
1110

    
1111
def get_token_from_cookie(request):
1112
    assert(request.method == 'GET'),\
1113
        "Cookie based authentication is only allowed to GET requests"
1114
    token = None
1115
    if COOKIE_NAME in request.COOKIES:
1116
        cookie_value = unquote(request.COOKIES.get(COOKIE_NAME, ''))
1117
        account, sep, token = cookie_value.partition('|')
1118
    return token
1119

    
1120

    
1121
def view_method():
1122
    """Decorator function for views."""
1123

    
1124
    def decorator(func):
1125
        @wraps(func)
1126
        def wrapper(request, *args, **kwargs):
1127
            request.META['HTTP_X_AUTH_TOKEN'] = get_token_from_cookie(request)
1128
            # Get the response object
1129
            response = func(request, *args, **kwargs)
1130
            if response.status_code == 200:
1131
                return response
1132
            elif response.status_code == 404:
1133
                raise Http404()
1134
            elif response.status_code in [401, 403]:
1135
                return HttpResponseForbidden()
1136
            else:
1137
                raise Exception(response)
1138
        return wrapper
1139
    return decorator
1140

    
1141

    
1142
class Checksum:
1143
    def __init__(self):
1144
        self.md5 = hashlib.md5()
1145

    
1146
    def update(self, data):
1147
        self.md5.update(data)
1148

    
1149
    def hexdigest(self):
1150
        return self.md5.hexdigest().lower()
1151

    
1152

    
1153
class NoChecksum:
1154
    def update(self, data):
1155
        pass
1156

    
1157
    def hexdigest(self):
1158
        return ''