Merge lp:~trb143/openlp/cherrypy into lp:openlp

Proposed by Tim Bentley
Status: Merged
Merged at revision: 2300
Proposed branch: lp:~trb143/openlp/cherrypy
Merge into: lp:openlp
Diff against target: 1895 lines (+789/-842)
11 files modified
openlp/core/ui/exceptionform.py (+0/-7)
openlp/plugins/remotes/html/openlp.js (+5/-1)
openlp/plugins/remotes/html/stage.js (+3/-3)
openlp/plugins/remotes/lib/__init__.py (+3/-2)
openlp/plugins/remotes/lib/httprouter.py (+638/-0)
openlp/plugins/remotes/lib/httpserver.py (+97/-636)
openlp/plugins/remotes/lib/remotetab.py (+2/-2)
openlp/plugins/remotes/remoteplugin.py (+7/-8)
scripts/check_dependencies.py (+9/-5)
tests/functional/openlp_plugins/remotes/test_router.py (+25/-40)
tests/interfaces/openlp_plugins/remotes/test_server.py (+0/-138)
To merge this branch: bzr merge lp:~trb143/openlp/cherrypy
Reviewer Review Type Date Requested Status
Raoul Snyman Approve
Jeffrey Smith Pending
Review via email: mp+188208@code.launchpad.net

This proposal supersedes a proposal from 2013-09-28.

Description of the change

As the song goes "Bye Bye CherryPy"

Remove CherryPy and replace with pure python HTTPServer. CherryPy was a wrapper to this.

Convert code to use new structures and make code easier to unit test.

Add security tag to routes so individual routes can be secured.

Split read only and update routes where they shared a common function with conditional logic. (Better Android experience)

Sort out dependency checker etc..

To post a comment you must log in.
Revision history for this message
Jeffrey Smith (whydoubt) wrote : Posted in a previous version of this proposal

For .js files, media type 'application/javascript' is generally preferred.
For .ico files, media type 'image/x-icon' is generally preferred.

review: Needs Fixing
Revision history for this message
Raoul Snyman (raoul-snyman) wrote : Posted in a previous version of this proposal

You have a "live" link in the plugin settings, but it doesn't exist?

review: Needs Fixing
Revision history for this message
Raoul Snyman (raoul-snyman) wrote :

