Merge lp:~rvb/maas/file-delete-1.2 into lp:maas/1.2

Proposed by Raphaël Badin
Status: Merged
Approved by: Raphaël Badin
Approved revision: no longer in the source branch.
Merged at revision: 1359
Proposed branch: lp:~rvb/maas/file-delete-1.2
Merge into: lp:maas/1.2
Diff against target: 329 lines (+191/-1)
4 files modified
src/maasserver/api.py (+77/-0)
src/maasserver/tests/test_api.py (+96/-1)
src/maasserver/urls_api.py (+3/-0)
src/maastesting/utils.py (+15/-0)
To merge this branch: bzr merge lp:~rvb/maas/file-delete-1.2
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve
Review via email: mp+148472@code.launchpad.net

Commit message

Backport revisions 1435, 1436 and 1437.

To post a comment you must log in.
Revision history for this message
Raphaël Badin (rvb) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api.py'
2--- src/maasserver/api.py 2013-02-14 11:42:56 +0000
3+++ src/maasserver/api.py 2013-02-14 15:08:26 +0000
4@@ -61,6 +61,7 @@
5 "api_doc",
6 "api_doc_title",
7 "BootImagesHandler",
8+ "FileHandler",
9 "FilesHandler",
10 "get_oauth_token",
11 "MaasHandler",
12@@ -101,6 +102,7 @@
13 from django.db.utils import DatabaseError
14 from django.forms.models import model_to_dict
15 from django.http import (
16+ Http404,
17 HttpResponse,
18 HttpResponseBadRequest,
19 QueryDict,
20@@ -110,6 +112,7 @@
21 render_to_response,
22 )
23 from django.template import RequestContext
24+from django.utils.http import urlquote_plus
25 from docutils import core
26 from formencode import validators
27 from formencode.validators import Invalid
28@@ -173,10 +176,12 @@
29 strip_domain,
30 )
31 from maasserver.utils.orm import get_one
32+from piston.emitters import JSONEmitter
33 from piston.handler import (
34 AnonymousBaseHandler,
35 BaseHandler,
36 HandlerMetaClass,
37+ typemapper,
38 )
39 from piston.models import Token
40 from piston.resource import Resource
41@@ -1052,6 +1057,61 @@
42 return ('files_handler', [])
43
44
45+# DISPLAYED_FILES_FIELDS_OBJECT is the list of fields used when dumping
46+# lists of FileStorage objects.
47+DISPLAYED_FILES_FIELDS = ('filename', )
48+# DISPLAYED_FILES_FIELDS_OBJECT is the list of fields used when dumping
49+# individual FileStorage objects.
50+DISPLAYED_FILES_FIELDS_OBJECT = DISPLAYED_FILES_FIELDS + ('content', )
51+
52+
53+class FileHandler(OperationsHandler):
54+ """Manage a FileStorage object.
55+
56+ The file is identified by its filename.
57+ """
58+ model = FileStorage
59+ fields = DISPLAYED_FILES_FIELDS
60+ create = update = None
61+
62+ def read(self, request, filename):
63+ """GET a FileStorage object as a json object.
64+
65+ The 'content' of the file is base64-encoded."""
66+ # 'content' is a BinaryField, its 'value' is a base64-encoded version
67+ # of its value.
68+ stored_files = FileStorage.objects.filter(filename=filename).values()
69+ # filename is a 'unique' field, we can have either 0 or 1 objects in
70+ # 'stored_files'.
71+ if len(stored_files) == 0:
72+ raise Http404
73+ stored_file = stored_files[0]
74+ # Emit the json for this object manually because, no matter what the
75+ # piston documentation says, once an type is associated with a list
76+ # of fields by piston's typemapper mechanism, there is no way to
77+ # override that in a specific handler with 'fields' or 'exclude'.
78+ emitter = JSONEmitter(
79+ stored_file, typemapper, None, DISPLAYED_FILES_FIELDS_OBJECT)
80+ stream = emitter.render(request)
81+ return HttpResponse(
82+ stream, mimetype='application/json; charset=utf-8',
83+ status=httplib.OK)
84+
85+ @operation(idempotent=False)
86+ def delete(self, request, filename):
87+ """Delete a FileStorage object."""
88+ stored_file = get_object_or_404(FileStorage, filename=filename)
89+ stored_file.delete()
90+ return rc.DELETED
91+
92+ @classmethod
93+ def resource_uri(cls, stored_file=None):
94+ filename = "filename"
95+ if stored_file is not None:
96+ filename = urlquote_plus(stored_file.filename)
97+ return ('file_handler', (filename, ))
98+
99+
100 class FilesHandler(OperationsHandler):
101 """File management operations."""
102 create = read = update = delete = None
103@@ -1084,6 +1144,23 @@
104 FileStorage.objects.save_file(filename, uploaded_file)
105 return HttpResponse('', status=httplib.CREATED)
106
107+ @operation(idempotent=True)
108+ def list(self, request):
109+ """List the files from the file storage.
110+
111+ The returned files are ordered by file name and the content is
112+ excluded.
113+
114+ :param prefix: Optional prefix used to filter out the returned files.
115+ :type prefix: string
116+ """
117+ prefix = request.GET.get("prefix", None)
118+ files = FileStorage.objects.all()
119+ if prefix is not None:
120+ files = files.filter(filename__startswith=prefix)
121+ files = files.order_by('filename')
122+ return files
123+
124 @classmethod
125 def resource_uri(cls, *args, **kwargs):
126 return ('files_handler', [])
127
128=== modified file 'src/maasserver/tests/test_api.py'
129--- src/maasserver/tests/test_api.py 2013-01-30 15:55:49 +0000
130+++ src/maasserver/tests/test_api.py 2013-02-14 15:08:26 +0000
131@@ -16,7 +16,10 @@
132 ABCMeta,
133 abstractproperty,
134 )
135-from base64 import b64encode
136+from base64 import (
137+ b64decode,
138+ b64encode,
139+ )
140 from collections import namedtuple
141 from cStringIO import StringIO
142 from datetime import (
143@@ -46,6 +49,7 @@
144 )
145 from django.http import QueryDict
146 from django.test.client import RequestFactory
147+from django.utils.http import urlquote_plus
148 from fixtures import (
149 EnvironmentVariableFixture,
150 Fixture,
151@@ -86,6 +90,7 @@
152 BootImage,
153 Config,
154 DHCPLease,
155+ FileStorage,
156 MACAddress,
157 Node,
158 NodeGroup,
159@@ -124,6 +129,7 @@
160 from maastesting.djangotestcase import TransactionTestCase
161 from maastesting.fakemethod import FakeMethod
162 from maastesting.matchers import ContainsAll
163+from maastesting.utils import sample_binary_data
164 from metadataserver.models import (
165 NodeKey,
166 NodeUserData,
167@@ -2703,6 +2709,29 @@
168 self.assertEqual(httplib.OK, response.status_code)
169 self.assertEqual(b"give me rope", response.content)
170
171+ def test_anon_cannot_list_files(self):
172+ factory.make_file_storage(
173+ filename="filename", content=b"test content")
174+ response = self.make_API_GET_request("list")
175+ # The 'list' operation is not available to anon users.
176+ self.assertEqual(httplib.BAD_REQUEST, response.status_code)
177+
178+ def test_anon_cannot_get_file(self):
179+ filename = factory.make_name("file")
180+ factory.make_file_storage(
181+ filename=filename, content=b"test file content")
182+ response = self.client.get(
183+ reverse('file_handler', args=[filename]))
184+ self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
185+
186+ def test_anon_cannot_delete_file(self):
187+ filename = factory.make_name('file')
188+ factory.make_file_storage(
189+ filename=filename, content=b"test content")
190+ response = self.client.delete(
191+ reverse('file_handler', args=[filename]))
192+ self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
193+
194
195 class FileStorageAPITest(FileStorageAPITestMixin, APITestCase):
196
197@@ -2788,6 +2817,72 @@
198 self.assertIn('text/plain', response['Content-Type'])
199 self.assertEqual("File not found", response.content)
200
201+ def test_list_files_returns_ordered_list(self):
202+ filenames = ["myfiles/a", "myfiles/z", "myfiles/b"]
203+ for filename in filenames:
204+ factory.make_file_storage(
205+ filename=filename, content=b"test content")
206+ response = self.make_API_GET_request("list")
207+ self.assertEqual(httplib.OK, response.status_code)
208+ parsed_results = json.loads(response.content)
209+ filenames = [result['filename'] for result in parsed_results]
210+ self.assertEqual(sorted(filenames), filenames)
211+
212+ def test_list_files_lists_files_with_prefix(self):
213+ filenames_with_prefix = ["prefix-file1", "prefix-file2"]
214+ filenames = filenames_with_prefix + ["otherfile", "otherfile2"]
215+ for filename in filenames:
216+ factory.make_file_storage(
217+ filename=filename, content=b"test content")
218+ response = self.client.get(
219+ self.get_uri('files/'), {"op": "list", "prefix": "prefix-"})
220+ self.assertEqual(httplib.OK, response.status_code)
221+ parsed_results = json.loads(response.content)
222+ filenames = [result['filename'] for result in parsed_results]
223+ self.assertItemsEqual(filenames_with_prefix, filenames)
224+
225+ def test_list_files_does_not_include_file_content(self):
226+ factory.make_file_storage(
227+ filename="filename", content=b"test content")
228+ response = self.make_API_GET_request("list")
229+ parsed_results = json.loads(response.content)
230+ self.assertNotIn('content', parsed_results[0].keys())
231+
232+ def test_files_resource_uri_are_url_escaped(self):
233+ filename = "&*!c/%//filename/"
234+ factory.make_file_storage(
235+ filename=filename, content=b"test content")
236+ response = self.make_API_GET_request("list")
237+ parsed_results = json.loads(response.content)
238+ resource_uri = parsed_results[0]['resource_uri']
239+ resource_uri_elements = resource_uri.split('/')
240+ # The url-escaped name of the file is part of the resource uri.
241+ self.assertIn(urlquote_plus(filename), resource_uri_elements)
242+
243+ def test_get_file_returns_file_object_with_content_base64_encoded(self):
244+ filename = factory.make_name("file")
245+ content = sample_binary_data
246+ factory.make_file_storage(filename=filename, content=content)
247+ response = self.client.get(
248+ reverse('file_handler', args=[filename]))
249+ parsed_result = json.loads(response.content)
250+ self.assertEqual(
251+ (filename, content),
252+ (
253+ parsed_result['filename'],
254+ b64decode(parsed_result['content'])
255+ ))
256+
257+ def test_delete_file_deletes_file(self):
258+ filename = factory.make_name('file')
259+ factory.make_file_storage(
260+ filename=filename, content=b"test content")
261+ response = self.client.delete(
262+ reverse('file_handler', args=[filename]))
263+ self.assertEqual(httplib.NO_CONTENT, response.status_code)
264+ files = FileStorage.objects.filter(filename=filename)
265+ self.assertEqual([], list(files))
266+
267
268 class TestTagAPI(APITestCase):
269 """Tests for /api/1.0/tags/<tagname>/."""
270
271=== modified file 'src/maasserver/urls_api.py'
272--- src/maasserver/urls_api.py 2012-12-20 14:50:18 +0000
273+++ src/maasserver/urls_api.py 2013-02-14 15:08:26 +0000
274@@ -22,6 +22,7 @@
275 api_doc,
276 BootImagesHandler,
277 describe,
278+ FileHandler,
279 FilesHandler,
280 MaasHandler,
281 NodeGroupHandler,
282@@ -42,6 +43,7 @@
283
284 account_handler = RestrictedResource(AccountHandler, authentication=api_auth)
285 files_handler = RestrictedResource(FilesHandler, authentication=api_auth)
286+file_handler = RestrictedResource(FileHandler, authentication=api_auth)
287 node_handler = RestrictedResource(NodeHandler, authentication=api_auth)
288 nodes_handler = RestrictedResource(NodesHandler, authentication=api_auth)
289 node_mac_handler = RestrictedResource(NodeMacHandler, authentication=api_auth)
290@@ -94,6 +96,7 @@
291 url(r'nodegroups/(?P<uuid>[^/]+)/interfaces/(?P<interface>[^/]+)/$',
292 nodegroupinterface_handler, name='nodegroupinterface_handler'),
293 url(r'files/$', files_handler, name='files_handler'),
294+ url(r'files/(?P<filename>[^/]+)/$', file_handler, name='file_handler'),
295 url(r'account/$', account_handler, name='account_handler'),
296 url(r'boot-images/$', boot_images_handler, name='boot_images_handler'),
297 url(r'tags/(?P<name>[\w\-]+)/$', tag_handler, name='tag_handler'),
298
299=== modified file 'src/maastesting/utils.py'
300--- src/maastesting/utils.py 2012-07-16 10:19:46 +0000
301+++ src/maastesting/utils.py 2013-02-14 15:08:26 +0000
302@@ -17,8 +17,10 @@
303 "get_write_time",
304 "preexec_fn",
305 "retries",
306+ "sample_binary_data",
307 ]
308
309+import codecs
310 import os
311 import re
312 import signal
313@@ -90,3 +92,16 @@
314 sleep(min(delay, end - now))
315 else:
316 break
317+
318+
319+# Some horrible binary data that could never, ever, under any encoding
320+# known to man(1) survive mis-interpretation as text.
321+#
322+# The data contains a nul byte to trip up accidental string termination.
323+# Switch the bytes of the byte-order mark around and by design you get
324+# an invalid codepoint; put a byte with the high bit set between bytes
325+# that have it cleared, and you have a guaranteed non-UTF-8 sequence.
326+#
327+# (1) Provided, of course, that man know only about ASCII and
328+# UTF.
329+sample_binary_data = codecs.BOM64_LE + codecs.BOM64_BE + b'\x00\xff\x00'

Subscribers

People subscribed via source and target branches

to status/vote changes: