Statistics
| Branch: | Tag: | Revision:

root / snf-pithos-app / pithos / api / util.py @ d47565d8

History | View | Annotate | Download (41.6 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, HttpResponseRedirect, Http404,
39
                         HttpResponseForbidden)
40
from django.template.loader import render_to_string
41
from django.utils import simplejson as json
42
from django.utils.http import http_date, parse_etags
43
from django.utils.encoding import smart_unicode, smart_str
44
from django.core.files.uploadhandler import FileUploadHandler
45
from django.core.files.uploadedfile import UploadedFile
46
from django.core.urlresolvers import reverse
47

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

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

    
70
from pithos.api.resources import resources
71
from pithos.backends import connect_backend
72
from pithos.backends.base import (NotAllowedError, QuotaError, ItemNotExists,
73
                                  VersionNotExists)
74

    
75
from synnefo.lib import join_urls
76

    
77
from astakosclient import AstakosClient
78
from astakosclient.errors import NoUserName, NoUUID
79

    
80
import logging
81
import re
82
import hashlib
83
import uuid
84
import decimal
85

    
86
logger = logging.getLogger(__name__)
87

    
88

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

    
94

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

    
101

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

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

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

    
114

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

    
119

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

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

    
130

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

    
140

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

    
154

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

    
176

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

    
185

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

    
208

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

    
221

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

    
253

    
254
def update_manifest_meta(request, v_account, meta):
255
    """Update metadata if the object has an X-Object-Manifest."""
256

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

    
281

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

    
292

    
293
##########################
294
# USER CATALOG utilities #
295
##########################
296

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

    
310

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

    
320

    
321
def retrieve_uuid(token, displayname):
322
    if is_uuid(displayname):
323
        return displayname
324

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

    
333

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

    
343

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

    
355

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

    
367

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

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

    
385
    ret = []
386

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

    
399

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

    
406

    
407
def validate_modification_preconditions(request, meta):
408
    """Check the modified timestamp conforms with the preconditions set."""
409

    
410
    if 'modified' not in meta:
411
        return  # TODO: Always return?
412

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

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

    
427

    
428
def validate_matching_preconditions(request, meta):
429
    """Check that the ETag conforms with the preconditions set."""
430

    
431
    etag = meta['hash'] if not UPDATE_MD5 else meta['checksum']
432
    if not etag:
433
        etag = None
434

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

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

    
455

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

    
465

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

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

    
506

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

    
517

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

    
524

    
525
def get_range(request, size):
526
    """Parse a Range header from the request.
527

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

    
532
    ranges = request.META.get('HTTP_RANGE', '').replace(' ', '')
533
    if not ranges.startswith('bytes='):
534
        return None
535

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

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

    
560
    return ret
561

    
562

    
563
def get_content_range(request):
564
    """Parse a Content-Range header from the request.
565

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

    
571
    ranges = request.META.get('HTTP_CONTENT_RANGE', '')
572
    if not ranges:
573
        return None
574

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

    
597
    if upto is None:
598
        length = None
599
    else:
600
        length = upto - offset + 1
601
    return (offset, length, total)
602

    
603

    
604
def get_sharing(request):
605
    """Parse an X-Object-Sharing header from the request.
606

607
    Raises BadRequest on error.
608
    """
609

    
610
    permissions = request.META.get('HTTP_X_OBJECT_SHARING')
611
    if permissions is None:
612
        return None
613

    
614
    # TODO: Document or remove '~' replacing.
615
    permissions = permissions.replace('~', '')
616

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

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

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

    
668
    return ret
669

    
670

    
671
def get_public(request):
672
    """Parse an X-Object-Public header from the request.
673

674
    Raises BadRequest on error.
675
    """
676

    
677
    public = request.META.get('HTTP_X_OBJECT_PUBLIC')
678
    if public is None:
679
        return None
680

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

    
688

    
689
def raw_input_socket(request):
690
    """Return the socket for reading the rest of the request."""
691

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

    
699
MAX_UPLOAD_SIZE = 5 * (1024 * 1024 * 1024)  # 5GB
700

    
701

    
702
def socket_read_iterator(request, length=0, blocksize=4096):
703
    """Return maximum of blocksize data read from the socket in each iteration
704

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

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

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

    
768

    
769
class SaveToBackendHandler(FileUploadHandler):
770
    """Handle a file from an HTML form the django way."""
771

    
772
    def __init__(self, request=None):
773
        super(SaveToBackendHandler, self).__init__(request)
774
        self.backend = request.backend
775

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

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

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

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

    
805

    
806
class ObjectWrapper(object):
807
    """Return the object's data block-per-block in each iteration.
808

809
    Read from the object using the offset and length provided
810
    in each entry of the range list.
811
    """
812

    
813
    def __init__(self, backend, ranges, sizes, hashmaps, boundary):
814
        self.backend = backend
815
        self.ranges = ranges
816
        self.sizes = sizes
817
        self.hashmaps = hashmaps
818
        self.boundary = boundary
819
        self.size = sum(self.sizes)
820

    
821
        self.file_index = 0
822
        self.block_index = 0
823
        self.block_hash = -1
824
        self.block = ''
825

    
826
        self.range_index = -1
827
        self.offset, self.length = self.ranges[0]
828

    
829
    def __iter__(self):
830
        return self
831

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

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

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

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

    
898

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

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

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

    
952

    
953
def put_object_block(request, hashmap, data, offset):
954
    """Put one block of data at the given offset."""
955

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

    
965

    
966
def hashmap_md5(backend, hashmap, size):
967
    """Produce the MD5 sum from the data in the hashmap."""
968

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

    
980

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

    
989

    
990
from pithos.backends.util import PithosBackendPool
991

    
992
if RADOS_STORAGE:
993
    BLOCK_PARAMS = {'mappool': RADOS_POOL_MAPS,
994
                    'blockpool': RADOS_POOL_BLOCKS, }
995
else:
996
    BLOCK_PARAMS = {'mappool': None,
997
                    'blockpool': None, }
998

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

    
1021
_pithos_backend_pool = PithosBackendPool(size=BACKEND_POOL_SIZE,
1022
                                         **BACKEND_KWARGS)
1023

    
1024

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

    
1034

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

    
1050

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

    
1060

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

    
1070

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

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

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

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

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

    
1113

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

    
1123

    
1124
def view_method():
1125
    """Decorator function for views."""
1126

    
1127
    def decorator(func):
1128
        @wraps(func)
1129
        def wrapper(request, *args, **kwargs):
1130
            token = get_token_from_cookie(request)
1131
            if token is None:
1132
                return HttpResponseRedirect('%s?next=%s' % (
1133
                    LOGIN_URL, join_urls(BASE_HOST, request.path)))
1134
            request.META['HTTP_X_AUTH_TOKEN'] = token
1135
            # Get the response object
1136
            response = func(request, *args, **kwargs)
1137
            if response.status_code in [200, 206, 304, 412, 416]:
1138
                return response
1139
            elif response.status_code == 404:
1140
                raise Http404()
1141
            elif response.status_code in [401, 403]:
1142
                return HttpResponseForbidden()
1143
            else:
1144
                # unexpected response status
1145
                raise Exception(response.status_code)
1146
        return wrapper
1147
    return decorator
1148

    
1149

    
1150
class Checksum:
1151
    def __init__(self):
1152
        self.md5 = hashlib.md5()
1153

    
1154
    def update(self, data):
1155
        self.md5.update(data)
1156

    
1157
    def hexdigest(self):
1158
        return self.md5.hexdigest().lower()
1159

    
1160

    
1161
class NoChecksum:
1162
    def update(self, data):
1163
        pass
1164

    
1165
    def hexdigest(self):
1166
        return ''