1
|
|
|
#!/usr/bin/env python |
2
|
|
|
# -*- coding: UTF-8 -*- |
3
|
|
|
|
4
|
|
|
import os |
5
|
|
|
import json |
6
|
|
|
import base64 |
7
|
|
|
import logging |
8
|
|
|
import hashlib |
9
|
|
|
import functools |
10
|
|
|
|
11
|
|
|
try: |
12
|
|
|
import lzma |
13
|
|
|
LZMA_OPTIONS = { |
14
|
|
|
'format': lzma.FORMAT_RAW, |
15
|
|
|
'filters': [ |
16
|
|
|
{'id': lzma.FILTER_DELTA, 'dist': 5}, |
17
|
|
|
{'id': lzma.FILTER_LZMA2, 'preset': lzma.PRESET_DEFAULT}, |
18
|
|
|
] |
19
|
|
|
} |
20
|
|
|
compress = functools.partial(lzma.compress, **LZMA_OPTIONS) |
21
|
|
|
decompress = functools.partial(lzma.decompress, **LZMA_OPTIONS) |
22
|
|
|
except ImportError: |
23
|
|
|
from zlib import compress, decompress |
24
|
|
|
|
25
|
|
|
|
26
|
|
|
from flask import request |
27
|
|
|
from browsepy.compat import range |
28
|
|
|
|
29
|
|
|
from .exceptions import InvalidClipboardSizeError |
30
|
|
|
|
31
|
|
|
logger = logging.getLogger(__name__) |
32
|
|
|
|
33
|
|
|
|
34
|
|
|
class Clipboard(set): |
35
|
|
|
''' |
36
|
|
|
Clipboard (set) with convenience methods to pick its state from request |
37
|
|
|
cookies and save it to response cookies. |
38
|
|
|
''' |
39
|
|
|
cookie_secret = os.urandom(256) |
40
|
|
|
cookie_name = 'clipboard-{:x}' |
41
|
|
|
cookie_path = '/' |
42
|
|
|
request_cache_field = '_browsepy_file_actions_clipboard_cache' |
43
|
|
|
max_pages = 20 |
44
|
|
|
|
45
|
|
|
@classmethod |
46
|
|
|
def count(cls, request=request): |
47
|
|
|
''' |
48
|
|
|
Get how many clipboard items are stores on request cookies. |
49
|
|
|
|
50
|
|
|
:param request: optional request, defaults to current flask request |
51
|
|
|
:type request: werkzeug.Request |
52
|
|
|
:return: number of clipboard items on request's cookies |
53
|
|
|
:rtype: int |
54
|
|
|
''' |
55
|
|
|
return len(cls.from_request(request)) |
56
|
|
|
|
57
|
|
|
@classmethod |
58
|
|
|
def from_request(cls, request=request): |
59
|
|
|
''' |
60
|
|
|
Create clipboard object from request cookies. |
61
|
|
|
Uses request itself for cache. |
62
|
|
|
|
63
|
|
|
:param request: optional request, defaults to current flask request |
64
|
|
|
:type request: werkzeug.Request |
65
|
|
|
:returns: clipboard instance |
66
|
|
|
:rtype: Clipboard |
67
|
|
|
''' |
68
|
|
|
cached = getattr(request, cls.request_cache_field, None) |
69
|
|
|
if cached is not None: |
70
|
|
|
return cached |
71
|
|
|
self = cls() |
72
|
|
|
setattr(request, cls.request_cache_field, self) |
73
|
|
|
try: |
74
|
|
|
self.__setstate__(cls._read_paginated_cookie(request)) |
75
|
|
|
except BaseException: |
76
|
|
|
pass |
77
|
|
|
return self |
78
|
|
|
|
79
|
|
|
@classmethod |
80
|
|
|
def _paginated_cookie_length(cls, page=0): |
81
|
|
|
name_fnc = cls.cookie_name.format |
82
|
|
|
return 3990 - len(name_fnc(page) + cls.cookie_path) |
83
|
|
|
|
84
|
|
|
@classmethod |
85
|
|
|
def _read_paginated_cookie(cls, request=request): |
86
|
|
|
chunks = [] |
87
|
|
|
if request: |
88
|
|
|
name_fnc = cls.cookie_name.format |
89
|
|
|
for i in range(cls.max_pages): # 2 ** 32 - 1 |
90
|
|
|
cookie = request.cookies.get(name_fnc(i), '').encode('ascii') |
91
|
|
|
chunks.append(cookie) |
92
|
|
|
if len(cookie) < cls._paginated_cookie_length(i): |
93
|
|
|
break |
94
|
|
|
serialized = decompress(base64.b64decode(b''.join(chunks))) |
95
|
|
|
return json.loads(serialized.decode('utf-8')) |
96
|
|
|
|
97
|
|
|
@classmethod |
98
|
|
|
def _write_paginated_cookie(cls, data, response): |
99
|
|
|
serialized = compress(json.dumps(data).encode('utf-8')) |
100
|
|
|
data = base64.b64encode(serialized) |
101
|
|
|
name_fnc = cls.cookie_name.format |
102
|
|
|
start = 0 |
103
|
|
|
size = len(data) |
104
|
|
|
for i in range(cls.max_pages): |
105
|
|
|
end = cls._paginated_cookie_length(i) |
106
|
|
|
response.set_cookie(name_fnc(i), data[start:end].decode('ascii')) |
107
|
|
|
start = end |
108
|
|
|
if start > size: # we need an empty page after start == size |
109
|
|
|
return i |
110
|
|
|
raise InvalidClipboardSizeError(max_cookies=cls.max_pages) |
111
|
|
|
|
112
|
|
|
@classmethod |
113
|
|
|
def _delete_paginated_cookie(cls, response, start=0, request=request): |
114
|
|
|
name_fnc = cls.cookie_name.format |
115
|
|
|
for i in range(start, cls.max_pages): |
116
|
|
|
name = name_fnc(i) |
117
|
|
|
if name not in request.cookies: |
118
|
|
|
break |
119
|
|
|
response.set_cookie(name, '', expires=0) |
120
|
|
|
|
121
|
|
|
@classmethod |
122
|
|
|
def _signature(cls, items, method): |
123
|
|
|
serialized = json.dumps(items).encode('utf-8') |
124
|
|
|
data = cls.cookie_secret + method.encode('utf-8') + serialized |
125
|
|
|
return base64.b64encode(hashlib.sha512(data).digest()).decode('ascii') |
126
|
|
|
|
127
|
|
|
def __init__(self, iterable=(), mode='copy'): |
128
|
|
|
self.mode = mode |
129
|
|
|
super(Clipboard, self).__init__(iterable) |
130
|
|
|
|
131
|
|
|
def __getstate__(self): |
132
|
|
|
items = list(self) |
133
|
|
|
return { |
134
|
|
|
'mode': self.mode, |
135
|
|
|
'items': items, |
136
|
|
|
'signature': self._signature(items, self.mode), |
137
|
|
|
} |
138
|
|
|
|
139
|
|
|
def __setstate__(self, data): |
140
|
|
|
if data['signature'] == self._signature(data['items'], data['mode']): |
141
|
|
|
self.update(data['items']) |
142
|
|
|
self.mode = data['mode'] |
143
|
|
|
|
144
|
|
|
def to_response(self, response, request=request): |
145
|
|
|
''' |
146
|
|
|
Save clipboard state to response taking care of disposing old clipboard |
147
|
|
|
cookies from request. |
148
|
|
|
|
149
|
|
|
:param response: response object to write cookies on |
150
|
|
|
:type response: werkzeug.Response |
151
|
|
|
:param request: optional request, defaults to current flask request |
152
|
|
|
:type request: werkzeug.Request |
153
|
|
|
''' |
154
|
|
|
start = 0 |
155
|
|
|
if self: |
156
|
|
|
data = self.__getstate__() |
157
|
|
|
start = self._write_paginated_cookie(data, response) + 1 |
158
|
|
|
self._delete_paginated_cookie(response, start, request) |
159
|
|
|
|
160
|
|
|
def copy(self): |
161
|
|
|
''' |
162
|
|
|
Create another instance of this class with same items and mode. |
163
|
|
|
|
164
|
|
|
:returns: clipboard instance |
165
|
|
|
:rtype: Clipboard |
166
|
|
|
''' |
167
|
|
|
return self.___class__(self, mode=self.mode) |
168
|
|
|
|