Looks good to me.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'openlp/core/ui/exceptionform.py'
2--- openlp/core/ui/exceptionform.py 2013-08-31 18:17:38 +0000
3+++ openlp/core/ui/exceptionform.py 2013-09-28 20:57:10 +0000
4@@ -76,12 +76,6 @@
5 except ImportError:
6 ICU_VERSION = '-'
7 try:
8- import cherrypy
9- CHERRYPY_VERSION = cherrypy.__version__
10-except ImportError:
11- CHERRYPY_VERSION = '-'
12-
13-try:
14 WEBKIT_VERSION = QtWebKit.qWebKitVersion()
15 except AttributeError:
16 WEBKIT_VERSION = '-'
17@@ -140,7 +134,6 @@
18 'Chardet: %s\n' % CHARDET_VERSION + \
19 'PyEnchant: %s\n' % ENCHANT_VERSION + \
20 'Mako: %s\n' % MAKO_VERSION + \
21- 'CherryPy: %s\n' % CHERRYPY_VERSION + \
22 'pyICU: %s\n' % ICU_VERSION + \
23 'pyUNO bridge: %s\n' % self._pyuno_import() + \
24 'VLC: %s\n' % VLC_VERSION
25
26=== modified file 'openlp/plugins/remotes/html/openlp.js'
27--- openlp/plugins/remotes/html/openlp.js 2013-04-23 20:31:19 +0000
28+++ openlp/plugins/remotes/html/openlp.js 2013-09-28 20:57:10 +0000
29@@ -40,6 +40,8 @@
30 // defeat Safari bug
31 targ = targ.parentNode;
32 }
33+ var isSecure = false;
34+ var isAuthorised = false;
35 return $(targ);
36 },
37 getSearchablePlugins: function () {
38@@ -147,11 +149,13 @@
39 },
40 pollServer: function () {
41 $.getJSON(
42- "/stage/poll",
43+ "/api/poll",
44 function (data, status) {
45 var prevItem = OpenLP.currentItem;
46 OpenLP.currentSlide = data.results.slide;
47 OpenLP.currentItem = data.results.item;
48+ OpenLP.isSecure = data.results.isSecure;
49+ OpenLP.isAuthorised = data.results.isAuthorised;
50 if ($("#service-manager").is(":visible")) {
51 if (OpenLP.currentService != data.results.service) {
52 OpenLP.currentService = data.results.service;
53
54=== modified file 'openlp/plugins/remotes/html/stage.js'
55--- openlp/plugins/remotes/html/stage.js 2013-04-23 20:31:19 +0000
56+++ openlp/plugins/remotes/html/stage.js 2013-09-28 20:57:10 +0000
57@@ -26,7 +26,7 @@
58 window.OpenLP = {
59 loadService: function (event) {
60 $.getJSON(
61- "/stage/service/list",
62+ "/api/service/list",
63 function (data, status) {
64 OpenLP.nextSong = "";
65 $("#notes").html("");
66@@ -46,7 +46,7 @@
67 },
68 loadSlides: function (event) {
69 $.getJSON(
70- "/stage/controller/live/text",
71+ "/api/controller/live/text",
72 function (data, status) {
73 OpenLP.currentSlides = data.results.slides;
74 OpenLP.currentSlide = 0;
75@@ -137,7 +137,7 @@
76 },
77 pollServer: function () {
78 $.getJSON(
79- "/stage/poll",
80+ "/api/poll",
81 function (data, status) {
82 OpenLP.updateClock(data);
83 if (OpenLP.currentItem != data.results.item ||
84
85=== modified file 'openlp/plugins/remotes/lib/__init__.py'
86--- openlp/plugins/remotes/lib/__init__.py 2013-08-31 18:17:38 +0000
87+++ openlp/plugins/remotes/lib/__init__.py 2013-09-28 20:57:10 +0000
88@@ -28,6 +28,7 @@
89 ###############################################################################
90
91 from .remotetab import RemoteTab
92-from .httpserver import HttpServer
93+from .httprouter import HttpRouter
94+from .httpserver import OpenLPServer
95
96-__all__ = ['RemoteTab', 'HttpServer']
97+__all__ = ['RemoteTab', 'OpenLPServer', 'HttpRouter']
98
99=== added file 'openlp/plugins/remotes/lib/httprouter.py'
100--- openlp/plugins/remotes/lib/httprouter.py 1970-01-01 00:00:00 +0000
101+++ openlp/plugins/remotes/lib/httprouter.py 2013-09-28 20:57:10 +0000
102@@ -0,0 +1,638 @@
103+# -*- coding: utf-8 -*-
104+# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
105+
106+###############################################################################
107+# OpenLP - Open Source Lyrics Projection #
108+# --------------------------------------------------------------------------- #
109+# Copyright (c) 2008-2013 Raoul Snyman #
110+# Portions copyright (c) 2008-2013 Tim Bentley, Gerald Britton, Jonathan #
111+# Corwin, Samuel Findlay, Michael Gorven, Scott Guerrieri, Matthias Hub, #
112+# Meinert Jordan, Armin Köhler, Erik Lundin, Edwin Lunando, Brian T. Meyer. #
113+# Joshua Miller, Stevan Pettit, Andreas Preikschat, Mattias Põldaru, #
114+# Christian Richter, Philip Ridout, Simon Scudder, Jeffrey Smith, #
115+# Maikel Stuivenberg, Martin Thompson, Jon Tibble, Dave Warnock, #
116+# Frode Woldsund, Martin Zibricky, Patrick Zimmermann #
117+# --------------------------------------------------------------------------- #
118+# This program is free software; you can redistribute it and/or modify it #
119+# under the terms of the GNU General Public License as published by the Free #
120+# Software Foundation; version 2 of the License. #
121+# #
122+# This program is distributed in the hope that it will be useful, but WITHOUT #
123+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
124+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
125+# more details. #
126+# #
127+# You should have received a copy of the GNU General Public License along #
128+# with this program; if not, write to the Free Software Foundation, Inc., 59 #
129+# Temple Place, Suite 330, Boston, MA 02111-1307 USA #
130+###############################################################################
131+
132+"""
133+The :mod:`http` module contains the API web server. This is a lightweight web
134+server used by remotes to interact with OpenLP. It uses JSON to communicate with
135+the remotes.
136+
137+*Routes:*
138+
139+``/``
140+ Go to the web interface.
141+
142+``/stage``
143+ Show the stage view.
144+
145+``/files/{filename}``
146+ Serve a static file.
147+
148+``/stage/api/poll``
149+ Poll to see if there are any changes. Returns a JSON-encoded dict of
150+ any changes that occurred::
151+
152+ {"results": {"type": "controller"}}
153+
154+ Or, if there were no results, False::
155+
156+ {"results": False}
157+
158+``/api/display/{hide|show}``
159+ Blank or unblank the screen.
160+
161+``/api/alert``
162+ Sends an alert message to the alerts plugin. This method expects a
163+ JSON-encoded dict like this::
164+
165+ {"request": {"text": "<your alert text>"}}
166+
167+``/api/controller/{live|preview}/{action}``
168+ Perform ``{action}`` on the live or preview controller. Valid actions
169+ are:
170+
171+ ``next``
172+ Load the next slide.
173+
174+ ``previous``
175+ Load the previous slide.
176+
177+ ``set``
178+ Set a specific slide. Requires an id return in a JSON-encoded dict like
179+ this::
180+
181+ {"request": {"id": 1}}
182+
183+ ``first``
184+ Load the first slide.
185+
186+ ``last``
187+ Load the last slide.
188+
189+ ``text``
190+ Fetches the text of the current song. The output is a JSON-encoded
191+ dict which looks like this::
192+
193+ {"result": {"slides": ["...", "..."]}}
194+
195+``/api/service/{action}``
196+ Perform ``{action}`` on the service manager (e.g. go live). Data is
197+ passed as a json-encoded ``data`` parameter. Valid actions are:
198+
199+ ``next``
200+ Load the next item in the service.
201+
202+ ``previous``
203+ Load the previews item in the service.
204+
205+ ``set``
206+ Set a specific item in the service. Requires an id returned in a
207+ JSON-encoded dict like this::
208+
209+ {"request": {"id": 1}}
210+
211+ ``list``
212+ Request a list of items in the service. Returns a list of items in the
213+ current service in a JSON-encoded dict like this::
214+
215+ {"results": {"items": [{...}, {...}]}}
216+"""
217+import base64
218+import json
219+import logging
220+import os
221+import re
222+import urllib.request
223+import urllib.error
224+from urllib.parse import urlparse, parse_qs
225+
226+
227+from mako.template import Template
228+from PyQt4 import QtCore
229+
230+from openlp.core.lib import Registry, Settings, PluginStatus, StringContent, image_to_byte
231+from openlp.core.utils import AppLocation, translate
232+
233+log = logging.getLogger(__name__)
234+
235+
236+class HttpRouter(object):
237+ """
238+ This code is called by the HttpServer upon a request and it processes it based on the routing table.
239+ This code is stateless and is created on each request.
240+ Some variables may look incorrect but this extends BaseHTTPRequestHandler.
241+ """
242+ def initialise(self):
243+ """
244+ Initialise the router stack and any other variables.
245+ """
246+ authcode = "%s:%s" % (Settings().value('remotes/user id'), Settings().value('remotes/password'))
247+ try:
248+ self.auth = base64.b64encode(authcode)
249+ except TypeError:
250+ self.auth = base64.b64encode(authcode.encode()).decode()
251+ self.routes = [
252+ ('^/$', {'function': self.serve_file, 'secure': False}),
253+ ('^/(stage)$', {'function': self.serve_file, 'secure': False}),
254+ ('^/(main)$', {'function': self.serve_file, 'secure': False}),
255+ (r'^/files/(.*)$', {'function': self.serve_file, 'secure': False}),
256+ (r'^/api/poll$', {'function': self.poll, 'secure': False}),
257+ (r'^/main/poll$', {'function': self.main_poll, 'secure': False}),
258+ (r'^/main/image$', {'function': self.main_image, 'secure': False}),
259+ (r'^/api/controller/(live|preview)/text$', {'function': self.controller_text, 'secure': False}),
260+ (r'^/api/controller/(live|preview)/(.*)$', {'function': self.controller, 'secure': True}),
261+ (r'^/api/service/list$', {'function': self.service_list, 'secure': False}),
262+ (r'^/api/service/(.*)$', {'function': self.service, 'secure': True}),
263+ (r'^/api/display/(hide|show|blank|theme|desktop)$', {'function': self.display, 'secure': True}),
264+ (r'^/api/alert$', {'function': self.alert, 'secure': True}),
265+ (r'^/api/plugin/(search)$', {'function': self.plugin_info, 'secure': False}),
266+ (r'^/api/(.*)/search$', {'function': self.search, 'secure': False}),
267+ (r'^/api/(.*)/live$', {'function': self.go_live, 'secure': True}),
268+ (r'^/api/(.*)/add$', {'function': self.add_to_service, 'secure': True})
269+ ]
270+ self.settings_section = 'remotes'
271+ self.translate()
272+ self.html_dir = os.path.join(AppLocation.get_directory(AppLocation.PluginsDir), 'remotes', 'html')
273+
274+ def do_post_processor(self):
275+ """
276+ Handle the POST amd GET requests placed on the server.
277+ """
278+ if self.path == '/favicon.ico':
279+ return
280+ if not hasattr(self, 'auth'):
281+ self.initialise()
282+ function, args = self.process_http_request(self.path)
283+ if not function:
284+ self.do_http_error()
285+ return
286+ self.authorised = self.headers['Authorization'] is None
287+ if function['secure'] and Settings().value(self.settings_section + '/authentication enabled'):
288+ if self.headers['Authorization'] is None:
289+ self.do_authorisation()
290+ self.wfile.write(bytes('no auth header received', 'UTF-8'))
291+ elif self.headers['Authorization'] == 'Basic %s' % self.auth:
292+ self.do_http_success()
293+ self.call_function(function, *args)
294+ else:
295+ self.do_authorisation()
296+ self.wfile.write(bytes(self.headers['Authorization'], 'UTF-8'))
297+ self.wfile.write(bytes(' not authenticated', 'UTF-8'))
298+ else:
299+ self.call_function(function, *args)
300+
301+ def call_function(self, function, *args):
302+ """
303+ Invoke the route function passing the relevant values
304+
305+ ``function``
306+ The function to be calledL.
307+
308+ ``*args``
309+ Any passed data.
310+ """
311+ response = function['function'](*args)
312+ if response:
313+ self.wfile.write(response)
314+ return
315+
316+ def process_http_request(self, url_path, *args):
317+ """
318+ Common function to process HTTP requests
319+
320+ ``url_path``
321+ The requested URL.
322+
323+ ``*args``
324+ Any passed data.
325+ """
326+ self.request_data = None
327+ url_path_split = urlparse(url_path)
328+ url_query = parse_qs(url_path_split.query)
329+ if 'data' in url_query.keys():
330+ self.request_data = url_query['data'][0]
331+ for route, func in self.routes:
332+ match = re.match(route, url_path_split.path)
333+ if match:
334+ log.debug('Route "%s" matched "%s"', route, url_path)
335+ args = []
336+ for param in match.groups():
337+ args.append(param)
338+ return func, args
339+ return None, None
340+
341+ def do_http_success(self):
342+ """
343+ Create a success http header.
344+ """
345+ self.send_response(200)
346+ self.send_header('Content-type', 'text/html')
347+ self.end_headers()
348+
349+ def do_json_header(self):
350+ """
351+ Create a header for JSON messages
352+ """
353+ self.send_response(200)
354+ self.send_header('Content-type', 'application/json')
355+ self.end_headers()
356+
357+ def do_http_error(self):
358+ """
359+ Create a error http header.
360+ """
361+ self.send_response(404)
362+ self.send_header('Content-type', 'text/html')
363+ self.end_headers()
364+
365+ def do_authorisation(self):
366+ """
367+ Create a needs authorisation http header.
368+ """
369+ self.send_response(401)
370+ self.send_header('WWW-Authenticate', 'Basic realm=\"Test\"')
371+ self.send_header('Content-type', 'text/html')
372+ self.end_headers()
373+
374+ def do_not_found(self):
375+ """
376+ Create a not found http header.
377+ """
378+ self.send_response(404)
379+ self.send_header('Content-type', 'text/html')
380+ self.end_headers()
381+ self.wfile.write(bytes('<html><body>Sorry, an error occurred </body></html>', 'UTF-8'))
382+
383+ def _get_service_items(self):
384+ """
385+ Read the service item in use and return the data as a json object
386+ """
387+ service_items = []
388+ if self.live_controller.service_item:
389+ current_unique_identifier = self.live_controller.service_item.unique_identifier
390+ else:
391+ current_unique_identifier = None
392+ for item in self.service_manager.service_items:
393+ service_item = item['service_item']
394+ service_items.append({
395+ 'id': str(service_item.unique_identifier),
396+ 'title': str(service_item.get_display_title()),
397+ 'plugin': str(service_item.name),
398+ 'notes': str(service_item.notes),
399+ 'selected': (service_item.unique_identifier == current_unique_identifier)
400+ })
401+ return service_items
402+
403+ def translate(self):
404+ """
405+ Translate various strings in the mobile app.
406+ """
407+ self.template_vars = {
408+ 'app_title': translate('RemotePlugin.Mobile', 'OpenLP 2.1 Remote'),
409+ 'stage_title': translate('RemotePlugin.Mobile', 'OpenLP 2.1 Stage View'),
410+ 'live_title': translate('RemotePlugin.Mobile', 'OpenLP 2.1 Live View'),
411+ 'service_manager': translate('RemotePlugin.Mobile', 'Service Manager'),
412+ 'slide_controller': translate('RemotePlugin.Mobile', 'Slide Controller'),
413+ 'alerts': translate('RemotePlugin.Mobile', 'Alerts'),
414+ 'search': translate('RemotePlugin.Mobile', 'Search'),
415+ 'home': translate('RemotePlugin.Mobile', 'Home'),
416+ 'refresh': translate('RemotePlugin.Mobile', 'Refresh'),
417+ 'blank': translate('RemotePlugin.Mobile', 'Blank'),
418+ 'theme': translate('RemotePlugin.Mobile', 'Theme'),
419+ 'desktop': translate('RemotePlugin.Mobile', 'Desktop'),
420+ 'show': translate('RemotePlugin.Mobile', 'Show'),
421+ 'prev': translate('RemotePlugin.Mobile', 'Prev'),
422+ 'next': translate('RemotePlugin.Mobile', 'Next'),
423+ 'text': translate('RemotePlugin.Mobile', 'Text'),
424+ 'show_alert': translate('RemotePlugin.Mobile', 'Show Alert'),
425+ 'go_live': translate('RemotePlugin.Mobile', 'Go Live'),
426+ 'add_to_service': translate('RemotePlugin.Mobile', 'Add to Service'),
427+ 'add_and_go_to_service': translate('RemotePlugin.Mobile', 'Add &amp; Go to Service'),
428+ 'no_results': translate('RemotePlugin.Mobile', 'No Results'),
429+ 'options': translate('RemotePlugin.Mobile', 'Options'),
430+ 'service': translate('RemotePlugin.Mobile', 'Service'),
431+ 'slides': translate('RemotePlugin.Mobile', 'Slides')
432+ }
433+
434+ def serve_file(self, file_name=None):
435+ """
436+ Send a file to the socket. For now, just a subset of file types and must be top level inside the html folder.
437+ If subfolders requested return 404, easier for security for the present.
438+
439+ Ultimately for i18n, this could first look for xx/file.html before falling back to file.html.
440+ where xx is the language, e.g. 'en'
441+ """
442+ log.debug('serve file request %s' % file_name)
443+ if not file_name:
444+ file_name = 'index.html'
445+ elif file_name == 'stage':
446+ file_name = 'stage.html'
447+ elif file_name == 'main':
448+ file_name = 'main.html'
449+ path = os.path.normpath(os.path.join(self.html_dir, file_name))
450+ if not path.startswith(self.html_dir):
451+ return self.do_not_found()
452+ ext = os.path.splitext(file_name)[1]
453+ html = None
454+ if ext == '.html':
455+ self.send_header('Content-type', 'text/html')
456+ variables = self.template_vars
457+ html = Template(filename=path, input_encoding='utf-8', output_encoding='utf-8').render(**variables)
458+ elif ext == '.css':
459+ self.send_header('Content-type', 'text/css')
460+ elif ext == '.js':
461+ self.send_header('Content-type', 'application/javascript')
462+ elif ext == '.jpg':
463+ self.send_header('Content-type', 'image/jpeg')
464+ elif ext == '.gif':
465+ self.send_header('Content-type', 'image/gif')
466+ elif ext == '.ico':
467+ self.send_header('Content-type', 'image/x-icon')
468+ elif ext == '.png':
469+ self.send_header('Content-type', 'image/png')
470+ else:
471+ self.send_header('Content-type', 'text/plain')
472+ file_handle = None
473+ try:
474+ if html:
475+ content = html
476+ else:
477+ file_handle = open(path, 'rb')
478+ log.debug('Opened %s' % path)
479+ content = file_handle.read()
480+ except IOError:
481+ log.exception('Failed to open %s' % path)
482+ return self.do_not_found()
483+ finally:
484+ if file_handle:
485+ file_handle.close()
486+ return content
487+
488+ def poll(self):
489+ """
490+ Poll OpenLP to determine the current slide number and item name.
491+ """
492+ result = {
493+ 'service': self.service_manager.service_id,
494+ 'slide': self.live_controller.selected_row or 0,
495+ 'item': self.live_controller.service_item.unique_identifier if self.live_controller.service_item else '',
496+ 'twelve': Settings().value('remotes/twelve hour'),
497+ 'blank': self.live_controller.blank_screen.isChecked(),
498+ 'theme': self.live_controller.theme_screen.isChecked(),
499+ 'display': self.live_controller.desktop_screen.isChecked(),
500+ 'version': 2,
501+ 'isSecure': Settings().value(self.settings_section + '/authentication enabled'),
502+ 'isAuthorised': self.authorised
503+ }
504+ self.do_json_header()
505+ return json.dumps({'results': result}).encode()
506+
507+ def main_poll(self):
508+ """
509+ Poll OpenLP to determine the current slide count.
510+ """
511+ result = {
512+ 'slide_count': self.live_controller.slide_count
513+ }
514+ self.do_json_header()
515+ return json.dumps({'results': result}).encode()
516+
517+ def main_image(self):
518+ """
519+ Return the latest display image as a byte stream.
520+ """
521+ result = {
522+ 'slide_image': 'data:image/png;base64,' + str(image_to_byte(self.live_controller.slide_image))
523+ }
524+ self.do_json_header()
525+ return json.dumps({'results': result}).encode()
526+
527+ def display(self, action):
528+ """
529+ Hide or show the display screen.
530+ This is a cross Thread call and UI is updated so Events need to be used.
531+
532+ ``action``
533+ This is the action, either ``hide`` or ``show``.
534+ """
535+ self.live_controller.emit(QtCore.SIGNAL('slidecontroller_toggle_display'), action)
536+ self.do_json_header()
537+ return json.dumps({'results': {'success': True}}).encode()
538+
539+ def alert(self):
540+ """
541+ Send an alert.
542+ """
543+ plugin = self.plugin_manager.get_plugin_by_name("alerts")
544+ if plugin.status == PluginStatus.Active:
545+ try:
546+ text = json.loads(self.request_data)['request']['text']
547+ except KeyError as ValueError:
548+ return self.do_http_error()
549+ text = urllib.parse.unquote(text)
550+ self.alerts_manager.emit(QtCore.SIGNAL('alerts_text'), [text])
551+ success = True
552+ else:
553+ success = False
554+ self.do_json_header()
555+ return json.dumps({'results': {'success': success}}).encode()
556+
557+ def controller_text(self, var):
558+ """
559+ Perform an action on the slide controller.
560+ """
561+ current_item = self.live_controller.service_item
562+ data = []
563+ if current_item:
564+ for index, frame in enumerate(current_item.get_frames()):
565+ item = {}
566+ if current_item.is_text():
567+ if frame['verseTag']:
568+ item['tag'] = str(frame['verseTag'])
569+ else:
570+ item['tag'] = str(index + 1)
571+ item['text'] = str(frame['text'])
572+ item['html'] = str(frame['html'])
573+ else:
574+ item['tag'] = str(index + 1)
575+ item['text'] = str(frame['title'])
576+ item['html'] = str(frame['title'])
577+ item['selected'] = (self.live_controller.selected_row == index)
578+ data.append(item)
579+ json_data = {'results': {'slides': data}}
580+ if current_item:
581+ json_data['results']['item'] = self.live_controller.service_item.unique_identifier
582+ self.do_json_header()
583+ return json.dumps(json_data).encode()
584+
585+ def controller(self, display_type, action):
586+ """
587+ Perform an action on the slide controller.
588+
589+ ``display_type``
590+ This is the type of slide controller, either ``preview`` or ``live``.
591+
592+ ``action``
593+ The action to perform.
594+ """
595+ event = 'slidecontroller_%s_%s' % (display_type, action)
596+ if self.request_data:
597+ try:
598+ data = json.loads(self.request_data)['request']['id']
599+ except KeyError as ValueError:
600+ return self.do_http_error()
601+ log.info(data)
602+ # This slot expects an int within a list.
603+ self.live_controller.emit(QtCore.SIGNAL(event), [data])
604+ else:
605+ self.live_controller.emit(QtCore.SIGNAL(event))
606+ json_data = {'results': {'success': True}}
607+ self.do_json_header()
608+ return json.dumps(json_data).encode()
609+
610+ def service_list(self):
611+ """
612+ Handles requests for service items in the service manager
613+
614+ ``action``
615+ The action to perform.
616+ """
617+ self.do_json_header()
618+ return json.dumps({'results': {'items': self._get_service_items()}}).encode()
619+
620+ def service(self, action):
621+ """
622+ Handles requests for service items in the service manager
623+
624+ ``action``
625+ The action to perform.
626+ """
627+ event = 'servicemanager_%s_item' % action
628+ if self.request_data:
629+ try:
630+ data = json.loads(self.request_data)['request']['id']
631+ except KeyError:
632+ return self.do_http_error()
633+ self.service_manager.emit(QtCore.SIGNAL(event), data)
634+ else:
635+ Registry().execute(event)
636+ self.do_json_header()
637+ return json.dumps({'results': {'success': True}}).encode()
638+
639+ def plugin_info(self, action):
640+ """
641+ Return plugin related information, based on the action.
642+
643+ ``action``
644+ The action to perform. If *search* return a list of plugin names
645+ which support search.
646+ """
647+ if action == 'search':
648+ searches = []
649+ for plugin in self.plugin_manager.plugins:
650+ if plugin.status == PluginStatus.Active and plugin.media_item and plugin.media_item.has_search:
651+ searches.append([plugin.name, str(plugin.text_strings[StringContent.Name]['plural'])])
652+ self.do_json_header()
653+ return json.dumps({'results': {'items': searches}}).encode()
654+
655+ def search(self, plugin_name):
656+ """
657+ Return a list of items that match the search text.
658+
659+ ``plugin``
660+ The plugin name to search in.
661+ """
662+ try:
663+ text = json.loads(self.request_data)['request']['text']
664+ except KeyError as ValueError:
665+ return self.do_http_error()
666+ text = urllib.parse.unquote(text)
667+ plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
668+ if plugin.status == PluginStatus.Active and plugin.media_item and plugin.media_item.has_search:
669+ results = plugin.media_item.search(text, False)
670+ else:
671+ results = []
672+ self.do_json_header()
673+ return json.dumps({'results': {'items': results}}).encode()
674+
675+ def go_live(self, plugin_name):
676+ """
677+ Go live on an item of type ``plugin``.
678+ """
679+ try:
680+ id = json.loads(self.request_data)['request']['id']
681+ except KeyError as ValueError:
682+ return self.do_http_error()
683+ plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
684+ if plugin.status == PluginStatus.Active and plugin.media_item:
685+ plugin.media_item.emit(QtCore.SIGNAL('%s_go_live' % plugin_name), [id, True])
686+ return self.do_http_success()
687+
688+ def add_to_service(self, plugin_name):
689+ """
690+ Add item of type ``plugin_name`` to the end of the service.
691+ """
692+ try:
693+ id = json.loads(self.request_data)['request']['id']
694+ except KeyError as ValueError:
695+ return self.do_http_error()
696+ plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
697+ if plugin.status == PluginStatus.Active and plugin.media_item:
698+ item_id = plugin.media_item.create_item_from_id(id)
699+ plugin.media_item.emit(QtCore.SIGNAL('%s_add_to_service' % plugin_name), [item_id, True])
700+ self.do_http_success()
701+
702+ def _get_service_manager(self):
703+ """
704+ Adds the service manager to the class dynamically
705+ """
706+ if not hasattr(self, '_service_manager'):
707+ self._service_manager = Registry().get('service_manager')
708+ return self._service_manager
709+
710+ service_manager = property(_get_service_manager)
711+
712+ def _get_live_controller(self):
713+ """
714+ Adds the live controller to the class dynamically
715+ """
716+ if not hasattr(self, '_live_controller'):
717+ self._live_controller = Registry().get('live_controller')
718+ return self._live_controller
719+
720+ live_controller = property(_get_live_controller)
721+
722+ def _get_plugin_manager(self):
723+ """
724+ Adds the plugin manager to the class dynamically
725+ """
726+ if not hasattr(self, '_plugin_manager'):
727+ self._plugin_manager = Registry().get('plugin_manager')
728+ return self._plugin_manager
729+
730+ plugin_manager = property(_get_plugin_manager)
731+
732+ def _get_alerts_manager(self):
733+ """
734+ Adds the alerts manager to the class dynamically
735+ """
736+ if not hasattr(self, '_alerts_manager'):
737+ self._alerts_manager = Registry().get('alerts_manager')
738+ return self._alerts_manager
739+
740+ alerts_manager = property(_get_alerts_manager)
741
742=== modified file 'openlp/plugins/remotes/lib/httpserver.py'
743--- openlp/plugins/remotes/lib/httpserver.py 2013-08-31 18:17:38 +0000
744+++ openlp/plugins/remotes/lib/httpserver.py 2013-09-28 20:57:10 +0000
745@@ -31,661 +31,122 @@
746 The :mod:`http` module contains the API web server. This is a lightweight web
747 server used by remotes to interact with OpenLP. It uses JSON to communicate with
748 the remotes.
749-
750-*Routes:*
751-
752-``/``
753- Go to the web interface.
754-
755-``/stage``
756- Show the stage view.
757-
758-``/files/{filename}``
759- Serve a static file.
760-
761-``/stage/api/poll``
762- Poll to see if there are any changes. Returns a JSON-encoded dict of
763- any changes that occurred::
764-
765- {"results": {"type": "controller"}}
766-
767- Or, if there were no results, False::
768-
769- {"results": False}
770-
771-``/api/display/{hide|show}``
772- Blank or unblank the screen.
773-
774-``/api/alert``
775- Sends an alert message to the alerts plugin. This method expects a
776- JSON-encoded dict like this::
777-
778- {"request": {"text": "<your alert text>"}}
779-
780-``/api/controller/{live|preview}/{action}``
781- Perform ``{action}`` on the live or preview controller. Valid actions
782- are:
783-
784- ``next``
785- Load the next slide.
786-
787- ``previous``
788- Load the previous slide.
789-
790- ``set``
791- Set a specific slide. Requires an id return in a JSON-encoded dict like
792- this::
793-
794- {"request": {"id": 1}}
795-
796- ``first``
797- Load the first slide.
798-
799- ``last``
800- Load the last slide.
801-
802- ``text``
803- Fetches the text of the current song. The output is a JSON-encoded
804- dict which looks like this::
805-
806- {"result": {"slides": ["...", "..."]}}
807-
808-``/api/service/{action}``
809- Perform ``{action}`` on the service manager (e.g. go live). Data is
810- passed as a json-encoded ``data`` parameter. Valid actions are:
811-
812- ``next``
813- Load the next item in the service.
814-
815- ``previous``
816- Load the previews item in the service.
817-
818- ``set``
819- Set a specific item in the service. Requires an id returned in a
820- JSON-encoded dict like this::
821-
822- {"request": {"id": 1}}
823-
824- ``list``
825- Request a list of items in the service. Returns a list of items in the
826- current service in a JSON-encoded dict like this::
827-
828- {"results": {"items": [{...}, {...}]}}
829 """
830
831-import json
832+import ssl
833+import socket
834+import os
835 import logging
836-import os
837-import re
838-import urllib.request, urllib.parse, urllib.error
839-import urllib.parse
840-import cherrypy
841+from urllib.parse import urlparse, parse_qs
842
843-from mako.template import Template
844 from PyQt4 import QtCore
845
846-from openlp.core.lib import Registry, Settings, PluginStatus, StringContent, image_to_byte
847-from openlp.core.utils import AppLocation, translate
848-
849-from hashlib import sha1
850+from openlp.core.lib import Settings
851+from openlp.core.utils import AppLocation
852+
853+from openlp.plugins.remotes.lib import HttpRouter
854+
855+from socketserver import BaseServer, ThreadingMixIn
856+from http.server import BaseHTTPRequestHandler, HTTPServer
857
858 log = logging.getLogger(__name__)
859
860
861-def make_sha_hash(password):
862- """
863- Create an encrypted password for the given password.
864- """
865- log.debug("make_sha_hash")
866- return sha1(password.encode()).hexdigest()
867-
868-
869-def fetch_password(username):
870- """
871- Fetch the password for a provided user.
872- """
873- log.debug("Fetch Password")
874- if username != Settings().value('remotes/user id'):
875- return None
876- return make_sha_hash(Settings().value('remotes/password'))
877-
878-
879-class HttpServer(object):
880- """
881- Ability to control OpenLP via a web browser.
882- This class controls the Cherrypy server and configuration.
883- """
884- _cp_config = {
885- 'tools.sessions.on': True,
886- 'tools.auth.on': True
887- }
888-
889+class CustomHandler(BaseHTTPRequestHandler, HttpRouter):
890+ """
891+ Stateless session handler to handle the HTTP request and process it.
892+ This class handles just the overrides to the base methods and the logic to invoke the
893+ methods within the HttpRouter class.
894+ DO not try change the structure as this is as per the documentation.
895+ """
896+
897+ def do_POST(self):
898+ """
899+ Present pages / data and invoke URL level user authentication.
900+ """
901+ self.do_post_processor()
902+
903+ def do_GET(self):
904+ """
905+ Present pages / data and invoke URL level user authentication.
906+ """
907+ self.do_post_processor()
908+
909+
910+class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
911+ pass
912+
913+
914+class HttpThread(QtCore.QThread):
915+ """
916+ A special Qt thread class to allow the HTTP server to run at the same time as the UI.
917+ """
918+ def __init__(self, server):
919+ """
920+ Constructor for the thread class.
921+
922+ ``server``
923+ The http server class.
924+ """
925+ super(HttpThread, self).__init__(None)
926+ self.http_server = server
927+
928+ def run(self):
929+ """
930+ Run the thread.
931+ """
932+ self.http_server.start_server()
933+
934+
935+class OpenLPServer():
936 def __init__(self):
937 """
938- Initialise the http server, and start the server.
939+ Initialise the http server, and start the server of the correct type http / https
940 """
941 log.debug('Initialise httpserver')
942 self.settings_section = 'remotes'
943- self.router = HttpRouter()
944+ self.http_thread = HttpThread(self)
945+ self.http_thread.start()
946
947 def start_server(self):
948 """
949- Start the http server based on configuration.
950- """
951- log.debug('Start CherryPy server')
952- # Define to security levels and inject the router code
953- self.root = self.Public()
954- self.root.files = self.Files()
955- self.root.stage = self.Stage()
956- self.root.main = self.Main()
957- self.root.router = self.router
958- self.root.files.router = self.router
959- self.root.stage.router = self.router
960- self.root.main.router = self.router
961- cherrypy.tree.mount(self.root, '/', config=self.define_config())
962- # Turn off the flood of access messages cause by poll
963- cherrypy.log.access_log.propagate = False
964- cherrypy.engine.start()
965-
966- def define_config(self):
967- """
968- Define the configuration of the server.
969- """
970+ Start the correct server and save the handler
971+ """
972+ address = Settings().value(self.settings_section + '/ip address')
973 if Settings().value(self.settings_section + '/https enabled'):
974 port = Settings().value(self.settings_section + '/https port')
975- address = Settings().value(self.settings_section + '/ip address')
976- local_data = AppLocation.get_directory(AppLocation.DataDir)
977- cherrypy.config.update({'server.socket_host': str(address),
978- 'server.socket_port': port,
979- 'server.ssl_certificate': os.path.join(local_data, 'remotes', 'openlp.crt'),
980- 'server.ssl_private_key': os.path.join(local_data, 'remotes', 'openlp.key')})
981+ self.httpd = HTTPSServer((address, port), CustomHandler)
982+ log.debug('Started ssl httpd...')
983 else:
984 port = Settings().value(self.settings_section + '/port')
985- address = Settings().value(self.settings_section + '/ip address')
986- cherrypy.config.update({'server.socket_host': str(address)})
987- cherrypy.config.update({'server.socket_port': port})
988- cherrypy.config.update({'environment': 'embedded'})
989- cherrypy.config.update({'engine.autoreload_on': False})
990- directory_config = {'/': {'tools.staticdir.on': True,
991- 'tools.staticdir.dir': self.router.html_dir,
992- 'tools.basic_auth.on': Settings().value('remotes/authentication enabled'),
993- 'tools.basic_auth.realm': 'OpenLP Remote Login',
994- 'tools.basic_auth.users': fetch_password,
995- 'tools.basic_auth.encrypt': make_sha_hash},
996- '/files': {'tools.staticdir.on': True,
997- 'tools.staticdir.dir': self.router.html_dir,
998- 'tools.basic_auth.on': False},
999- '/stage': {'tools.staticdir.on': True,
1000- 'tools.staticdir.dir': self.router.html_dir,
1001- 'tools.basic_auth.on': False},
1002- '/main': {'tools.staticdir.on': True,
1003- 'tools.staticdir.dir': self.router.html_dir,
1004- 'tools.basic_auth.on': False}}
1005- return directory_config
1006-
1007- class Public(object):
1008- """
1009- Main access class with may have security enabled on it.
1010- """
1011- @cherrypy.expose
1012- def default(self, *args, **kwargs):
1013- self.router.request_data = None
1014- if isinstance(kwargs, dict):
1015- self.router.request_data = kwargs.get('data', None)
1016- url = urllib.parse.urlparse(cherrypy.url())
1017- return self.router.process_http_request(url.path, *args)
1018-
1019- class Files(object):
1020- """
1021- Provides access to files and has no security available. These are read only accesses
1022- """
1023- @cherrypy.expose
1024- def default(self, *args, **kwargs):
1025- url = urllib.parse.urlparse(cherrypy.url())
1026- return self.router.process_http_request(url.path, *args)
1027-
1028- class Stage(object):
1029- """
1030- Stage view is read only so security is not relevant and would reduce it's usability
1031- """
1032- @cherrypy.expose
1033- def default(self, *args, **kwargs):
1034- url = urllib.parse.urlparse(cherrypy.url())
1035- return self.router.process_http_request(url.path, *args)
1036-
1037- class Main(object):
1038- """
1039- Main view is read only so security is not relevant and would reduce it's usability
1040- """
1041- @cherrypy.expose
1042- def default(self, *args, **kwargs):
1043- url = urllib.parse.urlparse(cherrypy.url())
1044- return self.router.process_http_request(url.path, *args)
1045-
1046- def close(self):
1047- """
1048- Close down the http server.
1049- """
1050- log.debug('close http server')
1051- cherrypy.engine.exit()
1052-
1053-
1054-class HttpRouter(object):
1055- """
1056- This code is called by the HttpServer upon a request and it processes it based on the routing table.
1057- """
1058- def __init__(self):
1059- """
1060- Initialise the router
1061- """
1062- self.routes = [
1063- ('^/$', self.serve_file),
1064- ('^/(stage)$', self.serve_file),
1065- ('^/(main)$', self.serve_file),
1066- (r'^/files/(.*)$', self.serve_file),
1067- (r'^/api/poll$', self.poll),
1068- (r'^/stage/poll$', self.poll),
1069- (r'^/main/poll$', self.main_poll),
1070- (r'^/main/image$', self.main_image),
1071- (r'^/api/controller/(live|preview)/(.*)$', self.controller),
1072- (r'^/stage/controller/(live|preview)/(.*)$', self.controller),
1073- (r'^/api/service/(.*)$', self.service),
1074- (r'^/stage/service/(.*)$', self.service),
1075- (r'^/api/display/(hide|show|blank|theme|desktop)$', self.display),
1076- (r'^/api/alert$', self.alert),
1077- (r'^/api/plugin/(search)$', self.plugin_info),
1078- (r'^/api/(.*)/search$', self.search),
1079- (r'^/api/(.*)/live$', self.go_live),
1080- (r'^/api/(.*)/add$', self.add_to_service)
1081- ]
1082- self.translate()
1083- self.html_dir = os.path.join(AppLocation.get_directory(AppLocation.PluginsDir), 'remotes', 'html')
1084-
1085- def process_http_request(self, url_path, *args):
1086- """
1087- Common function to process HTTP requests
1088-
1089- ``url_path``
1090- The requested URL.
1091-
1092- ``*args``
1093- Any passed data.
1094- """
1095- response = None
1096- for route, func in self.routes:
1097- match = re.match(route, url_path)
1098- if match:
1099- log.debug('Route "%s" matched "%s"', route, url_path)
1100- args = []
1101- for param in match.groups():
1102- args.append(param)
1103- response = func(*args)
1104- break
1105- if response:
1106- return response
1107- else:
1108- log.debug('Path not found %s', url_path)
1109- return self._http_not_found()
1110-
1111- def _get_service_items(self):
1112- """
1113- Read the service item in use and return the data as a json object
1114- """
1115- service_items = []
1116- if self.live_controller.service_item:
1117- current_unique_identifier = self.live_controller.service_item.unique_identifier
1118- else:
1119- current_unique_identifier = None
1120- for item in self.service_manager.service_items:
1121- service_item = item['service_item']
1122- service_items.append({
1123- 'id': str(service_item.unique_identifier),
1124- 'title': str(service_item.get_display_title()),
1125- 'plugin': str(service_item.name),
1126- 'notes': str(service_item.notes),
1127- 'selected': (service_item.unique_identifier == current_unique_identifier)
1128- })
1129- return service_items
1130-
1131- def translate(self):
1132- """
1133- Translate various strings in the mobile app.
1134- """
1135- self.template_vars = {
1136- 'app_title': translate('RemotePlugin.Mobile', 'OpenLP 2.1 Remote'),
1137- 'stage_title': translate('RemotePlugin.Mobile', 'OpenLP 2.1 Stage View'),
1138- 'live_title': translate('RemotePlugin.Mobile', 'OpenLP 2.1 Live View'),
1139- 'service_manager': translate('RemotePlugin.Mobile', 'Service Manager'),
1140- 'slide_controller': translate('RemotePlugin.Mobile', 'Slide Controller'),
1141- 'alerts': translate('RemotePlugin.Mobile', 'Alerts'),
1142- 'search': translate('RemotePlugin.Mobile', 'Search'),
1143- 'home': translate('RemotePlugin.Mobile', 'Home'),
1144- 'refresh': translate('RemotePlugin.Mobile', 'Refresh'),
1145- 'blank': translate('RemotePlugin.Mobile', 'Blank'),
1146- 'theme': translate('RemotePlugin.Mobile', 'Theme'),
1147- 'desktop': translate('RemotePlugin.Mobile', 'Desktop'),
1148- 'show': translate('RemotePlugin.Mobile', 'Show'),
1149- 'prev': translate('RemotePlugin.Mobile', 'Prev'),
1150- 'next': translate('RemotePlugin.Mobile', 'Next'),
1151- 'text': translate('RemotePlugin.Mobile', 'Text'),
1152- 'show_alert': translate('RemotePlugin.Mobile', 'Show Alert'),
1153- 'go_live': translate('RemotePlugin.Mobile', 'Go Live'),
1154- 'add_to_service': translate('RemotePlugin.Mobile', 'Add to Service'),
1155- 'add_and_go_to_service': translate('RemotePlugin.Mobile', 'Add &amp; Go to Service'),
1156- 'no_results': translate('RemotePlugin.Mobile', 'No Results'),
1157- 'options': translate('RemotePlugin.Mobile', 'Options'),
1158- 'service': translate('RemotePlugin.Mobile', 'Service'),
1159- 'slides': translate('RemotePlugin.Mobile', 'Slides')
1160- }
1161-
1162- def serve_file(self, file_name=None):
1163- """
1164- Send a file to the socket. For now, just a subset of file types and must be top level inside the html folder.
1165- If subfolders requested return 404, easier for security for the present.
1166-
1167- Ultimately for i18n, this could first look for xx/file.html before falling back to file.html.
1168- where xx is the language, e.g. 'en'
1169- """
1170- log.debug('serve file request %s' % file_name)
1171- if not file_name:
1172- file_name = 'index.html'
1173- elif file_name == 'stage':
1174- file_name = 'stage.html'
1175- elif file_name == 'main':
1176- file_name = 'main.html'
1177- path = os.path.normpath(os.path.join(self.html_dir, file_name))
1178- if not path.startswith(self.html_dir):
1179- return self._http_not_found()
1180- ext = os.path.splitext(file_name)[1]
1181- html = None
1182- if ext == '.html':
1183- mimetype = 'text/html'
1184- variables = self.template_vars
1185- html = Template(filename=path, input_encoding='utf-8', output_encoding='utf-8').render(**variables)
1186- elif ext == '.css':
1187- mimetype = 'text/css'
1188- elif ext == '.js':
1189- mimetype = 'application/x-javascript'
1190- elif ext == '.jpg':
1191- mimetype = 'image/jpeg'
1192- elif ext == '.gif':
1193- mimetype = 'image/gif'
1194- elif ext == '.png':
1195- mimetype = 'image/png'
1196- else:
1197- mimetype = 'text/plain'
1198- file_handle = None
1199- try:
1200- if html:
1201- content = html
1202- else:
1203- file_handle = open(path, 'rb')
1204- log.debug('Opened %s' % path)
1205- content = file_handle.read()
1206- except IOError:
1207- log.exception('Failed to open %s' % path)
1208- return self._http_not_found()
1209- finally:
1210- if file_handle:
1211- file_handle.close()
1212- cherrypy.response.headers['Content-Type'] = mimetype
1213- return content
1214-
1215- def poll(self):
1216- """
1217- Poll OpenLP to determine the current slide number and item name.
1218- """
1219- result = {
1220- 'service': self.service_manager.service_id,
1221- 'slide': self.live_controller.selected_row or 0,
1222- 'item': self.live_controller.service_item.unique_identifier if self.live_controller.service_item else '',
1223- 'twelve': Settings().value('remotes/twelve hour'),
1224- 'blank': self.live_controller.blank_screen.isChecked(),
1225- 'theme': self.live_controller.theme_screen.isChecked(),
1226- 'display': self.live_controller.desktop_screen.isChecked()
1227- }
1228- cherrypy.response.headers['Content-Type'] = 'application/json'
1229- return json.dumps({'results': result}).encode()
1230-
1231- def main_poll(self):
1232- """
1233- Poll OpenLP to determine the current slide count.
1234- """
1235- result = {
1236- 'slide_count': self.live_controller.slide_count
1237- }
1238- cherrypy.response.headers['Content-Type'] = 'application/json'
1239- return json.dumps({'results': result}).encode()
1240-
1241- def main_image(self):
1242- """
1243- Return the latest display image as a byte stream.
1244- """
1245- result = {
1246- 'slide_image': 'data:image/png;base64,' + str(image_to_byte(self.live_controller.slide_image))
1247- }
1248- cherrypy.response.headers['Content-Type'] = 'application/json'
1249- return json.dumps({'results': result}).encode()
1250-
1251- def display(self, action):
1252- """
1253- Hide or show the display screen.
1254- This is a cross Thread call and UI is updated so Events need to be used.
1255-
1256- ``action``
1257- This is the action, either ``hide`` or ``show``.
1258- """
1259- self.live_controller.emit(QtCore.SIGNAL('slidecontroller_toggle_display'), action)
1260- cherrypy.response.headers['Content-Type'] = 'application/json'
1261- return json.dumps({'results': {'success': True}}).encode()
1262-
1263- def alert(self):
1264- """
1265- Send an alert.
1266- """
1267- plugin = self.plugin_manager.get_plugin_by_name("alerts")
1268- if plugin.status == PluginStatus.Active:
1269- try:
1270- text = json.loads(self.request_data)['request']['text']
1271- except KeyError as ValueError:
1272- return self._http_bad_request()
1273- text = urllib.parse.unquote(text)
1274- self.alerts_manager.emit(QtCore.SIGNAL('alerts_text'), [text])
1275- success = True
1276- else:
1277- success = False
1278- cherrypy.response.headers['Content-Type'] = 'application/json'
1279- return json.dumps({'results': {'success': success}}).encode()
1280-
1281- def controller(self, display_type, action):
1282- """
1283- Perform an action on the slide controller.
1284-
1285- ``display_type``
1286- This is the type of slide controller, either ``preview`` or ``live``.
1287-
1288- ``action``
1289- The action to perform.
1290- """
1291- event = 'slidecontroller_%s_%s' % (display_type, action)
1292- if action == 'text':
1293- current_item = self.live_controller.service_item
1294- data = []
1295- if current_item:
1296- for index, frame in enumerate(current_item.get_frames()):
1297- item = {}
1298- if current_item.is_text():
1299- if frame['verseTag']:
1300- item['tag'] = str(frame['verseTag'])
1301- else:
1302- item['tag'] = str(index + 1)
1303- item['text'] = str(frame['text'])
1304- item['html'] = str(frame['html'])
1305- else:
1306- item['tag'] = str(index + 1)
1307- item['text'] = str(frame['title'])
1308- item['html'] = str(frame['title'])
1309- item['selected'] = (self.live_controller.selected_row == index)
1310- data.append(item)
1311- json_data = {'results': {'slides': data}}
1312- if current_item:
1313- json_data['results']['item'] = self.live_controller.service_item.unique_identifier
1314- else:
1315- if self.request_data:
1316- try:
1317- data = json.loads(self.request_data)['request']['id']
1318- except KeyError as ValueError:
1319- return self._http_bad_request()
1320- log.info(data)
1321- # This slot expects an int within a list.
1322- self.live_controller.emit(QtCore.SIGNAL(event), [data])
1323- else:
1324- self.live_controller.emit(QtCore.SIGNAL(event))
1325- json_data = {'results': {'success': True}}
1326- cherrypy.response.headers['Content-Type'] = 'application/json'
1327- return json.dumps(json_data).encode()
1328-
1329- def service(self, action):
1330- """
1331- Handles requests for service items in the service manager
1332-
1333- ``action``
1334- The action to perform.
1335- """
1336- event = 'servicemanager_%s' % action
1337- if action == 'list':
1338- cherrypy.response.headers['Content-Type'] = 'application/json'
1339- return json.dumps({'results': {'items': self._get_service_items()}}).encode()
1340- event += '_item'
1341- if self.request_data:
1342- try:
1343- data = json.loads(self.request_data)['request']['id']
1344- except KeyError:
1345- return self._http_bad_request()
1346- self.service_manager.emit(QtCore.SIGNAL(event), data)
1347- else:
1348- Registry().execute(event)
1349- cherrypy.response.headers['Content-Type'] = 'application/json'
1350- return json.dumps({'results': {'success': True}}).encode()
1351-
1352- def plugin_info(self, action):
1353- """
1354- Return plugin related information, based on the action.
1355-
1356- ``action``
1357- The action to perform. If *search* return a list of plugin names
1358- which support search.
1359- """
1360- if action == 'search':
1361- searches = []
1362- for plugin in self.plugin_manager.plugins:
1363- if plugin.status == PluginStatus.Active and plugin.media_item and plugin.media_item.has_search:
1364- searches.append([plugin.name, str(plugin.text_strings[StringContent.Name]['plural'])])
1365- cherrypy.response.headers['Content-Type'] = 'application/json'
1366- return json.dumps({'results': {'items': searches}}).encode()
1367-
1368- def search(self, plugin_name):
1369- """
1370- Return a list of items that match the search text.
1371-
1372- ``plugin``
1373- The plugin name to search in.
1374- """
1375- try:
1376- text = json.loads(self.request_data)['request']['text']
1377- except KeyError as ValueError:
1378- return self._http_bad_request()
1379- text = urllib.parse.unquote(text)
1380- plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
1381- if plugin.status == PluginStatus.Active and plugin.media_item and plugin.media_item.has_search:
1382- results = plugin.media_item.search(text, False)
1383- else:
1384- results = []
1385- cherrypy.response.headers['Content-Type'] = 'application/json'
1386- return json.dumps({'results': {'items': results}}).encode()
1387-
1388- def go_live(self, plugin_name):
1389- """
1390- Go live on an item of type ``plugin``.
1391- """
1392- try:
1393- id = json.loads(self.request_data)['request']['id']
1394- except KeyError as ValueError:
1395- return self._http_bad_request()
1396- plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
1397- if plugin.status == PluginStatus.Active and plugin.media_item:
1398- plugin.media_item.emit(QtCore.SIGNAL('%s_go_live' % plugin_name), [id, True])
1399- return self._http_success()
1400-
1401- def add_to_service(self, plugin_name):
1402- """
1403- Add item of type ``plugin_name`` to the end of the service.
1404- """
1405- try:
1406- id = json.loads(self.request_data)['request']['id']
1407- except KeyError as ValueError:
1408- return self._http_bad_request()
1409- plugin = self.plugin_manager.get_plugin_by_name(plugin_name)
1410- if plugin.status == PluginStatus.Active and plugin.media_item:
1411- item_id = plugin.media_item.create_item_from_id(id)
1412- plugin.media_item.emit(QtCore.SIGNAL('%s_add_to_service' % plugin_name), [item_id, True])
1413- self._http_success()
1414-
1415- def _http_success(self):
1416- """
1417- Set the HTTP success return code.
1418- """
1419- cherrypy.response.status = 200
1420-
1421- def _http_bad_request(self):
1422- """
1423- Set the HTTP bad response return code.
1424- """
1425- cherrypy.response.status = 400
1426-
1427- def _http_not_found(self):
1428- """
1429- Set the HTTP not found return code.
1430- """
1431- cherrypy.response.status = 404
1432- cherrypy.response.body = [b'<html><body>Sorry, an error occurred </body></html>']
1433-
1434- def _get_service_manager(self):
1435- """
1436- Adds the service manager to the class dynamically
1437- """
1438- if not hasattr(self, '_service_manager'):
1439- self._service_manager = Registry().get('service_manager')
1440- return self._service_manager
1441-
1442- service_manager = property(_get_service_manager)
1443-
1444- def _get_live_controller(self):
1445- """
1446- Adds the live controller to the class dynamically
1447- """
1448- if not hasattr(self, '_live_controller'):
1449- self._live_controller = Registry().get('live_controller')
1450- return self._live_controller
1451-
1452- live_controller = property(_get_live_controller)
1453-
1454- def _get_plugin_manager(self):
1455- """
1456- Adds the plugin manager to the class dynamically
1457- """
1458- if not hasattr(self, '_plugin_manager'):
1459- self._plugin_manager = Registry().get('plugin_manager')
1460- return self._plugin_manager
1461-
1462- plugin_manager = property(_get_plugin_manager)
1463-
1464- def _get_alerts_manager(self):
1465- """
1466- Adds the alerts manager to the class dynamically
1467- """
1468- if not hasattr(self, '_alerts_manager'):
1469- self._alerts_manager = Registry().get('alerts_manager')
1470- return self._alerts_manager
1471-
1472- alerts_manager = property(_get_alerts_manager)
1473+ self.httpd = ThreadingHTTPServer((address, port), CustomHandler)
1474+ log.debug('Started non ssl httpd...')
1475+ self.httpd.serve_forever()
1476+
1477+ def stop_server(self):
1478+ """
1479+ Stop the server
1480+ """
1481+ self.http_thread.exit(0)
1482+ self.httpd = None
1483+ log.debug('Stopped the server.')
1484+
1485+
1486+class HTTPSServer(HTTPServer):
1487+ def __init__(self, address, handler):
1488+ """
1489+ Initialise the secure handlers for the SSL server if required.s
1490+ """
1491+ BaseServer.__init__(self, address, handler)
1492+ local_data = AppLocation.get_directory(AppLocation.DataDir)
1493+ self.socket = ssl.SSLSocket(
1494+ sock=socket.socket(self.address_family, self.socket_type),
1495+ ssl_version=ssl.PROTOCOL_TLSv1,
1496+ certfile=os.path.join(local_data, 'remotes', 'openlp.crt'),
1497+ keyfile=os.path.join(local_data, 'remotes', 'openlp.key'),
1498+ server_side=True)
1499+ self.server_bind()
1500+ self.server_activate()
1501+
1502+
1503+
1504
1505=== modified file 'openlp/plugins/remotes/lib/remotetab.py'
1506--- openlp/plugins/remotes/lib/remotetab.py 2013-09-09 16:32:13 +0000
1507+++ openlp/plugins/remotes/lib/remotetab.py 2013-09-28 20:57:10 +0000
1508@@ -207,8 +207,8 @@
1509 https_url_temp = https_url + 'stage'
1510 self.stage_url.setText('<a href="%s">%s</a>' % (http_url_temp, http_url_temp))
1511 self.stage_https_url.setText('<a href="%s">%s</a>' % (https_url_temp, https_url_temp))
1512- http_url_temp = http_url + 'live'
1513- https_url_temp = https_url + 'live'
1514+ http_url_temp = http_url + 'main'
1515+ https_url_temp = https_url + 'main'
1516 self.live_url.setText('<a href="%s">%s</a>' % (http_url_temp, http_url_temp))
1517 self.live_https_url.setText('<a href="%s">%s</a>' % (https_url_temp, https_url_temp))
1518
1519
1520=== modified file 'openlp/plugins/remotes/remoteplugin.py'
1521--- openlp/plugins/remotes/remoteplugin.py 2013-08-31 18:17:38 +0000
1522+++ openlp/plugins/remotes/remoteplugin.py 2013-09-28 20:57:10 +0000
1523@@ -28,11 +28,10 @@
1524 ###############################################################################
1525
1526 import logging
1527-
1528-from PyQt4 import QtGui
1529+import time
1530
1531 from openlp.core.lib import Plugin, StringContent, translate, build_icon
1532-from openlp.plugins.remotes.lib import RemoteTab, HttpServer
1533+from openlp.plugins.remotes.lib import RemoteTab, OpenLPServer
1534
1535 log = logging.getLogger(__name__)
1536
1537@@ -67,8 +66,7 @@
1538 """
1539 log.debug('initialise')
1540 super(RemotesPlugin, self).initialise()
1541- self.server = HttpServer()
1542- self.server.start_server()
1543+ self.server = OpenLPServer()
1544
1545 def finalise(self):
1546 """
1547@@ -77,7 +75,7 @@
1548 log.debug('finalise')
1549 super(RemotesPlugin, self).finalise()
1550 if self.server:
1551- self.server.close()
1552+ self.server.stop_server()
1553 self.server = None
1554
1555 def about(self):
1556@@ -109,5 +107,6 @@
1557 Called when Config is changed to restart the server on new address or port
1558 """
1559 log.debug('remote config changed')
1560- self.main_window.information_message(translate('RemotePlugin', 'Configuration Change'),
1561- translate('RemotePlugin', 'OpenLP will need to be restarted for the Remote changes to become active.'))
1562+ self.finalise()
1563+ time.sleep(0.5)
1564+ self.initialise()
1565
1566=== modified file 'scripts/check_dependencies.py'
1567--- scripts/check_dependencies.py 2013-09-07 21:29:31 +0000
1568+++ scripts/check_dependencies.py 2013-09-28 20:57:10 +0000
1569@@ -48,6 +48,7 @@
1570
1571 IS_WIN = sys.platform.startswith('win')
1572
1573+
1574 VERS = {
1575 'Python': '3.0',
1576 'PyQt4': '4.6',
1577@@ -84,7 +85,6 @@
1578 'enchant',
1579 'bs4',
1580 'mako',
1581- 'cherrypy',
1582 'uno',
1583 ]
1584
1585@@ -98,6 +98,7 @@
1586
1587 w = sys.stdout.write
1588
1589+
1590 def check_vers(version, required, text):
1591 if not isinstance(version, str):
1592 version = '.'.join(map(str, version))
1593@@ -111,13 +112,16 @@
1594 w('FAIL' + os.linesep)
1595 return False
1596
1597+
1598 def print_vers_fail(required, text):
1599 print(' %s >= %s ... FAIL' % (text, required))
1600
1601+
1602 def verify_python():
1603 if not check_vers(list(sys.version_info), VERS['Python'], text='Python'):
1604 exit(1)
1605
1606+
1607 def verify_versions():
1608 print('Verifying version of modules...')
1609 try:
1610@@ -138,6 +142,7 @@
1611 except ImportError:
1612 print_vers_fail(VERS['enchant'], 'enchant')
1613
1614+
1615 def check_module(mod, text='', indent=' '):
1616 space = (30 - len(mod) - len(text)) * ' '
1617 w(indent + '%s%s... ' % (mod, text) + space)
1618@@ -148,6 +153,7 @@
1619 w('FAIL')
1620 w(os.linesep)
1621
1622+
1623 def verify_pyenchant():
1624 w('Enchant (spell checker)... ')
1625 try:
1626@@ -160,6 +166,7 @@
1627 except ImportError:
1628 w('FAIL' + os.linesep)
1629
1630+
1631 def verify_pyqt():
1632 w('Qt4 image formats... ')
1633 try:
1634@@ -174,22 +181,19 @@
1635 except ImportError:
1636 w('FAIL' + os.linesep)
1637
1638+
1639 def main():
1640 verify_python()
1641-
1642 print('Checking for modules...')
1643 for m in MODULES:
1644 check_module(m)
1645-
1646 print('Checking for optional modules...')
1647 for m in OPTIONAL_MODULES:
1648 check_module(m[0], text=m[1])
1649-
1650 if IS_WIN:
1651 print('Checking for Windows specific modules...')
1652 for m in WIN32_MODULES:
1653 check_module(m)
1654-
1655 verify_versions()
1656 verify_pyqt()
1657 verify_pyenchant()
1658
1659=== modified file 'tests/functional/openlp_plugins/remotes/test_router.py'
1660--- tests/functional/openlp_plugins/remotes/test_router.py 2013-08-31 18:17:38 +0000
1661+++ tests/functional/openlp_plugins/remotes/test_router.py 2013-09-28 20:57:10 +0000
1662@@ -8,7 +8,7 @@
1663 from mock import MagicMock
1664
1665 from openlp.core.lib import Settings
1666-from openlp.plugins.remotes.lib.httpserver import HttpRouter, fetch_password, make_sha_hash
1667+from openlp.plugins.remotes.lib.httpserver import HttpRouter
1668 from PyQt4 import QtGui
1669
1670 __default_settings__ = {
1671@@ -44,40 +44,22 @@
1672 del self.application
1673 os.unlink(self.ini_file)
1674
1675- def fetch_password_unknown_test(self):
1676- """
1677- Test the fetch password code with an unknown userid
1678- """
1679- # GIVEN: A default configuration
1680- # WHEN: called with the defined userid
1681- password = fetch_password('itwinkle')
1682-
1683- # THEN: the function should return None
1684- self.assertEqual(password, None, 'The result for fetch_password should be None')
1685-
1686- def fetch_password_known_test(self):
1687- """
1688- Test the fetch password code with the defined userid
1689- """
1690- # GIVEN: A default configuration
1691- # WHEN: called with the defined userid
1692- password = fetch_password('openlp')
1693- required_password = make_sha_hash('password')
1694-
1695- # THEN: the function should return the correct password
1696- self.assertEqual(password, required_password, 'The result for fetch_password should be the defined password')
1697-
1698- def sha_password_encrypter_test(self):
1699- """
1700- Test hash password function
1701- """
1702- # GIVEN: A default configuration
1703- # WHEN: called with the defined userid
1704- required_password = make_sha_hash('password')
1705- test_value = '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8'
1706-
1707- # THEN: the function should return the correct password
1708- self.assertEqual(required_password, test_value,
1709+ def password_encrypter_test(self):
1710+ """
1711+ Test hash userid and password function
1712+ """
1713+ # GIVEN: A default configuration
1714+ Settings().setValue('remotes/user id', 'openlp')
1715+ Settings().setValue('remotes/password', 'password')
1716+
1717+ # WHEN: called with the defined userid
1718+ router = HttpRouter()
1719+ router.initialise()
1720+ test_value = 'b3BlbmxwOnBhc3N3b3Jk'
1721+ print(router.auth)
1722+
1723+ # THEN: the function should return the correct password
1724+ self.assertEqual(router.auth, test_value,
1725 'The result for make_sha_hash should return the correct encrypted password')
1726
1727 def process_http_request_test(self):
1728@@ -85,15 +67,18 @@
1729 Test the router control functionality
1730 """
1731 # GIVEN: A testing set of Routes
1732+ router = HttpRouter()
1733 mocked_function = MagicMock()
1734 test_route = [
1735- (r'^/stage/api/poll$', mocked_function),
1736+ (r'^/stage/api/poll$', {'function': mocked_function, 'secure': False}),
1737 ]
1738- self.router.routes = test_route
1739+ router.routes = test_route
1740
1741 # WHEN: called with a poll route
1742- self.router.process_http_request('/stage/api/poll', None)
1743+ function, args = router.process_http_request('/stage/api/poll', None)
1744
1745 # THEN: the function should have been called only once
1746- assert mocked_function.call_count == 1, \
1747- 'The mocked function should have been matched and called once.'
1748+ assert function['function'] == mocked_function, \
1749+ 'The mocked function should match defined value.'
1750+ assert function['secure'] == False, \
1751+ 'The mocked function should not require any security.'
1752\ No newline at end of file
1753
1754=== removed file 'tests/interfaces/openlp_plugins/remotes/test_server.py'
1755--- tests/interfaces/openlp_plugins/remotes/test_server.py 2013-09-09 21:10:40 +0000
1756+++ tests/interfaces/openlp_plugins/remotes/test_server.py 1970-01-01 00:00:00 +0000
1757@@ -1,138 +0,0 @@
1758-"""
1759-This module contains tests for the lib submodule of the Remotes plugin.
1760-"""
1761-import os
1762-
1763-from unittest import TestCase
1764-from tempfile import mkstemp
1765-from mock import MagicMock
1766-import urllib.request, urllib.error, urllib.parse
1767-import cherrypy
1768-
1769-from bs4 import BeautifulSoup
1770-
1771-from openlp.core.lib import Settings
1772-from openlp.plugins.remotes.lib.httpserver import HttpServer
1773-from PyQt4 import QtGui
1774-
1775-__default_settings__ = {
1776- 'remotes/twelve hour': True,
1777- 'remotes/port': 4316,
1778- 'remotes/https port': 4317,
1779- 'remotes/https enabled': False,
1780- 'remotes/user id': 'openlp',
1781- 'remotes/password': 'password',
1782- 'remotes/authentication enabled': False,
1783- 'remotes/ip address': '0.0.0.0'
1784-}
1785-
1786-
1787-class TestRouter(TestCase):
1788- """
1789- Test the functions in the :mod:`lib` module.
1790- """
1791- def setUp(self):
1792- """
1793- Create the UI
1794- """
1795- fd, self.ini_file = mkstemp('.ini')
1796- Settings().set_filename(self.ini_file)
1797- self.application = QtGui.QApplication.instance()
1798- Settings().extend_default_settings(__default_settings__)
1799- self.server = HttpServer()
1800-
1801- def tearDown(self):
1802- """
1803- Delete all the C++ objects at the end so that we don't have a segfault
1804- """
1805- del self.application
1806- os.unlink(self.ini_file)
1807- self.server.close()
1808-
1809- def start_server(self):
1810- """
1811- Common function to start server then mock out the router. CherryPy crashes if you mock before you start
1812- """
1813- self.server.start_server()
1814- self.server.router = MagicMock()
1815- self.server.router.process_http_request = process_http_request
1816-
1817- def start_default_server_test(self):
1818- """
1819- Test the default server serves the correct initial page
1820- """
1821- # GIVEN: A default configuration
1822- Settings().setValue('remotes/authentication enabled', False)
1823- self.start_server()
1824-
1825- # WHEN: called the route location
1826- code, page = call_remote_server('http://localhost:4316')
1827-
1828- # THEN: default title will be returned
1829- self.assertEqual(BeautifulSoup(page).title.text, 'OpenLP 2.1 Remote',
1830- 'The default menu should be returned')
1831-
1832- def start_authenticating_server_test(self):
1833- """
1834- Test the default server serves the correctly with authentication
1835- """
1836- # GIVEN: A default authorised configuration
1837- Settings().setValue('remotes/authentication enabled', True)
1838- self.start_server()
1839-
1840- # WHEN: called the route location with no user details
1841- code, page = call_remote_server('http://localhost:4316')
1842-
1843- # THEN: then server will ask for details
1844- self.assertEqual(code, 401, 'The basic authorisation request should be returned')
1845-
1846- # WHEN: called the route location with user details
1847- code, page = call_remote_server('http://localhost:4316', 'openlp', 'password')
1848-
1849- # THEN: default title will be returned
1850- self.assertEqual(BeautifulSoup(page).title.text, 'OpenLP 2.1 Remote',
1851- 'The default menu should be returned')
1852-
1853- # WHEN: called the route location with incorrect user details
1854- code, page = call_remote_server('http://localhost:4316', 'itwinkle', 'password')
1855-
1856- # THEN: then server will ask for details
1857- self.assertEqual(code, 401, 'The basic authorisation request should be returned')
1858-
1859-
1860-def call_remote_server(url, username=None, password=None):
1861- """
1862- Helper function
1863-
1864- ``username``
1865- The username.
1866-
1867- ``password``
1868- The password.
1869- """
1870- if username:
1871- passman = urllib.request.HTTPPasswordMgrWithDefaultRealm()
1872- passman.add_password(None, url, username, password)
1873- authhandler = urllib.request.HTTPBasicAuthHandler(passman)
1874- opener = urllib.request.build_opener(authhandler)
1875- urllib.request.install_opener(opener)
1876- try:
1877- page = urllib.request.urlopen(url)
1878- return 0, page.read()
1879- except urllib.error.HTTPError as e:
1880- return e.code, ''
1881-
1882-
1883-def process_http_request(url_path, *args):
1884- """
1885- Override function to make the Mock work but does nothing.
1886-
1887- ``Url_path``
1888- The url_path.
1889-
1890- ``*args``
1891- Some args.
1892- """
1893- cherrypy.response.status = 200
1894- return None
1895-