Merge lp:~dooferlad/linaro-license-protection/push_support into lp:~linaro-automation/linaro-license-protection/trunk

Proposed by James Tunnicliffe
Status: Merged
Approved by: Milo Casagrande
Approved revision: 203
Merged at revision: 207
Proposed branch: lp:~dooferlad/linaro-license-protection/push_support
Merge into: lp:~linaro-automation/linaro-license-protection/trunk
Diff against target: 566 lines (+390/-25)
8 files modified
README (+27/-0)
license_protected_downloads/common.py (+18/-0)
license_protected_downloads/models.py (+4/-0)
license_protected_downloads/tests/test_views.py (+144/-1)
license_protected_downloads/uploads.py (+117/-0)
license_protected_downloads/views.py (+40/-21)
settings.py (+34/-3)
urls.py (+6/-0)
To merge this branch: bzr merge lp:~dooferlad/linaro-license-protection/push_support
Reviewer Review Type Date Requested Status
Milo Casagrande (community) Approve
Review via email: mp+167817@code.launchpad.net

Description of the change

Adds to the API to allow file uploads.

settings.py has a blank master key by default, which prevents any API keys from being issued, thus disabling the new functionality. The master key should, of course, be generated on the server that this is installed on and only shared with trusted parties.

To post a comment you must log in.
199. By James Tunnicliffe

Make full dir path for uploads
Expect local_settings.py
If user supplies a valid key, default protection to OPEN.

200. By James Tunnicliffe

Uploads are now downloadable if the upload path isn't the same as one of the shared directories!

201. By James Tunnicliffe

Updated to make downloads work from inside the upload directory.

Revision history for this message
Milo Casagrande (milo) wrote :

Hi James!

Awesome progress! Everything looks good, there are only two definitely minor comments and one question from me.

First comes the question.
To upload a file there is no "api/" URL, we just use the catch-all urls.py rule and differentiate on GET/POST. Is that right?

So, an imaginary URL like 'https://snapshots.l.o/a_big_file.zip', with my API key and file contents as data, and a POST request, should upload the file. Is it correct?

