Use libbe.util.http.HTTP_USER_ERROR everywhere instead of hardcoding 418
[be.git] / libbe / command / serve_storage.py
1 # Copyright (C) 2010-2012 Chris Ball <cjb@laptop.org>
2 #                         W. Trevor King <wking@tremily.us>
3 #
4 # This file is part of Bugs Everywhere.
5 #
6 # Bugs Everywhere is free software: you can redistribute it and/or modify it
7 # under the terms of the GNU General Public License as published by the Free
8 # Software Foundation, either version 2 of the License, or (at your option) any
9 # later version.
10 #
11 # Bugs Everywhere is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 # FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
14 # more details.
15 #
16 # You should have received a copy of the GNU General Public License along with
17 # Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
18
19 """Define the :py:class:`Serve` serving BE Storage over HTTP.
20
21 See Also
22 --------
23 :py:mod:`libbe.storage.http` : the associated client
24 """
25
26 import logging
27 import os.path
28
29 import libbe
30 import libbe.command
31 import libbe.command.util
32 import libbe.util.http
33 import libbe.util.subproc
34 import libbe.util.wsgi
35 import libbe.version
36
37 if libbe.TESTING:
38     import copy
39     import doctest
40     import StringIO
41     import sys
42     import unittest
43     import wsgiref.validate
44     try:
45         import cherrypy.test.webtest
46         cherrypy_test_webtest = True
47     except ImportError:
48         cherrypy_test_webtest = None
49
50     import libbe.bugdir
51     import libbe.util.wsgi
52
53
54 class ServerApp (libbe.util.wsgi.WSGI_AppObject,
55                  libbe.util.wsgi.WSGI_DataObject):
56     """WSGI server for a BE Storage instance over HTTP.
57
58     RESTful_ WSGI request handler for serving the
59     libbe.storage.http.HTTP backend with GET, POST, and HEAD commands.
60     For more information on authentication and REST, see John
61     Calcote's `Open Sourcery article`_
62
63     .. _RESTful: http://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm
64     .. _Open Sourcery article: http://jcalcote.wordpress.com/2009/08/10/restful-authentication/
65
66     This serves files from a connected storage instance, usually
67     a VCS-based repository located on the local machine.
68
69     Notes
70     -----
71
72     The GET and HEAD requests are identical except that the HEAD
73     request omits the actual content of the file.
74     """
75     server_version = 'BE-storage-server/' + libbe.version.version()
76
77     def __init__(self, storage=None, notify=False, **kwargs):
78         super(ServerApp, self).__init__(
79             urls=[
80                 (r'^add/?', self.add),
81                 (r'^exists/?', self.exists),
82                 (r'^remove/?', self.remove),
83                 (r'^ancestors/?', self.ancestors),
84                 (r'^children/?', self.children),
85                 (r'^get/(.+)', self.get),
86                 (r'^set/(.+)', self.set),
87                 (r'^commit/?', self.commit),
88                 (r'^revision-id/?', self.revision_id),
89                 (r'^changed/?', self.changed),
90                 (r'^version/?', self.version),
91                 ],
92             **kwargs)
93         self.storage = storage
94         self.notify = notify
95
96     # handlers
97     def add(self, environ, start_response):
98         self.check_login(environ)
99         data = self.post_data(environ)
100         source = 'post'
101         id = self.data_get_id(data, source=source)
102         parent = self.data_get_string(
103             data, 'parent', default=None, source=source)
104         directory = self.data_get_boolean(
105             data, 'directory', default=False, source=source)
106         self.storage.add(id, parent=parent, directory=directory)
107         if self.notify:
108             self._notify(environ, 'add', id,
109                          [('parent', parent), ('directory', directory)])
110         return self.ok_response(environ, start_response, None)
111
112     def exists(self, environ, start_response):
113         self.check_login(environ)
114         data = self.query_data(environ)
115         source = 'query'
116         id = self.data_get_id(data, source=source)
117         revision = self.data_get_string(
118             data, 'revision', default=None, source=source)
119         content = str(self.storage.exists(id, revision))
120         return self.ok_response(environ, start_response, content)
121
122     def remove(self, environ, start_response):
123         self.check_login(environ)
124         data = self.post_data(environ)
125         source = 'post'
126         id = self.data_get_id(data, source=source)
127         recursive = self.data_get_boolean(
128             data, 'recursive', default=False, source=source)
129         if recursive == True:
130             self.storage.recursive_remove(id)
131         else:
132             self.storage.remove(id)
133         if self.notify:
134             self._notify(environ, 'remove', id, [('recursive', recursive)])
135         return self.ok_response(environ, start_response, None)
136
137     def ancestors(self, environ, start_response):
138         self.check_login(environ)
139         data = self.query_data(environ)
140         source = 'query'
141         id = self.data_get_id(data, source=source)
142         revision = self.data_get_string(
143             data, 'revision', default=None, source=source)
144         content = '\n'.join(self.storage.ancestors(id, revision))+'\n'
145         return self.ok_response(environ, start_response, content)
146
147     def children(self, environ, start_response):
148         self.check_login(environ)
149         data = self.query_data(environ)
150         source = 'query'
151         id = self.data_get_id(data, default=None, source=source)
152         revision = self.data_get_string(
153             data, 'revision', default=None, source=source)
154         content = '\n'.join(self.storage.children(id, revision))
155         return self.ok_response(environ, start_response, content)
156
157     def get(self, environ, start_response):
158         self.check_login(environ)
159         data = self.query_data(environ)
160         source = 'query'
161         try:
162             id = environ['be-server.url_args'][0]
163         except:
164             raise libbe.util.wsgi.HandlerError(404, 'Not Found')
165         revision = self.data_get_string(
166             data, 'revision', default=None, source=source)
167         content = self.storage.get(id, revision=revision)
168         be_version = self.storage.storage_version(revision)
169         return self.ok_response(environ, start_response, content,
170                                 headers=[('X-BE-Version', be_version)])
171
172     def set(self, environ, start_response):
173         self.check_login(environ)
174         data = self.post_data(environ)
175         try:
176             id = environ['be-server.url_args'][0]
177         except:
178             raise libbe.util.wsgi.HandlerError(404, 'Not Found')
179         if not 'value' in data:
180             raise libbe.util.wsgi.HandlerError(406, 'Missing query key value')
181         value = data['value']
182         self.storage.set(id, value)
183         if self.notify:
184             self._notify(environ, 'set', id, [('value', value)])
185         return self.ok_response(environ, start_response, None)
186
187     def commit(self, environ, start_response):
188         self.check_login(environ)
189         data = self.post_data(environ)
190         if not 'summary' in data:
191             raise libbe.util.wsgi.HandlerError(
192                 406, 'Missing query key summary')
193         summary = data['summary']
194         if not 'body' in data or data['body'] == 'None':
195             data['body'] = None
196         body = data['body']
197         if not 'allow_empty' in data \
198                 or data['allow_empty'] == 'True':
199             allow_empty = True
200         else:
201             allow_empty = False
202         try:
203             revision = self.storage.commit(summary, body, allow_empty)
204         except libbe.storage.EmptyCommit, e:
205             raise libbe.util.wsgi.HandlerError(
206                 libbe.util.http.HTTP_USER_ERROR, 'EmptyCommit')
207         if self.notify:
208             self._notify(environ, 'commit', id,
209                          [('allow_empty', allow_empty), ('summary', summary),
210                           ('body', body)])
211         return self.ok_response(environ, start_response, revision)
212
213     def revision_id(self, environ, start_response):
214         self.check_login(environ)
215         data = self.query_data(environ)
216         source = 'query'
217         index = int(self.data_get_string(
218             data, 'index', default=libbe.util.wsgi.HandlerError,
219             source=source))
220         content = self.storage.revision_id(index)
221         return self.ok_response(environ, start_response, content)
222
223     def changed(self, environ, start_response):
224         self.check_login(environ)
225         data = self.query_data(environ)
226         source = 'query'
227         revision = self.data_get_string(
228             data, 'revision', default=None, source=source)
229         add,mod,rem = self.storage.changed(revision)
230         content = '\n\n'.join(['\n'.join(p) for p in (add,mod,rem)])
231         return self.ok_response(environ, start_response, content)
232
233     def version(self, environ, start_response):
234         self.check_login(environ)
235         data = self.query_data(environ)
236         source = 'query'
237         revision = self.data_get_string(
238             data, 'revision', default=None, source=source)
239         content = self.storage.storage_version(revision)
240         return self.ok_response(environ, start_response, content)
241
242     # handler utility functions
243     def check_login(self, environ):
244         user = environ.get('be-auth.user', None)
245         if user is not None:  # we're running under AuthenticationApp
246             if environ['REQUEST_METHOD'] == 'POST':
247                 if user == 'guest' or self.storage.is_writeable() == False:
248                     raise _Unauthorized() # only non-guests allowed to write
249             # allow read-only commands for all users
250
251     def _notify(self, environ, command, id, params):
252         message = self._format_notification(environ, command, id, params)
253         self._submit_notification(message)
254
255     def _format_notification(self, environ, command, id, params):
256         key_length = len('command')
257         for key,value in params:
258             if len(key) > key_length and '\n' not in str(value):
259                 key_length = len(key)
260         key_length += 1
261         lines = []
262         multi_line_params = []
263         for key,value in [('address', environ.get('REMOTE_ADDR', '-')),
264                           ('command', command), ('id', id)]+params:
265             v = str(value)
266             if '\n' in v:
267                 multi_line_params.append((key,v))
268                 continue
269             lines.append('%*.*s %s' % (key_length, key_length, key+':', v))
270         lines.append('')
271         for key,value in multi_line_params:
272             lines.extend(['=== START %s ===' % key, v,
273                           '=== STOP %s ===' % key, ''])
274         lines.append('')
275         return '\n'.join(lines)
276
277     def _submit_notification(self, message):
278         libbe.util.subproc.invoke(self.notify, stdin=message, shell=True)
279
280
281 class ServeStorage (libbe.util.wsgi.ServerCommand):
282     """Serve bug directory storage over HTTP.
283
284     This allows you to run local `be` commands interfacing with remote
285     data, transmitting file reads/writes/etc. over the network.
286
287     :py:class:`~libbe.command.base.Command` wrapper around
288     :py:class:`ServerApp`.
289     """
290
291     name = 'serve-storage'
292
293     def _get_app(self, logger, storage, **kwargs):
294         return ServerApp(
295             logger=logger, storage=storage, notify=kwargs.get('notify', False))
296
297     def _long_help(self):
298         return """
299 Example usage::
300
301     $ be serve-storage
302
303 And in another terminal (or after backgrounding the server)::
304
305     $ be --repo http://localhost:8000/ list
306
307 If you bind your server to a public interface, take a look at the
308 ``--read-only`` option or the combined ``--ssl --auth FILE``
309 options so other people can't mess with your repository.  If you do use
310 authentication, you'll need to send in your username and password::
311
312     $ be --repo http://username:password@localhost:8000/ list
313 """
314
315
316 # alias for libbe.command.base.get_command_class()
317 Serve_storage = ServeStorage
318
319
320 if libbe.TESTING:
321     class ServerAppTestCase (libbe.util.wsgi.WSGITestCase):
322         def setUp(self):
323             super(ServerAppTestCase, self).setUp()
324             self.bd = libbe.bugdir.SimpleBugDir(memory=False)
325             self.app = ServerApp(self.bd.storage, logger=self.logger)
326
327         def tearDown(self):
328             self.bd.cleanup()
329             super(ServerAppTestCase, self).tearDown()
330
331         def test_add_get(self):
332             try:
333                 self.getURL(self.app, '/add/', method='GET')
334             except libbe.util.wsgi.HandlerError as e:
335                 self.failUnless(e.code == 404, e)
336             else:
337                 self.fail('GET /add/ did not raise 404')
338
339         def test_add_post(self):
340             self.getURL(self.app, '/add/', method='POST',
341                         data_dict={'id':'123456', 'parent':'abc123',
342                                    'directory':'True'})
343             self.failUnless(self.status == '200 OK', self.status)
344             self.failUnless(self.response_headers == [],
345                             self.response_headers)
346             self.failUnless(self.exc_info is None, self.exc_info)
347         # Note: other methods tested in libbe.storage.http
348
349         # TODO: integration tests on Serve?
350
351     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
352     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])