1
# WSGI script for hosting a database of infrared remote configurations
2
# Copyright (C) 2008 Openismus GmbH (www.openismus.com)
4
# This program is free software; you can redistribute it and/or modify it under
5
# the terms of the GNU General Public License as published by the Free Software
6
# Foundation; either version 2 of the License, or (at your option) any later
9
# This program is distributed in the hope that it will be useful, but WITHOUT
10
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
14
# You should have received a copy of the GNU General Public License along with
15
# this program; if not, write to the Free Software Foundation, Inc.,
16
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
19
# Mathias Hasselmann <mathias@openismus.com>
21
WSGI script for hosting a database of infrared remote configurations
24
import cgi, fcntl, httplib, os, rfc822, tarfile, time
26
from errno import ENOENT
27
from gettext import gettext as _
28
from locale import LC_ALL, setlocale
29
from sha import sha as SHA1
32
'''A WSGI request context.'''
34
def __init__(self, environ):
35
super(Context, self).__init__(environ)
37
content_type = property(lambda self: self['CONTENT_TYPE'])
38
request_method = property(lambda self: self['REQUEST_METHOD'].upper())
39
request_uri = property(lambda self: self['REQUEST_URI'])
40
path_info = property(lambda self: self['PATH_INFO'])
42
errors = property(lambda self: self['wsgi.errors'])
43
input = property(lambda self: self['wsgi.input'])
46
'''Wrapper arround the various parts of a WSGI response.'''
48
def __init__(self, status=httplib.OK,
49
content_type='text/html',
52
super(Response, self).__init__()
54
self['Content-Type'] = content_type
55
self.__output = [content]
56
self.__status = status
58
def __get_status(self):
59
'''Query response status as tuple of status code and message.'''
60
return self.__status, __responses__.get(self.__status, '')
62
def __set_status(self, status):
63
'''Update response status (code).'''
64
self.__status = int(status)
66
def __get_output(self):
67
'''Access the list of response chunks.'''
70
status = property(fget=__get_status, fset=__set_status)
71
output = property(fget=__get_output)
73
def find_request_handler(request_method, path_info, default=None):
74
'''Finds the request handler for the current request.'''
76
handler_id = '%s:%s' % (request_method, path_info)
77
return __request_handlers__.get(handler_id, default)
79
def find_locale(locale, localedir='/usr/lib/locale'):
80
'''Finds the name of the matching locallly installed locale.'''
82
for name in '%s.utf8' % locale, locale, locale[:2]:
83
ident = os.path.join(localedir, name, 'LC_IDENTIFICATION')
85
if os.path.isfile(ident):
90
def html_page(context, status, title=None, message=None, *args):
91
'''Creates a response that contains a HTML page.'''
96
<title>%(title)s</title>
107
title = __responses__[status]
109
message = _('Request failed.')
114
status=status, content=template % dict(vars(),
115
signature=context.get('SERVER_SIGNATURE', ''),
118
def redirect(context, path, status=httplib.SEE_OTHER):
119
'''Creates a response that redirects to another page.'''
121
response = html_page(context, status, None,
122
_('See: <a href="%(path)s">%(path)s</a>.') %
123
dict(path=cgi.escape(path)))
125
response['Location'] = path
129
def not_found(context):
130
'''Creates a response that handles missing pages.'''
132
uri = context.request_uri
134
if not uri.endswith('/'):
135
fallback = find_request_handler(context.request_method,
136
context.path_info + '/')
139
return redirect(context, uri + '/')
141
return html_page(context, httplib.NOT_FOUND,
142
_('Resource Not Found'),
143
_('Cannot find this resource: <b>%s</b>.'),
146
def show_upload_form(context):
147
'''Shows the HTML form for uploading LIRC configuration files.'''
152
<title>%(title)s</title>
158
<form action="upload/" method="post" enctype="multipart/form-data">
161
<td>Configuration File:</td>
162
<td><input name="config" type="file" size="40" value="/etc/lirc/lircd.conf"/></td>
166
<td>SHA1 Digest:</td>
167
<td><input name="digest" type="text" size="40" maxlength="40" /></td>
173
<select name="locale">
174
<option value="en_US">English</option>
175
<option value="de_DE">German</option>
180
<td colspan="2" align="right">
181
<button>Upload</button>
187
<p>Download <a href="%(dburi)s">current archive</a>.</p>
192
return Response(content=template % dict(
193
signature=context.get('SERVER_SIGNATURE', ''),
194
title='Upload LIRC Remote Control Configuration',
195
dburi='remotes.tar.gz',
198
def process_upload(context):
199
'''Processes an uploaded LIRC configuration file.'''
201
# pylint: disable-msg=R0911
203
form = cgi.FieldStorage(fp=context.input, environ=context)
205
digest, config, locale = [
206
form.has_key(key) and form[key]
207
for key in ('digest', 'config', 'locale')]
209
locale = locale is not False and find_locale(locale.value)
212
setlocale(LC_ALL, locale)
214
# validate request body:
216
if form.type != 'multipart/form-data':
217
return html_page(context, httplib.BAD_REQUEST, None,
218
_('Request has unexpected content type.'))
220
if form.type != digest is False or config is False or locale is False:
221
return html_page(context, httplib.BAD_REQUEST, None,
222
_('Some fields are missing in this request.'))
224
if digest.value != SHA1(config.value).hexdigest():
225
return html_page(context, httplib.BAD_REQUEST, None,
226
_('Checksum doesn\'t match the uploaded content.'))
228
# process request body:
230
workdir = os.path.dirname(__file__)
231
archive = os.path.join(workdir, 'archive', 'remotes.tar.gz')
232
filename = os.path.join(workdir, 'archive', 'incoming', '%s.conf' % digest.value)
235
# force rebuild by removing the obsolete archive:
239
if ENOENT != ex.errno:
240
return html_page(context, httplib.INTERNAL_SERVER_ERROR, None,
241
_('Cannot remove obsolete remotes archive: %s.'),
244
# store the uploaded configuration file in the incoming folder:
246
storage = open(filename, 'wb')
249
return html_page(context, httplib.INTERNAL_SERVER_ERROR, None,
250
_('Cannot store configuration file: %s.'),
254
fcntl.lockf(storage, fcntl.LOCK_EX)
257
return html_page(context, httplib.INTERNAL_SERVER_ERROR, None,
258
_('Cannot get exclusive file access: %s.'),
261
storage.write(config.file.read())
264
# use POST/REDIRECT/GET pattern to avoid duplicate uploads:
265
return redirect(context, context.request_uri + 'success')
267
def show_upload_success(context):
268
'''Shows success message after upload'''
270
return html_page(context, httplib.OK, _('Upload Succeeded'),
271
_('Upload of your configuration file succeeded. '
272
'Thanks alot for contributing.'))
274
def send_archive(context, filename, must_exist=False):
275
'''Sends the specified tarball.'''
278
# Checks last-modified time, when requested:
279
reference_time = context.get('HTTP_IF_MODIFIED_SINCE')
282
reference_time = rfc822.parsedate(reference_time)
283
reference_time = time.mktime(reference_time)
285
if reference_time >= os.path.getmtime(filename):
286
return Response(httplib.NOT_MODIFIED)
288
# Deliver the file with checksum and last-modified header:
289
response = Response(content_type='application/x-gzip',
290
content=open(filename, 'rb').read())
292
digest = SHA1(response.output[0]).hexdigest()
293
timestamp = time.ctime(os.path.getmtime(filename))
295
response['X-Checksum-Sha1'] = digest
296
response['Last-Modified'] = timestamp
300
except (IOError, OSError), ex:
301
if must_exist or ENOENT != ex.errno:
302
return html_page(context,
303
httplib.INTERNAL_SERVER_ERROR, None,
304
_('Cannot read remotes archive: %s.'),
309
def send_remotes_archive(context):
310
'''Sends the archive with uploaded LIRC configuration files.'''
312
archive_root = os.path.join(os.path.dirname(__file__), 'archive')
313
archive_name = os.path.join(archive_root, 'remotes.tar.gz')
315
response = send_archive(context, archive_name)
319
archive = tarfile.open(archive_name, 'w:gz')
322
return html_page(context, httplib.INTERNAL_SERVER_ERROR, None,
323
_('Cannot create remotes archive: %s.'),
327
# pylint: disable-msg=W0612
328
for path, subdirs, files in os.walk(archive_root):
329
# drop folders with SCM meta-information:
330
subdirs[:] = [name for name in subdirs if
331
not name.startswith('.')
334
# drop archive_root from current folder name:
335
dirname = path[len(archive_root):]
337
# scan files in current folder:
339
if name.startswith('.'):
342
filename = os.path.join(path, name)
343
arcname = os.path.join(dirname, name)
345
if filename == archive_name:
348
info = archive.gettarinfo(filename, arcname)
349
archive.addfile(info, open(filename))
351
# Trigger finalization of the tarball instance,
352
# since Python 2.4 doesn't create its file otherwise:
356
response = send_archive(context, archive_name, must_exist=True)
360
# Try to remove artifacts on error:
361
os.unlink(archive.name)
370
def application(environ, start_response):
371
'''The WSGI entry point of this service.'''
373
context, response = Context(environ), None
376
handler = find_request_handler(context.request_method,
379
response = handler(context)
384
response = Response(content_type='text/plain',
385
content=traceback.format_exc(),
386
status=httplib.INTERNAL_SERVER_ERROR)
388
traceback.print_exc(context.errors)
390
if (not response.has_key('Content-Length') and
391
response.get('Transfer-Encoding', '').lower() != 'chunked'):
392
response['Content-Length'] = str(len(''.join(response.output)))
394
start_response('%d %s' % response.status, response.items())
396
return response.output
398
# Map status codes to official W3C names (from httplib/2.5):
401
101: 'Switching Protocols',
406
203: 'Non-Authoritative Information',
408
205: 'Reset Content',
409
206: 'Partial Content',
411
300: 'Multiple Choices',
412
301: 'Moved Permanently',
418
307: 'Temporary Redirect',
422
402: 'Payment Required',
425
405: 'Method Not Allowed',
426
406: 'Not Acceptable',
427
407: 'Proxy Authentication Required',
428
408: 'Request Timeout',
431
411: 'Length Required',
432
412: 'Precondition Failed',
433
413: 'Request Entity Too Large',
434
414: 'Request-URI Too Long',
435
415: 'Unsupported Media Type',
436
416: 'Requested Range Not Satisfiable',
437
417: 'Expectation Failed',
439
500: 'Internal Server Error',
440
501: 'Not Implemented',
442
503: 'Service Unavailable',
443
504: 'Gateway Timeout',
444
505: 'HTTP Version Not Supported',
447
# Mapping request paths to request handlers:
448
__request_handlers__ = {
449
'GET:/remotes.tar.gz': send_remotes_archive,
450
'GET:/upload/success': show_upload_success,
451
'GET:/': show_upload_form,
453
'POST:/upload/': process_upload,
457
# pylint: disable-msg=W0702,W0704