=== added file 'license_protected_downloads/common.py'
--- license_protected_downloads/common.py 1970-01-01 00:00:00 +0000
+++ license_protected_downloads/common.py 2013-06-14 12:41:40 +0000
@@ -0,0 +1,18 @@
+import os
+
+def safe_path_join(base_path, *paths):
+ """os.path.join with with check that result is inside base_path.

s/with with/with

=== modified file 'license_protected_downloads/views.py'
+
+ # if key is in request.GET["key"] then need to mod path and give
+ # access to a per-key directory.
+ if "key" in request.GET :

Extra space before the colon?

=== modified file 'settings.py'
--- settings.py 2013-06-10 16:03:50 +0000
+++ settings.py 2013-06-14 12:41:40 +0000
 TEMPLATE_CONTEXT_PROCESSORS = (
     'django.contrib.messages.context_processors.messages',
@@ -224,3 +227,7 @@
     "init.css": "http://www.linaro.org/remote/css/init.css",
     "remote.css": "http://www.linaro.org/remote/css/remote.css",
     }
+
+MASTER_API_KEY = ""
+
+from local_settings import *

Maybe is worth adding some comments here about the MASTER_API_KEY, and also update the installation/deployment notes.

Approving since those things can be fixed during merge (and the questions are just to make sure I understood the implementation correctly).
Thanks.

review: Approve
202. By James Tunnicliffe

Added auto-generation of local_settings (if needed) and updated README

203. By James Tunnicliffe

Cosmetic fixes

Revision history for this message
Milo Casagrande (milo) wrote :

Looks good to go for me!
Thanks James!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'README'
2--- README 2013-06-03 14:05:59 +0000
3+++ README 2013-06-14 17:43:26 +0000
4@@ -21,6 +21,7 @@
5 (as used by Launchpad.net) and Atlassian Crowd are supported).
6 * post-processing of all uploads (a script that manages uploads)
7 * per-IP pass-through (for automatic services like a test framework)
8+ * key based file uploads into a private area.
9
10 Background
11 ----------
12@@ -79,6 +80,32 @@
13 so it may be inaccessible to the general public.
14
15
16+Publishing API
17+--------------
18+To upload a file using the publishing API you need a key. This can be obtained
19+by the query <server>/api/request_key?<MASTER_API_KEY> where MASTER_API_KEY
20+is stored in local_settings.py.
21+
22+Now you can use HTTP PUT requests to push up files:
23+curl -F file=@<file> -F key=<key> <server>/path/to/store/file
24+
25+And this file can be downloaded from:
26+<server>/path/to/store/file?key=<key>
27+
28+ * By default API key protected files have no further license protection.
29+ * You can upload multiple files with the same key
30+ * Directories are automatically created
31+ * File listings still work (as long as you append ?key=<key> to the URL)
32+
33+Once you have finished with the files, you can delete all files associated
34+with a temporary API key by deleting the key:
35+
36+curl <server>/api/delete_key?key=<key>
37+
38+Uploading files to a public directory is not supported. This is planned for
39+the future.
40+
41+
42 Build-Info support
43 ------------------
44
45
46=== added file 'license_protected_downloads/common.py'
47--- license_protected_downloads/common.py 1970-01-01 00:00:00 +0000
48+++ license_protected_downloads/common.py 2013-06-14 17:43:26 +0000
49@@ -0,0 +1,18 @@
50+import os
51+
52+def safe_path_join(base_path, *paths):
53+ """os.path.join with check that result is inside base_path.
54+
55+ Checks that the generated path doesn't end up outside the target
56+ directory, so server accesses stay where we expect them.
57+ """
58+
59+ target_path = os.path.join(base_path, *paths)
60+
61+ if not target_path.startswith(base_path):
62+ return None
63+
64+ if not os.path.normpath(target_path) == target_path.rstrip("/"):
65+ return None
66+
67+ return target_path
68
69=== modified file 'license_protected_downloads/models.py'
70--- license_protected_downloads/models.py 2012-08-06 11:53:16 +0000
71+++ license_protected_downloads/models.py 2013-06-14 17:43:26 +0000
72@@ -26,3 +26,7 @@
73
74 def __unicode__(self):
75 return self.digest
76+
77+
78+class APIKeyStore(models.Model):
79+ key = models.CharField(max_length=80)
80
81=== modified file 'license_protected_downloads/tests/test_views.py'
82--- license_protected_downloads/tests/test_views.py 2013-06-05 13:12:53 +0000
83+++ license_protected_downloads/tests/test_views.py 2013-06-14 17:43:26 +0000
84@@ -10,7 +10,8 @@
85 import urllib2
86 import urlparse
87 import json
88-
89+import random
90+import shutil
91 from mock import Mock
92
93 from license_protected_downloads import bzr_version
94@@ -34,9 +35,19 @@
95 self.old_served_paths = settings.SERVED_PATHS
96 settings.SERVED_PATHS = [os.path.join(THIS_DIRECTORY,
97 "testserver_root")]
98+ self.old_upload_path = settings.UPLOAD_PATH
99+ settings.UPLOAD_PATH = os.path.join(THIS_DIRECTORY,
100+ "test_upload_root")
101+ if not os.path.isdir(settings.UPLOAD_PATH):
102+ os.makedirs(settings.UPLOAD_PATH)
103+ self.old_master_api_key = settings.MASTER_API_KEY
104+ settings.MASTER_API_KEY = "1234abcd"
105
106 def tearDown(self):
107 settings.SERVED_PATHS = self.old_served_paths
108+ settings.MASTER_API_KEY = self.old_master_api_key
109+ os.rmdir(settings.UPLOAD_PATH)
110+ settings.UPLOAD_PATH = self.old_upload_path
111
112
113 class ViewTests(BaseServeViewTest):
114@@ -839,6 +850,138 @@
115 # Shouldn't be able to escape served paths...
116 self.assertEqual(response.status_code, 404)
117
118+ def test_get_key(self):
119+ response = self.client.get("http://testserver/api/request_key",
120+ data={"key": settings.MASTER_API_KEY})
121+
122+ self.assertEqual(response.status_code, 200)
123+ # Don't care what the key is, as long as it isn't blank
124+ self.assertRegexpMatches(response.content, "\S+")
125+
126+ def test_get_key_api_disabled(self):
127+ settings.MASTER_API_KEY = ""
128+ response = self.client.get("http://testserver/api/request_key",
129+ data={"key": settings.MASTER_API_KEY})
130+
131+ self.assertEqual(response.status_code, 403)
132+
133+ def test_get_key_post_and_get_file(self):
134+ response = self.client.get("http://testserver/api/request_key",
135+ data={"key": settings.MASTER_API_KEY})
136+
137+ self.assertEqual(response.status_code, 200)
138+ # Don't care what the key is, as long as it isn't blank
139+ self.assertRegexpMatches(response.content, "\S+")
140+ key = response.content
141+
142+ # Now write a file so we can upload it
143+ file_content = "test_get_key_post_and_get_file"
144+ file_root = "/tmp"
145+
146+ tmp_file_name = os.path.join(
147+ file_root,
148+ self.make_temporary_file(file_content))
149+
150+ # Send the file
151+ with open(tmp_file_name) as f:
152+ response = self.client.post("http://testserver/file_name",
153+ data={"key": key, "file": f})
154+ self.assertEqual(response.status_code, 200)
155+
156+ # Check the upload worked by reading the file back from its uploaded
157+ # location
158+ uploaded_file_path = os.path.join(settings.UPLOAD_PATH,
159+ key,
160+ "file_name")
161+ with open(uploaded_file_path) as f:
162+ self.assertEqual(f.read(), file_content)
163+
164+ # Test we can fetch the newly uploaded file if we present the key
165+ response = self.client.get("http://testserver/file_name",
166+ data={"key": key})
167+ self.assertEqual(response.status_code, 200)
168+
169+ response = self.client.get("http://testserver/file_name")
170+ self.assertNotEqual(response.status_code, 200)
171+
172+ # Delete the files generated by the test
173+ shutil.rmtree(os.path.join(settings.UPLOAD_PATH, key))
174+
175+ def test_post_file_no_key(self):
176+ file_content = "test_post_file_no_key"
177+ file_root = "/tmp"
178+
179+ tmp_file_name = os.path.join(
180+ file_root,
181+ self.make_temporary_file(file_content))
182+
183+ # Try to upload a file without a key.
184+ with open(tmp_file_name) as f:
185+ response = self.client.post("http://testserver/file_name",
186+ data={"file": f})
187+ self.assertEqual(response.status_code, 500)
188+
189+ # Make sure the file didn't get created.
190+ self.assertFalse(os.path.isfile(os.path.join(settings.UPLOAD_PATH,
191+ "file_name")))
192+
193+ def test_post_file_random_key(self):
194+ key = "%030x" % random.randrange(256**15)
195+ file_content = "test_post_file_random_key"
196+ file_root = "/tmp"
197+
198+ tmp_file_name = os.path.join(
199+ file_root,
200+ self.make_temporary_file(file_content))
201+
202+ # Try to upload a file with a randomly generated key.
203+ with open(tmp_file_name) as f:
204+ response = self.client.post("http://testserver/file_name",
205+ data={"key": key, "file": f})
206+ self.assertEqual(response.status_code, 500)
207+
208+ # Make sure the file didn't get created.
209+ self.assertFalse(os.path.isfile(os.path.join(settings.UPLOAD_PATH,
210+ key,
211+ "file_name")))
212+
213+ def test_api_delete_key(self):
214+ response = self.client.get("http://testserver/api/request_key",
215+ data={"key": settings.MASTER_API_KEY})
216+
217+ self.assertEqual(response.status_code, 200)
218+ # Don't care what the key is, as long as it isn't blank
219+ self.assertRegexpMatches(response.content, "\S+")
220+ key = response.content
221+ file_content = "test_api_delete_key"
222+ file_root = "/tmp"
223+
224+ tmp_file_name = os.path.join(
225+ file_root,
226+ self.make_temporary_file(file_content))
227+
228+ with open(tmp_file_name) as f:
229+ response = self.client.post("http://testserver/file_name",
230+ data={"key": key, "file": f})
231+ self.assertEqual(response.status_code, 200)
232+
233+ self.assertTrue(os.path.isfile(os.path.join(settings.UPLOAD_PATH,
234+ key,
235+ "file_name")))
236+
237+ # Release the key, the files should be deleted
238+ response = self.client.get("http://testserver/api/delete_key",
239+ data={"key": key})
240+ self.assertEqual(response.status_code, 200)
241+ self.assertFalse(os.path.isfile(os.path.join(settings.UPLOAD_PATH,
242+ key,
243+ "file_name")))
244+
245+ # Key shouldn't work after released
246+ response = self.client.get("http://testserver/file_name",
247+ data={"key": key})
248+ self.assertNotEqual(response.status_code, 200)
249+
250
251 class HowtoViewTests(BaseServeViewTest):
252 def test_no_howtos(self):
253
254=== added file 'license_protected_downloads/uploads.py'
255--- license_protected_downloads/uploads.py 1970-01-01 00:00:00 +0000
256+++ license_protected_downloads/uploads.py 2013-06-14 17:43:26 +0000
257@@ -0,0 +1,117 @@
258+from django.views.decorators.csrf import csrf_exempt
259+from django.http import (
260+ HttpResponse,
261+ HttpResponseForbidden,
262+ HttpResponseServerError
263+)
264+from django import forms
265+import random
266+from django.conf import settings
267+import os
268+import shutil
269+from models import APIKeyStore
270+from common import safe_path_join
271+
272+
273+class UploadFileForm(forms.Form):
274+ file = forms.FileField()
275+
276+
277+def upload_target_path(path, key):
278+ """Quick path handling function.
279+
280+ Checks that the generated path doesn't end up outside the target directory,
281+ so you can't set path to start with "/" and upload to anywhere.
282+ """
283+ base_path = os.path.join(settings.UPLOAD_PATH, key)
284+ return safe_path_join(base_path, path)
285+
286+
287+@csrf_exempt
288+def file_server_post(request, path):
289+ """ Handle post requests.
290+
291+ All post requests must be accompanied by a valid key. If not, the upload
292+ will be ignored.
293+
294+ Files are stored in a private directory that can not be accessed via the
295+ web interface unless you have the same key.
296+ """
297+ if not ("key" in request.POST and
298+ APIKeyStore.objects.filter(key=request.POST["key"])):
299+ return HttpResponseServerError("Invalid key")
300+
301+ form = UploadFileForm(request.POST, request.FILES)
302+ if not form.is_valid() or not path:
303+ return HttpResponseServerError("Invalid call")
304+
305+ path = upload_target_path(path, request.POST["key"])
306+
307+ # Create directory if required
308+ dirname = os.path.dirname(path)
309+ if not os.path.isdir(dirname):
310+ os.makedirs(dirname)
311+
312+ with open(path, "wb") as destination:
313+ for chunk in request.FILES["file"].chunks():
314+ destination.write(chunk)
315+
316+ return HttpResponse("OK")
317+
318+
319+def api_request_key(request):
320+ if("key" in request.GET and
321+ request.GET["key"] == settings.MASTER_API_KEY and
322+ settings.MASTER_API_KEY):
323+
324+ # Generate a new, random key.
325+ key = "%030x" % random.randrange(256**15)
326+ while APIKeyStore.objects.filter(key=key):
327+ key = "%030x" % random.randrange(256**15)
328+
329+ api_key = APIKeyStore(key=key)
330+ api_key.save()
331+ return HttpResponse(key)
332+
333+ return HttpResponseForbidden()
334+
335+def api_delete_key(request):
336+ if "key" not in request.GET:
337+ return HttpResponseServerError("Invalid key")
338+
339+ key = request.GET["key"]
340+ api_key = APIKeyStore.objects.filter(key=key)
341+
342+ if not api_key:
343+ return HttpResponseServerError("Invalid key")
344+
345+ # Delete key from database and all files associated with it
346+ api_key.delete()
347+ shutil.rmtree(os.path.join(settings.UPLOAD_PATH, key))
348+
349+ return HttpResponse("OK")
350+
351+def api_push_to_server(request):
352+ # TODO: Upload files from this machine to another linaro-licence-protection
353+ # node.
354+ """
355+ Something like:
356+
357+ if request.GET["target"] in settings.REMOTE_SERVERS:
358+ remote_server = settings.REMOTE_SERVERS[request.GET["target"]]
359+
360+ remote_server should contain:
361+ {
362+ "key": "...",
363+ "url": "...",
364+ }
365+
366+ now just POST files from this machine to the specified URL/KEY.
367+
368+ Possibly add some magic to POST endpoint (file_server_post) to allow
369+ (some users??) uploads to a public path:
370+
371+ POST snapshots.linaro.org/path/to/file?key="key"&public=true
372+
373+ """
374+ pass
375
376=== modified file 'license_protected_downloads/views.py'
377--- license_protected_downloads/views.py 2013-06-06 13:24:17 +0000
378+++ license_protected_downloads/views.py 2013-06-14 17:43:26 +0000
379@@ -19,6 +19,7 @@
380 from django.shortcuts import render_to_response, redirect
381 from django.template import RequestContext
382 from django.utils.encoding import smart_str, iri_to_uri
383+from django.views.decorators.csrf import csrf_exempt
384
385 import bzr_version
386 from buildinfo import BuildInfo, IncorrectDataFormatException
387@@ -28,7 +29,9 @@
388 import importlib
389 group_auth_modules = [importlib.import_module(m) for m in settings.GROUP_AUTH_MODULES]
390 from BeautifulSoup import BeautifulSoup
391+from uploads import file_server_post
392 import config
393+from common import safe_path_join
394 from group_auth_common import GroupAuthError
395
396
397@@ -132,28 +135,22 @@
398 return listing
399
400
401-def safe_path_join(base_path, *paths):
402- """os.path.join with check that result is inside base_path.
403+def test_path(path, served_paths=None):
404+ """Check that path points to something we can serve up.
405
406- Checks that the generated path doesn't end up outside the target
407- directory, so server accesses stay where we expect them.
408+ served_paths can be provided to overwrite settings.SERVED_PATHS. This is
409+ used for uploaded files, which may not be shared in the server root.
410 """
411
412- target_path = os.path.join(base_path, *paths)
413-
414- if not target_path.startswith(base_path):
415- return None
416-
417- if not os.path.normpath(target_path) == target_path.rstrip("/"):
418- return None
419-
420- return target_path
421-
422-
423-def test_path(path):
424-
425- for basepath in settings.SERVED_PATHS:
426+ if served_paths is None:
427+ served_paths = settings.SERVED_PATHS
428+ else:
429+ if not isinstance(served_paths, list):
430+ served_paths = [served_paths]
431+
432+ for basepath in served_paths:
433 fullpath = safe_path_join(basepath, path)
434+
435 if fullpath is None:
436 return None
437
438@@ -443,11 +440,32 @@
439 return response
440
441
442+@csrf_exempt
443 def file_server(request, path):
444 """Serve up a file / directory listing or license page as required"""
445 path = iri_to_uri(path)
446+
447+ # Intercept post requests and send them to file_server_post.
448+ if request.method == "POST":
449+ return file_server_post(request, path)
450+
451+ # GET requests are handled by file_server_get
452+ elif request.method == "GET":
453+ return file_server_get(request, path)
454+
455+
456+def file_server_get(request, path):
457+
458 url = path
459- result = test_path(path)
460+
461+ # if key is in request.GET["key"] then need to mod path and give
462+ # access to a per-key directory.
463+ if "key" in request.GET:
464+ path = os.path.join(request.GET["key"], path)
465+ result = test_path(path, settings.UPLOAD_PATH)
466+ else:
467+ result = test_path(path)
468+
469 if not result:
470 raise Http404
471
472@@ -522,8 +540,9 @@
473 if not file_listed(path, url):
474 raise Http404
475
476- if get_client_ip(request) in config.INTERNAL_HOSTS or\
477- is_whitelisted(os.path.join('/', url)):
478+ if (get_client_ip(request) in config.INTERNAL_HOSTS or
479+ is_whitelisted(os.path.join('/', url)) or
480+ "key" in request.GET): # If user has a key, default to open
481 digests = 'OPEN'
482 else:
483 digests = is_protected(path)
484
485=== modified file 'settings.py'
486--- settings.py 2013-06-10 16:03:50 +0000
487+++ settings.py 2013-06-14 17:43:26 +0000
488@@ -30,6 +30,8 @@
489 }
490 }
491
492+FILE_UPLOAD_PERMISSIONS = 0644
493+
494 TIME_ZONE = None
495
496 # Language code for this installation. All choices can be found here:
497@@ -80,9 +82,6 @@
498 # 'django.contrib.staticfiles.finders.DefaultStorageFinder',
499 )
500
501-# Make this unique, and don't share it with anybody.
502-SECRET_KEY = 'lkye^=q_i(#jies7^cz#anqq(1g0k$luy5^1jr2nk=g#inet(n'
503-
504 # List of callables that know how to import templates from various sources.
505 TEMPLATE_LOADERS = (
506 'django.template.loaders.filesystem.Loader',
507@@ -183,6 +182,7 @@
508 }
509
510 SERVED_PATHS = [os.path.join(PROJECT_ROOT, "sampleroot")]
511+UPLOAD_PATH = os.path.join(PROJECT_ROOT, "sample_upload_root")
512
513 TEMPLATE_CONTEXT_PROCESSORS = (
514 'django.contrib.messages.context_processors.messages',
515@@ -224,3 +224,34 @@
516 "init.css": "http://www.linaro.org/remote/css/init.css",
517 "remote.css": "http://www.linaro.org/remote/css/remote.css",
518 }
519+
520+MASTER_API_KEY = ""
521+
522+# Try to import local_settings. If it doesn't exist, generate it. It contains
523+# SECRET_KEY (to keep it secret).
524+try:
525+ from local_settings import *
526+except ImportError:
527+ import random
528+
529+ # Create local_settings with random SECRET_KEY and MASTER_API_KEY
530+ char_selection = '0123456789abcdefghijklmnopqrstuvwxyz' \
531+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
532+ char_selection_with_punctuation = char_selection + '!@#$%^&*(-_=+)'
533+
534+ # SECRET_KEY contains anything but whitespace
535+ secret_key = ''.join(random.sample(char_selection_with_punctuation, 50))
536+ local_settings_content = "SECRET_KEY = '%s'\n" % secret_key
537+
538+ # At the moment the publishing API is still in development so it is
539+ # disabled...
540+ if False:
541+ # MASTER_API_KEY contains characters that don't have to be % encoded
542+ # in an HTTP URL.
543+ master_api_key = ''.join(random.sample(char_selection, 50))
544+ local_settings_content += "MASTER_API_KEY = '%s'\n" % master_api_key
545+
546+ with open(os.path.join(PROJECT_ROOT, "local_settings.py"), "w") as f:
547+ f.write(local_settings_content)
548+
549+ from local_settings import *
550
551=== modified file 'urls.py'
552--- urls.py 2013-03-11 15:31:31 +0000
553+++ urls.py 2013-06-14 17:43:26 +0000
554@@ -52,6 +52,12 @@
555 url(r'^api/license/(?P<path>.*)$',
556 'license_protected_downloads.views.get_license_api'),
557
558+ url(r'^api/request_key$',
559+ 'license_protected_downloads.uploads.api_request_key'),
560+
561+ url(r'^api/delete_key$',
562+ 'license_protected_downloads.uploads.api_delete_key'),
563+
564 # Catch-all. We always return a file (or try to) if it exists.
565 # This handler does that.
566 url(r'(?P<path>.*)', 'license_protected_downloads.views.file_server'),

Subscribers

People subscribed via source and target branches