Mercurial > libervia-web
comparison libervia/web/server/server.py @ 1518:eb00d593801d
refactoring: rename `libervia` to `libervia.web` + update imports following backend changes
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 16:49:28 +0200 |
parents | libervia/server/server.py@a3ca1bab6eb1 |
children | 01b8d68edd70 |
comparison
equal
deleted
inserted
replaced
1517:b8ed9726525b | 1518:eb00d593801d |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Libervia Web | |
4 # Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
16 # You should have received a copy of the GNU Affero General Public License | |
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | |
19 from functools import partial | |
20 import os.path | |
21 from pathlib import Path | |
22 import re | |
23 import sys | |
24 import time | |
25 from typing import Callable, Dict, Optional | |
26 import urllib.error | |
27 import urllib.parse | |
28 import urllib.request | |
29 | |
30 from twisted.application import service | |
31 from twisted.internet import defer, inotify, reactor | |
32 from twisted.python import failure | |
33 from twisted.python import filepath | |
34 from twisted.python.components import registerAdapter | |
35 from twisted.web import server | |
36 from twisted.web import static | |
37 from twisted.web import resource as web_resource | |
38 from twisted.web import util as web_util | |
39 from twisted.web import vhost | |
40 from twisted.words.protocols.jabber import jid | |
41 | |
42 import libervia.web | |
43 from libervia.web.server import websockets | |
44 from libervia.web.server import session_iface | |
45 from libervia.web.server.constants import Const as C | |
46 from libervia.web.server.pages import LiberviaPage | |
47 from libervia.web.server.tasks.manager import TasksManager | |
48 from libervia.web.server.utils import ProgressHandler | |
49 from libervia.backend.core import exceptions | |
50 from libervia.backend.core.i18n import _ | |
51 from libervia.backend.core.log import getLogger | |
52 from libervia.backend.tools import utils | |
53 from libervia.backend.tools import config | |
54 from libervia.backend.tools.common import regex | |
55 from libervia.backend.tools.common import template | |
56 from libervia.backend.tools.common import data_format | |
57 from libervia.backend.tools.common import tls | |
58 from libervia.frontends.bridge.bridge_frontend import BridgeException | |
59 from libervia.frontends.bridge.dbus_bridge import BridgeExceptionNoService, bridge | |
60 from libervia.frontends.bridge.dbus_bridge import const_TIMEOUT as BRIDGE_TIMEOUT | |
61 | |
62 from .resources import LiberviaRootResource, ProtectedFile | |
63 from .restricted_bridge import RestrictedBridge | |
64 | |
65 log = getLogger(__name__) | |
66 | |
67 | |
68 DEFAULT_MASK = (inotify.IN_CREATE | inotify.IN_MODIFY | inotify.IN_MOVE_SELF | |
69 | inotify.IN_MOVED_TO) | |
70 | |
71 | |
72 class SysExit(Exception): | |
73 | |
74 def __init__(self, exit_code, message=""): | |
75 self.exit_code = exit_code | |
76 self.message = message | |
77 | |
78 def __str__(self): | |
79 return f"System Exit({self.exit_code}): {self.message}" | |
80 | |
81 | |
82 class FilesWatcher(object): | |
83 """Class to check files modifications using iNotify""" | |
84 _notifier = None | |
85 | |
86 def __init__(self, host): | |
87 self.host = host | |
88 | |
89 @property | |
90 def notifier(self): | |
91 if self._notifier == None: | |
92 notifier = self.__class__._notifier = inotify.INotify() | |
93 notifier.startReading() | |
94 return self._notifier | |
95 | |
96 def _check_callback(self, dir_path, callback, recursive): | |
97 # Twisted doesn't add callback if a watcher was already set on a path | |
98 # but in dev mode Libervia watches whole sites + internal path can be watched | |
99 # by tasks, so several callbacks must be called on some paths. | |
100 # This method check that the new callback is indeed present in the desired path | |
101 # and add it otherwise. | |
102 # FIXME: this should probably be fixed upstream | |
103 if recursive: | |
104 for child in dir_path.walk(): | |
105 if child.isdir(): | |
106 self._check_callback(child, callback, recursive=False) | |
107 else: | |
108 watch_id = self.notifier._isWatched(dir_path) | |
109 if watch_id is None: | |
110 log.warning( | |
111 f"There is no watch ID for path {dir_path}, this should not happen" | |
112 ) | |
113 else: | |
114 watch_point = self.notifier._watchpoints[watch_id] | |
115 if callback not in watch_point.callbacks: | |
116 watch_point.callbacks.append(callback) | |
117 | |
118 def watch_dir(self, dir_path, callback, mask=DEFAULT_MASK, auto_add=False, | |
119 recursive=False, **kwargs): | |
120 dir_path = str(dir_path) | |
121 log.info(_("Watching directory {dir_path}").format(dir_path=dir_path)) | |
122 wrapped_callback = lambda __, filepath, mask: callback( | |
123 self.host, filepath, inotify.humanReadableMask(mask), **kwargs) | |
124 callbacks = [wrapped_callback] | |
125 dir_path = filepath.FilePath(dir_path) | |
126 self.notifier.watch( | |
127 dir_path, mask=mask, autoAdd=auto_add, recursive=recursive, | |
128 callbacks=callbacks) | |
129 self._check_callback(dir_path, wrapped_callback, recursive) | |
130 | |
131 | |
132 class WebSession(server.Session): | |
133 sessionTimeout = C.SESSION_TIMEOUT | |
134 | |
135 def __init__(self, *args, **kwargs): | |
136 self.__lock = False | |
137 server.Session.__init__(self, *args, **kwargs) | |
138 | |
139 def lock(self): | |
140 """Prevent session from expiring""" | |
141 self.__lock = True | |
142 self._expireCall.reset(sys.maxsize) | |
143 | |
144 def unlock(self): | |
145 """Allow session to expire again, and touch it""" | |
146 self.__lock = False | |
147 self.touch() | |
148 | |
149 def touch(self): | |
150 if not self.__lock: | |
151 server.Session.touch(self) | |
152 | |
153 | |
154 class WaitingRequests(dict): | |
155 def set_request(self, request, profile, register_with_ext_jid=False): | |
156 """Add the given profile to the waiting list. | |
157 | |
158 @param request (server.Request): the connection request | |
159 @param profile (str): %(doc_profile)s | |
160 @param register_with_ext_jid (bool): True if we will try to register the | |
161 profile with an external XMPP account credentials | |
162 """ | |
163 dc = reactor.callLater(BRIDGE_TIMEOUT, self.purge_request, profile) | |
164 self[profile] = (request, dc, register_with_ext_jid) | |
165 | |
166 def purge_request(self, profile): | |
167 """Remove the given profile from the waiting list. | |
168 | |
169 @param profile (str): %(doc_profile)s | |
170 """ | |
171 try: | |
172 dc = self[profile][1] | |
173 except KeyError: | |
174 return | |
175 if dc.active(): | |
176 dc.cancel() | |
177 del self[profile] | |
178 | |
179 def get_request(self, profile): | |
180 """Get the waiting request for the given profile. | |
181 | |
182 @param profile (str): %(doc_profile)s | |
183 @return: the waiting request or None | |
184 """ | |
185 return self[profile][0] if profile in self else None | |
186 | |
187 def get_register_with_ext_jid(self, profile): | |
188 """Get the value of the register_with_ext_jid parameter. | |
189 | |
190 @param profile (str): %(doc_profile)s | |
191 @return: bool or None | |
192 """ | |
193 return self[profile][2] if profile in self else None | |
194 | |
195 | |
196 class LiberviaWeb(service.Service): | |
197 debug = defer.Deferred.debug # True if twistd/Libervia is launched in debug mode | |
198 | |
199 def __init__(self, options): | |
200 self.options = options | |
201 websockets.host = self | |
202 | |
203 def _init(self): | |
204 # we do init here and not in __init__ to avoid doule initialisation with twistd | |
205 # this _init is called in startService | |
206 self.initialised = defer.Deferred() | |
207 self.waiting_profiles = WaitingRequests() # FIXME: should be removed | |
208 self._main_conf = None | |
209 self.files_watcher = FilesWatcher(self) | |
210 | |
211 if self.options["base_url_ext"]: | |
212 self.base_url_ext = self.options.pop("base_url_ext") | |
213 if self.base_url_ext[-1] != "/": | |
214 self.base_url_ext += "/" | |
215 self.base_url_ext_data = urllib.parse.urlsplit(self.base_url_ext) | |
216 else: | |
217 self.base_url_ext = None | |
218 # we split empty string anyway so we can do things like | |
219 # scheme = self.base_url_ext_data.scheme or 'https' | |
220 self.base_url_ext_data = urllib.parse.urlsplit("") | |
221 | |
222 if not self.options["port_https_ext"]: | |
223 self.options["port_https_ext"] = self.options["port_https"] | |
224 | |
225 self._cleanup = [] | |
226 | |
227 self.sessions = {} # key = session value = user | |
228 self.prof_connected = set() # Profiles connected | |
229 self.ns_map = {} # map of short name to namespaces | |
230 | |
231 ## bridge ## | |
232 self._bridge_retry = self.options['bridge-retries'] | |
233 self.bridge = bridge() | |
234 self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb) | |
235 | |
236 ## libervia app callbacks ## | |
237 # mapping instance id to the callback to call on "started" signal | |
238 self.apps_cb: Dict[str, Callable] = {} | |
239 | |
240 @property | |
241 def roots(self): | |
242 """Return available virtual host roots | |
243 | |
244 Root resources are only returned once, even if they are present for multiple | |
245 named vhosts. Order is not relevant, except for default vhost which is always | |
246 returned first. | |
247 @return (list[web_resource.Resource]): all vhost root resources | |
248 """ | |
249 roots = list(set(self.vhost_root.hosts.values())) | |
250 default = self.vhost_root.default | |
251 if default is not None and default not in roots: | |
252 roots.insert(0, default) | |
253 return roots | |
254 | |
255 @property | |
256 def main_conf(self): | |
257 """SafeConfigParser instance opened on configuration file (libervia.conf)""" | |
258 if self._main_conf is None: | |
259 self._main_conf = config.parse_main_conf(log_filenames=True) | |
260 return self._main_conf | |
261 | |
262 def config_get(self, site_root_res, key, default=None, value_type=None): | |
263 """Retrieve configuration associated to a site | |
264 | |
265 Section is automatically set to site name | |
266 @param site_root_res(LiberviaRootResource): resource of the site in use | |
267 @param key(unicode): key to use | |
268 @param default: value to use if not found (see [config.config_get]) | |
269 @param value_type(unicode, None): filter to use on value | |
270 Note that filters are already automatically used when the key finish | |
271 by a well known suffix ("_path", "_list", "_dict", or "_json") | |
272 None to use no filter, else can be: | |
273 - "path": a path is expected, will be normalized and expanded | |
274 | |
275 """ | |
276 section = site_root_res.site_name.lower().strip() or C.CONFIG_SECTION | |
277 value = config.config_get(self.main_conf, section, key, default=default) | |
278 if value_type is not None: | |
279 if value_type == 'path': | |
280 v_filter = lambda v: os.path.abspath(os.path.expanduser(v)) | |
281 else: | |
282 raise ValueError("unknown value type {value_type}".format( | |
283 value_type = value_type)) | |
284 if isinstance(value, list): | |
285 value = [v_filter(v) for v in value] | |
286 elif isinstance(value, dict): | |
287 value = {k:v_filter(v) for k,v in list(value.items())} | |
288 elif value is not None: | |
289 value = v_filter(value) | |
290 return value | |
291 | |
292 def _namespaces_get_cb(self, ns_map): | |
293 self.ns_map = {str(k): str(v) for k,v in ns_map.items()} | |
294 | |
295 def _namespaces_get_eb(self, failure_): | |
296 log.error(_("Can't get namespaces map: {msg}").format(msg=failure_)) | |
297 | |
298 @template.contextfilter | |
299 def _front_url_filter(self, ctx, relative_url): | |
300 template_data = ctx['template_data'] | |
301 return os.path.join( | |
302 '/', C.TPL_RESOURCE, template_data.site or C.SITE_NAME_DEFAULT, | |
303 C.TEMPLATE_TPL_DIR, template_data.theme, relative_url) | |
304 | |
305 def _move_first_level_to_dict(self, options, key, keys_to_keep): | |
306 """Read a config option and put value at first level into u'' dict | |
307 | |
308 This is useful to put values for Libervia official site directly in dictionary, | |
309 and to use site_name as keys when external sites are used. | |
310 options will be modified in place | |
311 @param options(dict): options to modify | |
312 @param key(unicode): setting key to modify | |
313 @param keys_to_keep(list(unicode)): keys allowed in first level | |
314 """ | |
315 try: | |
316 conf = options[key] | |
317 except KeyError: | |
318 return | |
319 if not isinstance(conf, dict): | |
320 options[key] = {'': conf} | |
321 return | |
322 default_dict = conf.get('', {}) | |
323 to_delete = [] | |
324 for key, value in conf.items(): | |
325 if key not in keys_to_keep: | |
326 default_dict[key] = value | |
327 to_delete.append(key) | |
328 for key in to_delete: | |
329 del conf[key] | |
330 if default_dict: | |
331 conf[''] = default_dict | |
332 | |
333 async def check_and_connect_service_profile(self): | |
334 passphrase = self.options["passphrase"] | |
335 if not passphrase: | |
336 raise SysExit( | |
337 C.EXIT_BAD_ARG, | |
338 _("No passphrase set for service profile, please check installation " | |
339 "documentation.") | |
340 ) | |
341 try: | |
342 s_prof_connected = await self.bridge_call("is_connected", C.SERVICE_PROFILE) | |
343 except BridgeException as e: | |
344 if e.classname == "ProfileUnknownError": | |
345 log.info("Service profile doesn't exist, creating it.") | |
346 try: | |
347 xmpp_domain = await self.bridge_call("config_get", "", "xmpp_domain") | |
348 xmpp_domain = xmpp_domain.strip() | |
349 if not xmpp_domain: | |
350 raise SysExit( | |
351 C.EXIT_BAD_ARG, | |
352 _('"xmpp_domain" must be set to create new accounts, please ' | |
353 'check documentation') | |
354 ) | |
355 service_profile_jid_s = f"{C.SERVICE_PROFILE}@{xmpp_domain}" | |
356 await self.bridge_call( | |
357 "in_band_account_new", | |
358 service_profile_jid_s, | |
359 passphrase, | |
360 "", | |
361 xmpp_domain, | |
362 0, | |
363 ) | |
364 except BridgeException as e: | |
365 if e.condition == "conflict": | |
366 log.info( | |
367 _("Service's profile JID {profile_jid} already exists") | |
368 .format(profile_jid=service_profile_jid_s) | |
369 ) | |
370 elif e.classname == "UnknownMethod": | |
371 raise SysExit( | |
372 C.EXIT_BRIDGE_ERROR, | |
373 _("Can't create service profile XMPP account, In-Band " | |
374 "Registration plugin is not activated, you'll have to " | |
375 "create the {profile!r} profile with {profile_jid!r} JID " | |
376 "manually.").format( | |
377 profile=C.SERVICE_PROFILE, | |
378 profile_jid=service_profile_jid_s) | |
379 ) | |
380 elif e.condition == "service-unavailable": | |
381 raise SysExit( | |
382 C.EXIT_BRIDGE_ERROR, | |
383 _("Can't create service profile XMPP account, In-Band " | |
384 "Registration is not activated on your server, you'll have " | |
385 "to create the {profile!r} profile with {profile_jid!r} JID " | |
386 "manually.\nNote that you'll need to activate In-Band " | |
387 "Registation on your server if you want users to be able " | |
388 "to create new account from {app_name}, please check " | |
389 "documentation.").format( | |
390 profile=C.SERVICE_PROFILE, | |
391 profile_jid=service_profile_jid_s, | |
392 app_name=C.APP_NAME) | |
393 ) | |
394 elif e.condition == "not-acceptable": | |
395 raise SysExit( | |
396 C.EXIT_BRIDGE_ERROR, | |
397 _("Can't create service profile XMPP account, your XMPP " | |
398 "server doesn't allow us to create new accounts with " | |
399 "In-Band Registration please check XMPP server " | |
400 "configuration: {reason}" | |
401 ).format( | |
402 profile=C.SERVICE_PROFILE, | |
403 profile_jid=service_profile_jid_s, | |
404 reason=e.message) | |
405 ) | |
406 | |
407 else: | |
408 raise SysExit( | |
409 C.EXIT_BRIDGE_ERROR, | |
410 _("Can't create service profile XMPP account, you'll have " | |
411 "do to it manually: {reason}").format(reason=e.message) | |
412 ) | |
413 try: | |
414 await self.bridge_call("profile_create", C.SERVICE_PROFILE, passphrase) | |
415 await self.bridge_call( | |
416 "profile_start_session", passphrase, C.SERVICE_PROFILE) | |
417 await self.bridge_call( | |
418 "param_set", "JabberID", service_profile_jid_s, "Connection", -1, | |
419 C.SERVICE_PROFILE) | |
420 await self.bridge_call( | |
421 "param_set", "Password", passphrase, "Connection", -1, | |
422 C.SERVICE_PROFILE) | |
423 except BridgeException as e: | |
424 raise SysExit( | |
425 C.EXIT_BRIDGE_ERROR, | |
426 _("Can't create service profile XMPP account, you'll have " | |
427 "do to it manually: {reason}").format(reason=e.message) | |
428 ) | |
429 log.info(_("Service profile has been successfully created")) | |
430 s_prof_connected = False | |
431 else: | |
432 raise SysExit(C.EXIT_BRIDGE_ERROR, e.message) | |
433 | |
434 if not s_prof_connected: | |
435 try: | |
436 await self.bridge_call( | |
437 "connect", | |
438 C.SERVICE_PROFILE, | |
439 passphrase, | |
440 {}, | |
441 ) | |
442 except BridgeException as e: | |
443 raise SysExit( | |
444 C.EXIT_BRIDGE_ERROR, | |
445 _("Connection of service profile failed: {reason}").format(reason=e) | |
446 ) | |
447 | |
448 async def backend_ready(self): | |
449 log.info(f"Libervia Web v{self.full_version}") | |
450 | |
451 # settings | |
452 if self.options['dev-mode']: | |
453 log.info(_("Developer mode activated")) | |
454 self.media_dir = await self.bridge_call("config_get", "", "media_dir") | |
455 self.local_dir = await self.bridge_call("config_get", "", "local_dir") | |
456 self.cache_root_dir = os.path.join(self.local_dir, C.CACHE_DIR) | |
457 self.renderer = template.Renderer(self, self._front_url_filter) | |
458 sites_names = list(self.renderer.sites_paths.keys()) | |
459 | |
460 self._move_first_level_to_dict(self.options, "url_redirections_dict", sites_names) | |
461 self._move_first_level_to_dict(self.options, "menu_json", sites_names) | |
462 self._move_first_level_to_dict(self.options, "menu_extra_json", sites_names) | |
463 menu = self.options["menu_json"] | |
464 if not '' in menu: | |
465 menu[''] = C.DEFAULT_MENU | |
466 for site, value in self.options["menu_extra_json"].items(): | |
467 menu[site].extend(value) | |
468 | |
469 # service profile | |
470 if not self.options['build-only']: | |
471 await self.check_and_connect_service_profile() | |
472 | |
473 # restricted bridge, the one used by browser code | |
474 self.restricted_bridge = RestrictedBridge(self) | |
475 | |
476 # we create virtual hosts and import Libervia pages into them | |
477 self.vhost_root = vhost.NameVirtualHost() | |
478 default_site_path = Path(libervia.web.__file__).parent.resolve() | |
479 # self.sat_root is official Libervia site | |
480 root_path = default_site_path / C.TEMPLATE_STATIC_DIR | |
481 self.sat_root = default_root = LiberviaRootResource( | |
482 host=self, host_name='', site_name='', | |
483 site_path=default_site_path, path=root_path) | |
484 if self.options['dev-mode']: | |
485 self.files_watcher.watch_dir( | |
486 default_site_path, auto_add=True, recursive=True, | |
487 callback=LiberviaPage.on_file_change, site_root=self.sat_root, | |
488 site_path=default_site_path) | |
489 LiberviaPage.import_pages(self, self.sat_root) | |
490 tasks_manager = TasksManager(self, self.sat_root) | |
491 await tasks_manager.parse_tasks() | |
492 await tasks_manager.run_tasks() | |
493 # FIXME: handle _set_menu in a more generic way, taking care of external sites | |
494 await self.sat_root._set_menu(self.options["menu_json"]) | |
495 self.vhost_root.default = default_root | |
496 existing_vhosts = {b'': default_root} | |
497 | |
498 for host_name, site_name in self.options["vhosts_dict"].items(): | |
499 if site_name == C.SITE_NAME_DEFAULT: | |
500 raise ValueError( | |
501 f"{C.DEFAULT_SITE_NAME} is reserved and can't be used in vhosts_dict") | |
502 encoded_site_name = site_name.encode('utf-8') | |
503 try: | |
504 site_path = self.renderer.sites_paths[site_name] | |
505 except KeyError: | |
506 log.warning(_( | |
507 "host {host_name} link to non existing site {site_name}, ignoring " | |
508 "it").format(host_name=host_name, site_name=site_name)) | |
509 continue | |
510 if encoded_site_name in existing_vhosts: | |
511 # we have an alias host, we re-use existing resource | |
512 res = existing_vhosts[encoded_site_name] | |
513 else: | |
514 # for root path we first check if there is a global static dir | |
515 # if not, we use default template's static dir | |
516 root_path = os.path.join(site_path, C.TEMPLATE_STATIC_DIR) | |
517 if not os.path.isdir(root_path): | |
518 root_path = os.path.join( | |
519 site_path, C.TEMPLATE_TPL_DIR, C.TEMPLATE_THEME_DEFAULT, | |
520 C.TEMPLATE_STATIC_DIR) | |
521 res = LiberviaRootResource( | |
522 host=self, | |
523 host_name=host_name, | |
524 site_name=site_name, | |
525 site_path=site_path, | |
526 path=root_path) | |
527 | |
528 existing_vhosts[encoded_site_name] = res | |
529 | |
530 if self.options['dev-mode']: | |
531 self.files_watcher.watch_dir( | |
532 site_path, auto_add=True, recursive=True, | |
533 callback=LiberviaPage.on_file_change, site_root=res, | |
534 # FIXME: site_path should always be a Path, check code above and | |
535 # in template module | |
536 site_path=Path(site_path)) | |
537 | |
538 LiberviaPage.import_pages(self, res) | |
539 # FIXME: default pages are accessible if not overriden by external website | |
540 # while necessary for login or re-using existing pages | |
541 # we may want to disable access to the page by direct URL | |
542 # (e.g. /blog disabled except if called by external site) | |
543 LiberviaPage.import_pages(self, res, root_path=default_site_path) | |
544 tasks_manager = TasksManager(self, res) | |
545 await tasks_manager.parse_tasks() | |
546 await tasks_manager.run_tasks() | |
547 await res._set_menu(self.options["menu_json"]) | |
548 | |
549 self.vhost_root.addHost(host_name.encode('utf-8'), res) | |
550 | |
551 templates_res = web_resource.Resource() | |
552 self.put_child_all(C.TPL_RESOURCE.encode('utf-8'), templates_res) | |
553 for site_name, site_path in self.renderer.sites_paths.items(): | |
554 templates_res.putChild(site_name.encode() or C.SITE_NAME_DEFAULT.encode(), | |
555 static.File(site_path)) | |
556 | |
557 d = self.bridge_call("namespaces_get") | |
558 d.addCallback(self._namespaces_get_cb) | |
559 d.addErrback(self._namespaces_get_eb) | |
560 | |
561 # websocket | |
562 if self.options["connection_type"] in ("https", "both"): | |
563 wss = websockets.LiberviaPageWSProtocol.get_resource(secure=True) | |
564 self.put_child_all(b'wss', wss) | |
565 if self.options["connection_type"] in ("http", "both"): | |
566 ws = websockets.LiberviaPageWSProtocol.get_resource(secure=False) | |
567 self.put_child_all(b'ws', ws) | |
568 | |
569 # following signal is needed for cache handling in Libervia pages | |
570 self.bridge.register_signal( | |
571 "ps_event_raw", partial(LiberviaPage.on_node_event, self), "plugin" | |
572 ) | |
573 self.bridge.register_signal( | |
574 "message_new", partial(self.on_signal, "message_new") | |
575 ) | |
576 self.bridge.register_signal( | |
577 "call_accepted", partial(self.on_signal, "call_accepted"), "plugin" | |
578 ) | |
579 self.bridge.register_signal( | |
580 "call_ended", partial(self.on_signal, "call_ended"), "plugin" | |
581 ) | |
582 self.bridge.register_signal( | |
583 "ice_candidates_new", partial(self.on_signal, "ice_candidates_new"), "plugin" | |
584 ) | |
585 self.bridge.register_signal( | |
586 "action_new", self.action_new_handler, | |
587 ) | |
588 | |
589 # libervia applications handling | |
590 self.bridge.register_signal( | |
591 "application_started", self.application_started_handler, "plugin" | |
592 ) | |
593 self.bridge.register_signal( | |
594 "application_error", self.application_error_handler, "plugin" | |
595 ) | |
596 | |
597 # Progress handling | |
598 self.bridge.register_signal( | |
599 "progress_started", partial(ProgressHandler._signal, "started") | |
600 ) | |
601 self.bridge.register_signal( | |
602 "progress_finished", partial(ProgressHandler._signal, "finished") | |
603 ) | |
604 self.bridge.register_signal( | |
605 "progress_error", partial(ProgressHandler._signal, "error") | |
606 ) | |
607 | |
608 # media dirs | |
609 # FIXME: get rid of dirname and "/" in C.XXX_DIR | |
610 self.put_child_all(os.path.dirname(C.MEDIA_DIR).encode('utf-8'), | |
611 ProtectedFile(self.media_dir)) | |
612 | |
613 self.cache_resource = web_resource.NoResource() | |
614 self.put_child_all(C.CACHE_DIR.encode('utf-8'), self.cache_resource) | |
615 self.cache_resource.putChild( | |
616 b"common", ProtectedFile(str(self.cache_root_dir / Path("common")))) | |
617 | |
618 # redirections | |
619 for root in self.roots: | |
620 await root._init_redirections(self.options) | |
621 | |
622 # no need to keep url_redirections_dict, it will not be used anymore | |
623 del self.options["url_redirections_dict"] | |
624 | |
625 server.Request.defaultContentType = "text/html; charset=utf-8" | |
626 wrapped = web_resource.EncodingResourceWrapper( | |
627 self.vhost_root, [server.GzipEncoderFactory()] | |
628 ) | |
629 self.site = server.Site(wrapped) | |
630 self.site.sessionFactory = WebSession | |
631 | |
632 def _bridge_cb(self): | |
633 del self._bridge_retry | |
634 self.bridge.ready_get( | |
635 lambda: self.initialised.callback(None), | |
636 lambda failure: self.initialised.errback(Exception(failure)), | |
637 ) | |
638 self.initialised.addCallback(lambda __: defer.ensureDeferred(self.backend_ready())) | |
639 | |
640 def _bridge_eb(self, failure_): | |
641 if isinstance(failure_, BridgeExceptionNoService): | |
642 if self._bridge_retry: | |
643 if self._bridge_retry < 0: | |
644 print(_("Can't connect to bridge, will retry indefinitely. " | |
645 "Next try in 1s.")) | |
646 else: | |
647 self._bridge_retry -= 1 | |
648 print( | |
649 _( | |
650 "Can't connect to bridge, will retry in 1 s ({retries_left} " | |
651 "trie(s) left)." | |
652 ).format(retries_left=self._bridge_retry) | |
653 ) | |
654 time.sleep(1) | |
655 self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb) | |
656 return | |
657 | |
658 print("Can't connect to SàT backend, are you sure it's launched ?") | |
659 else: | |
660 log.error("Can't connect to bridge: {}".format(failure)) | |
661 sys.exit(1) | |
662 | |
663 @property | |
664 def version(self): | |
665 """Return the short version of Libervia""" | |
666 return C.APP_VERSION | |
667 | |
668 @property | |
669 def full_version(self): | |
670 """Return the full version of Libervia (with extra data when in dev mode)""" | |
671 version = self.version | |
672 if version[-1] == "D": | |
673 # we are in debug version, we add extra data | |
674 try: | |
675 return self._version_cache | |
676 except AttributeError: | |
677 self._version_cache = "{} ({})".format( | |
678 version, utils.get_repository_data(libervia.web) | |
679 ) | |
680 return self._version_cache | |
681 else: | |
682 return version | |
683 | |
684 def bridge_call(self, method_name, *args, **kwargs): | |
685 """Call an asynchronous bridge method and return a deferred | |
686 | |
687 @param method_name: name of the method as a unicode | |
688 @return: a deferred which trigger the result | |
689 | |
690 """ | |
691 d = defer.Deferred() | |
692 | |
693 def _callback(*args): | |
694 if not args: | |
695 d.callback(None) | |
696 else: | |
697 if len(args) != 1: | |
698 Exception("Multiple return arguments not supported") | |
699 d.callback(args[0]) | |
700 | |
701 def _errback(failure_): | |
702 d.errback(failure.Failure(failure_)) | |
703 | |
704 kwargs["callback"] = _callback | |
705 kwargs["errback"] = _errback | |
706 getattr(self.bridge, method_name)(*args, **kwargs) | |
707 return d | |
708 | |
709 def action_new_handler( | |
710 self, | |
711 action_data_s: str, | |
712 action_id: str, | |
713 security_limit: int, | |
714 profile: str | |
715 ) -> None: | |
716 if security_limit > C.SECURITY_LIMIT: | |
717 log.debug( | |
718 f"ignoring action {action_id} due to security limit" | |
719 ) | |
720 else: | |
721 self.on_signal( | |
722 "action_new", action_data_s, action_id, security_limit, profile | |
723 ) | |
724 | |
725 def on_signal(self, signal_name, *args): | |
726 profile = args[-1] | |
727 if not profile: | |
728 log.error(f"got signal without profile: {signal_name}, {args}") | |
729 return | |
730 session_iface.WebSession.send( | |
731 profile, | |
732 "bridge", | |
733 {"signal": signal_name, "args": args} | |
734 ) | |
735 | |
736 def application_started_handler( | |
737 self, | |
738 name: str, | |
739 instance_id: str, | |
740 extra_s: str | |
741 ) -> None: | |
742 callback = self.apps_cb.pop(instance_id, None) | |
743 if callback is not None: | |
744 defer.ensureDeferred(callback(str(name), str(instance_id))) | |
745 | |
746 def application_error_handler( | |
747 self, | |
748 name: str, | |
749 instance_id: str, | |
750 extra_s: str | |
751 ) -> None: | |
752 callback = self.apps_cb.pop(instance_id, None) | |
753 if callback is not None: | |
754 extra = data_format.deserialise(extra_s) | |
755 log.error( | |
756 f"Can't start application {name}: {extra['class']}\n{extra['msg']}" | |
757 ) | |
758 | |
759 async def _logged(self, profile, request): | |
760 """Set everything when a user just logged in | |
761 | |
762 @param profile | |
763 @param request | |
764 @return: a constant indicating the state: | |
765 - C.PROFILE_LOGGED | |
766 - C.PROFILE_LOGGED_EXT_JID | |
767 @raise exceptions.ConflictError: session is already active | |
768 """ | |
769 register_with_ext_jid = self.waiting_profiles.get_register_with_ext_jid(profile) | |
770 self.waiting_profiles.purge_request(profile) | |
771 session = request.getSession() | |
772 web_session = session_iface.IWebSession(session) | |
773 if web_session.profile: | |
774 log.error(_("/!\\ Session has already a profile, this should NEVER happen!")) | |
775 raise failure.Failure(exceptions.ConflictError("Already active")) | |
776 | |
777 # XXX: we force string because python D-Bus has its own string type (dbus.String) | |
778 # which may cause trouble when exposing it to scripts | |
779 web_session.profile = str(profile) | |
780 self.prof_connected.add(profile) | |
781 cache_dir = os.path.join( | |
782 self.cache_root_dir, "profiles", regex.path_escape(profile) | |
783 ) | |
784 # FIXME: would be better to have a global /cache URL which redirect to | |
785 # profile's cache directory, without uuid | |
786 self.cache_resource.putChild(web_session.uuid.encode('utf-8'), | |
787 ProtectedFile(cache_dir)) | |
788 log.debug( | |
789 _("profile cache resource added from {uuid} to {path}").format( | |
790 uuid=web_session.uuid, path=cache_dir | |
791 ) | |
792 ) | |
793 | |
794 def on_expire(): | |
795 log.info("Session expired (profile={profile})".format(profile=profile)) | |
796 self.cache_resource.delEntity(web_session.uuid.encode('utf-8')) | |
797 log.debug( | |
798 _("profile cache resource {uuid} deleted").format(uuid=web_session.uuid) | |
799 ) | |
800 web_session.on_expire() | |
801 if web_session.ws_socket is not None: | |
802 web_session.ws_socket.close() | |
803 # and now we disconnect the profile | |
804 self.bridge_call("disconnect", profile) | |
805 | |
806 session.notifyOnExpire(on_expire) | |
807 | |
808 # FIXME: those session infos should be returned by connect or is_connected | |
809 infos = await self.bridge_call("session_infos_get", profile) | |
810 web_session.jid = jid.JID(infos["jid"]) | |
811 own_bare_jid_s = web_session.jid.userhost() | |
812 own_id_raw = await self.bridge_call( | |
813 "identity_get", own_bare_jid_s, [], True, profile) | |
814 web_session.identities[own_bare_jid_s] = data_format.deserialise(own_id_raw) | |
815 web_session.backend_started = int(infos["started"]) | |
816 | |
817 state = C.PROFILE_LOGGED_EXT_JID if register_with_ext_jid else C.PROFILE_LOGGED | |
818 return state | |
819 | |
820 @defer.inlineCallbacks | |
821 def connect(self, request, login, password): | |
822 """log user in | |
823 | |
824 If an other user was already logged, it will be unlogged first | |
825 @param request(server.Request): request linked to the session | |
826 @param login(unicode): user login | |
827 can be profile name | |
828 can be profile@[libervia_domain.ext] | |
829 can be a jid (a new profile will be created with this jid if needed) | |
830 @param password(unicode): user password | |
831 @return (unicode, None): C.SESSION_ACTIVE: if session was aleady active else | |
832 self._logged value | |
833 @raise exceptions.DataError: invalid login | |
834 @raise exceptions.ProfileUnknownError: this login doesn't exist | |
835 @raise exceptions.PermissionError: a login is not accepted (e.g. empty password | |
836 not allowed) | |
837 @raise exceptions.NotReady: a profile connection is already waiting | |
838 @raise exceptions.TimeoutError: didn't received and answer from bridge | |
839 @raise exceptions.InternalError: unknown error | |
840 @raise ValueError(C.PROFILE_AUTH_ERROR): invalid login and/or password | |
841 @raise ValueError(C.XMPP_AUTH_ERROR): invalid XMPP account password | |
842 """ | |
843 | |
844 # XXX: all security checks must be done here, even if present in javascript | |
845 if login.startswith("@"): | |
846 raise failure.Failure(exceptions.DataError("No profile_key allowed")) | |
847 | |
848 if login.startswith("guest@@") and login.count("@") == 2: | |
849 log.debug("logging a guest account") | |
850 elif "@" in login: | |
851 if login.count("@") != 1: | |
852 raise failure.Failure( | |
853 exceptions.DataError("Invalid login: {login}".format(login=login)) | |
854 ) | |
855 try: | |
856 login_jid = jid.JID(login) | |
857 except (RuntimeError, jid.InvalidFormat, AttributeError): | |
858 raise failure.Failure(exceptions.DataError("No profile_key allowed")) | |
859 | |
860 # FIXME: should it be cached? | |
861 new_account_domain = yield self.bridge_call("account_domain_new_get") | |
862 | |
863 if login_jid.host == new_account_domain: | |
864 # redirect "user@libervia.org" to the "user" profile | |
865 login = login_jid.user | |
866 login_jid = None | |
867 else: | |
868 login_jid = None | |
869 | |
870 try: | |
871 profile = yield self.bridge_call("profile_name_get", login) | |
872 except Exception: # XXX: ProfileUnknownError wouldn't work, it's encapsulated | |
873 # FIXME: find a better way to handle bridge errors | |
874 if ( | |
875 login_jid is not None and login_jid.user | |
876 ): # try to create a new libervia.backend profile using the XMPP credentials | |
877 if not self.options["allow_registration"]: | |
878 log.warning( | |
879 "Trying to register JID account while registration is not " | |
880 "allowed") | |
881 raise failure.Failure( | |
882 exceptions.DataError( | |
883 "JID login while registration is not allowed" | |
884 ) | |
885 ) | |
886 profile = login # FIXME: what if there is a resource? | |
887 connect_method = "credentials_xmpp_connect" | |
888 register_with_ext_jid = True | |
889 else: # non existing username | |
890 raise failure.Failure(exceptions.ProfileUnknownError()) | |
891 else: | |
892 if profile != login or ( | |
893 not password | |
894 and profile | |
895 not in self.options["empty_password_allowed_warning_dangerous_list"] | |
896 ): | |
897 # profiles with empty passwords are restricted to local frontends | |
898 raise exceptions.PermissionError | |
899 register_with_ext_jid = False | |
900 | |
901 connect_method = "connect" | |
902 | |
903 # we check if there is not already an active session | |
904 web_session = session_iface.IWebSession(request.getSession()) | |
905 if web_session.profile: | |
906 # yes, there is | |
907 if web_session.profile != profile: | |
908 # it's a different profile, we need to disconnect it | |
909 log.warning(_( | |
910 "{new_profile} requested login, but {old_profile} was already " | |
911 "connected, disconnecting {old_profile}").format( | |
912 old_profile=web_session.profile, new_profile=profile)) | |
913 self.purge_session(request) | |
914 | |
915 if self.waiting_profiles.get_request(profile): | |
916 # FIXME: check if and when this can happen | |
917 raise failure.Failure(exceptions.NotReady("Already waiting")) | |
918 | |
919 self.waiting_profiles.set_request(request, profile, register_with_ext_jid) | |
920 try: | |
921 connected = yield self.bridge_call(connect_method, profile, password) | |
922 except Exception as failure_: | |
923 fault = getattr(failure_, 'classname', None) | |
924 self.waiting_profiles.purge_request(profile) | |
925 if fault in ("PasswordError", "ProfileUnknownError"): | |
926 log.info("Profile {profile} doesn't exist or the submitted password is " | |
927 "wrong".format( profile=profile)) | |
928 raise failure.Failure(ValueError(C.PROFILE_AUTH_ERROR)) | |
929 elif fault == "SASLAuthError": | |
930 log.info("The XMPP password of profile {profile} is wrong" | |
931 .format(profile=profile)) | |
932 raise failure.Failure(ValueError(C.XMPP_AUTH_ERROR)) | |
933 elif fault == "NoReply": | |
934 log.info(_("Did not receive a reply (the timeout expired or the " | |
935 "connection is broken)")) | |
936 raise exceptions.TimeOutError | |
937 elif fault is None: | |
938 log.info(_("Unexepected failure: {failure_}").format(failure_=failure)) | |
939 raise failure_ | |
940 else: | |
941 log.error('Unmanaged fault class "{fault}" in errback for the ' | |
942 'connection of profile {profile}'.format( | |
943 fault=fault, profile=profile)) | |
944 raise failure.Failure(exceptions.InternalError(fault)) | |
945 | |
946 if connected: | |
947 # profile is already connected in backend | |
948 # do we have a corresponding session in Libervia? | |
949 web_session = session_iface.IWebSession(request.getSession()) | |
950 if web_session.profile: | |
951 # yes, session is active | |
952 if web_session.profile != profile: | |
953 # existing session should have been ended above | |
954 # so this line should never be reached | |
955 log.error(_( | |
956 "session profile [{session_profile}] differs from login " | |
957 "profile [{profile}], this should not happen!") | |
958 .format(session_profile=web_session.profile, profile=profile)) | |
959 raise exceptions.InternalError("profile mismatch") | |
960 defer.returnValue(C.SESSION_ACTIVE) | |
961 log.info( | |
962 _( | |
963 "profile {profile} was already connected in backend".format( | |
964 profile=profile | |
965 ) | |
966 ) | |
967 ) | |
968 # no, we have to create it | |
969 | |
970 state = yield defer.ensureDeferred(self._logged(profile, request)) | |
971 defer.returnValue(state) | |
972 | |
973 def register_new_account(self, request, login, password, email): | |
974 """Create a new account, or return error | |
975 @param request(server.Request): request linked to the session | |
976 @param login(unicode): new account requested login | |
977 @param email(unicode): new account email | |
978 @param password(unicode): new account password | |
979 @return(unicode): a constant indicating the state: | |
980 - C.BAD_REQUEST: something is wrong in the request (bad arguments) | |
981 - C.INVALID_INPUT: one of the data is not valid | |
982 - C.REGISTRATION_SUCCEED: new account has been successfully registered | |
983 - C.ALREADY_EXISTS: the given profile already exists | |
984 - C.INTERNAL_ERROR or any unmanaged fault string | |
985 @raise PermissionError: registration is now allowed in server configuration | |
986 """ | |
987 if not self.options["allow_registration"]: | |
988 log.warning( | |
989 _("Registration received while it is not allowed, hack attempt?") | |
990 ) | |
991 raise failure.Failure( | |
992 exceptions.PermissionError("Registration is not allowed on this server") | |
993 ) | |
994 | |
995 if ( | |
996 not re.match(C.REG_LOGIN_RE, login) | |
997 or not re.match(C.REG_EMAIL_RE, email, re.IGNORECASE) | |
998 or len(password) < C.PASSWORD_MIN_LENGTH | |
999 ): | |
1000 return C.INVALID_INPUT | |
1001 | |
1002 def registered(result): | |
1003 return C.REGISTRATION_SUCCEED | |
1004 | |
1005 def registering_error(failure_): | |
1006 # FIXME: better error handling for bridge error is needed | |
1007 status = failure_.value.fullname.split('.')[-1] | |
1008 if status == "ConflictError": | |
1009 return C.ALREADY_EXISTS | |
1010 elif status == "InvalidCertificate": | |
1011 return C.INVALID_CERTIFICATE | |
1012 elif status == "InternalError": | |
1013 return C.INTERNAL_ERROR | |
1014 else: | |
1015 log.error( | |
1016 _("Unknown registering error status: {status}\n{traceback}").format( | |
1017 status=status, traceback=failure_.value.message | |
1018 ) | |
1019 ) | |
1020 return status | |
1021 | |
1022 d = self.bridge_call("libervia_account_register", email, password, login) | |
1023 d.addCallback(registered) | |
1024 d.addErrback(registering_error) | |
1025 return d | |
1026 | |
1027 def addCleanup(self, callback, *args, **kwargs): | |
1028 """Add cleaning method to call when service is stopped | |
1029 | |
1030 cleaning method will be called in reverse order of they insertion | |
1031 @param callback: callable to call on service stop | |
1032 @param *args: list of arguments of the callback | |
1033 @param **kwargs: list of keyword arguments of the callback""" | |
1034 self._cleanup.insert(0, (callback, args, kwargs)) | |
1035 | |
1036 def init_eb(self, failure): | |
1037 from twisted.application import app | |
1038 if failure.check(SysExit): | |
1039 if failure.value.message: | |
1040 log.error(failure.value.message) | |
1041 app._exitCode = failure.value.exit_code | |
1042 reactor.stop() | |
1043 else: | |
1044 log.error(_("Init error: {msg}").format(msg=failure)) | |
1045 app._exitCode = C.EXIT_INTERNAL_ERROR | |
1046 reactor.stop() | |
1047 return failure | |
1048 | |
1049 def _build_only_cb(self, __): | |
1050 log.info(_("Stopping here due to --build-only flag")) | |
1051 self.stop() | |
1052 | |
1053 def startService(self): | |
1054 """Connect the profile for Libervia and start the HTTP(S) server(s)""" | |
1055 self._init() | |
1056 if self.options['build-only']: | |
1057 self.initialised.addCallback(self._build_only_cb) | |
1058 else: | |
1059 self.initialised.addCallback(self._start_service) | |
1060 self.initialised.addErrback(self.init_eb) | |
1061 | |
1062 ## URLs ## | |
1063 | |
1064 def put_child_sat(self, path, resource): | |
1065 """Add a child to the libervia.backend resource""" | |
1066 if not isinstance(path, bytes): | |
1067 raise ValueError("path must be specified in bytes") | |
1068 self.sat_root.putChild(path, resource) | |
1069 | |
1070 def put_child_all(self, path, resource): | |
1071 """Add a child to all vhost root resources""" | |
1072 if not isinstance(path, bytes): | |
1073 raise ValueError("path must be specified in bytes") | |
1074 # we wrap before calling putChild, to avoid having useless multiple instances | |
1075 # of the resource | |
1076 # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders) | |
1077 wrapped_res = web_resource.EncodingResourceWrapper( | |
1078 resource, [server.GzipEncoderFactory()]) | |
1079 for root in self.roots: | |
1080 root.putChild(path, wrapped_res) | |
1081 | |
1082 def get_build_path(self, site_name: str, dev: bool=False) -> Path: | |
1083 """Generate build path for a given site name | |
1084 | |
1085 @param site_name: name of the site | |
1086 @param dev: return dev build dir if True, production one otherwise | |
1087 dev build dir is used for installing dependencies needed temporarily (e.g. | |
1088 to compile files), while production build path is the one served by the | |
1089 HTTP server, where final files are downloaded. | |
1090 @return: path to the build directory | |
1091 """ | |
1092 sub_dir = C.DEV_BUILD_DIR if dev else C.PRODUCTION_BUILD_DIR | |
1093 build_path_elts = [ | |
1094 config.config_get(self.main_conf, "", "local_dir"), | |
1095 C.CACHE_DIR, | |
1096 C.LIBERVIA_CACHE, | |
1097 sub_dir, | |
1098 regex.path_escape(site_name or C.SITE_NAME_DEFAULT)] | |
1099 build_path = Path("/".join(build_path_elts)) | |
1100 return build_path.expanduser().resolve() | |
1101 | |
1102 def get_ext_base_url_data(self, request): | |
1103 """Retrieve external base URL Data | |
1104 | |
1105 this method try to retrieve the base URL found by external user | |
1106 It does by checking in this order: | |
1107 - base_url_ext option from configuration | |
1108 - proxy x-forwarder-host headers | |
1109 - URL of the request | |
1110 @return (urlparse.SplitResult): SplitResult instance with only scheme and | |
1111 netloc filled | |
1112 """ | |
1113 ext_data = self.base_url_ext_data | |
1114 url_path = request.URLPath() | |
1115 | |
1116 try: | |
1117 forwarded = request.requestHeaders.getRawHeaders( | |
1118 "forwarded" | |
1119 )[0] | |
1120 except TypeError: | |
1121 # we try deprecated headers | |
1122 try: | |
1123 proxy_netloc = request.requestHeaders.getRawHeaders( | |
1124 "x-forwarded-host" | |
1125 )[0] | |
1126 except TypeError: | |
1127 proxy_netloc = None | |
1128 try: | |
1129 proxy_scheme = request.requestHeaders.getRawHeaders( | |
1130 "x-forwarded-proto" | |
1131 )[0] | |
1132 except TypeError: | |
1133 proxy_scheme = None | |
1134 else: | |
1135 fwd_data = { | |
1136 k.strip(): v.strip() | |
1137 for k,v in (d.split("=") for d in forwarded.split(";")) | |
1138 } | |
1139 proxy_netloc = fwd_data.get("host") | |
1140 proxy_scheme = fwd_data.get("proto") | |
1141 | |
1142 return urllib.parse.SplitResult( | |
1143 ext_data.scheme or proxy_scheme or url_path.scheme.decode(), | |
1144 ext_data.netloc or proxy_netloc or url_path.netloc.decode(), | |
1145 ext_data.path or "/", | |
1146 "", | |
1147 "", | |
1148 ) | |
1149 | |
1150 def get_ext_base_url( | |
1151 self, | |
1152 request: server.Request, | |
1153 path: str = "", | |
1154 query: str = "", | |
1155 fragment: str = "", | |
1156 scheme: Optional[str] = None, | |
1157 ) -> str: | |
1158 """Get external URL according to given elements | |
1159 | |
1160 external URL is the URL seen by external user | |
1161 @param path: same as for urlsplit.urlsplit | |
1162 path will be prefixed to follow found external URL if suitable | |
1163 @param params: same as for urlsplit.urlsplit | |
1164 @param query: same as for urlsplit.urlsplit | |
1165 @param fragment: same as for urlsplit.urlsplit | |
1166 @param scheme: if not None, will override scheme from base URL | |
1167 @return: external URL | |
1168 """ | |
1169 split_result = self.get_ext_base_url_data(request) | |
1170 return urllib.parse.urlunsplit( | |
1171 ( | |
1172 split_result.scheme if scheme is None else scheme, | |
1173 split_result.netloc, | |
1174 os.path.join(split_result.path, path), | |
1175 query, | |
1176 fragment, | |
1177 ) | |
1178 ) | |
1179 | |
1180 def check_redirection(self, vhost_root: LiberviaRootResource, url_path: str) -> str: | |
1181 """check is a part of the URL prefix is redirected then replace it | |
1182 | |
1183 @param vhost_root: root of this virtual host | |
1184 @param url_path: path of the url to check | |
1185 @return: possibly redirected URL which should link to the same location | |
1186 """ | |
1187 inv_redirections = vhost_root.inv_redirections | |
1188 url_parts = url_path.strip("/").split("/") | |
1189 for idx in range(len(url_parts), -1, -1): | |
1190 test_url = "/" + "/".join(url_parts[:idx]) | |
1191 if test_url in inv_redirections: | |
1192 rem_url = url_parts[idx:] | |
1193 return os.path.join( | |
1194 "/", "/".join([inv_redirections[test_url]] + rem_url) | |
1195 ) | |
1196 return url_path | |
1197 | |
1198 ## Sessions ## | |
1199 | |
1200 def purge_session(self, request): | |
1201 """helper method to purge a session during request handling""" | |
1202 session = request.session | |
1203 if session is not None: | |
1204 log.debug(_("session purge")) | |
1205 web_session = self.get_session_data(request, session_iface.IWebSession) | |
1206 socket = web_session.ws_socket | |
1207 if socket is not None: | |
1208 socket.close() | |
1209 session.ws_socket = None | |
1210 session.expire() | |
1211 # FIXME: not clean but it seems that it's the best way to reset | |
1212 # session during request handling | |
1213 request._secureSession = request._insecureSession = None | |
1214 | |
1215 def get_session_data(self, request, *args): | |
1216 """helper method to retrieve session data | |
1217 | |
1218 @param request(server.Request): request linked to the session | |
1219 @param *args(zope.interface.Interface): interface of the session to get | |
1220 @return (iterator(data)): requested session data | |
1221 """ | |
1222 session = request.getSession() | |
1223 if len(args) == 1: | |
1224 return args[0](session) | |
1225 else: | |
1226 return (iface(session) for iface in args) | |
1227 | |
1228 @defer.inlineCallbacks | |
1229 def get_affiliation(self, request, service, node): | |
1230 """retrieve pubsub node affiliation for current user | |
1231 | |
1232 use cache first, and request pubsub service if not cache is found | |
1233 @param request(server.Request): request linked to the session | |
1234 @param service(jid.JID): pubsub service | |
1235 @param node(unicode): pubsub node | |
1236 @return (unicode): affiliation | |
1237 """ | |
1238 web_session = self.get_session_data(request, session_iface.IWebSession) | |
1239 if web_session.profile is None: | |
1240 raise exceptions.InternalError("profile must be set to use this method") | |
1241 affiliation = web_session.get_affiliation(service, node) | |
1242 if affiliation is not None: | |
1243 defer.returnValue(affiliation) | |
1244 else: | |
1245 try: | |
1246 affiliations = yield self.bridge_call( | |
1247 "ps_affiliations_get", service.full(), node, web_session.profile | |
1248 ) | |
1249 except Exception as e: | |
1250 log.warning( | |
1251 "Can't retrieve affiliation for {service}/{node}: {reason}".format( | |
1252 service=service, node=node, reason=e | |
1253 ) | |
1254 ) | |
1255 affiliation = "" | |
1256 else: | |
1257 try: | |
1258 affiliation = affiliations[node] | |
1259 except KeyError: | |
1260 affiliation = "" | |
1261 web_session.set_affiliation(service, node, affiliation) | |
1262 defer.returnValue(affiliation) | |
1263 | |
1264 ## Websocket (dynamic pages) ## | |
1265 | |
1266 def get_websocket_url(self, request): | |
1267 base_url_split = self.get_ext_base_url_data(request) | |
1268 if base_url_split.scheme.endswith("s"): | |
1269 scheme = "wss" | |
1270 else: | |
1271 scheme = "ws" | |
1272 | |
1273 return self.get_ext_base_url(request, path=scheme, scheme=scheme) | |
1274 | |
1275 | |
1276 ## Various utils ## | |
1277 | |
1278 def get_http_date(self, timestamp=None): | |
1279 now = time.gmtime(timestamp) | |
1280 fmt_date = "{day_name}, %d {month_name} %Y %H:%M:%S GMT".format( | |
1281 day_name=C.HTTP_DAYS[now.tm_wday], month_name=C.HTTP_MONTH[now.tm_mon - 1] | |
1282 ) | |
1283 return time.strftime(fmt_date, now) | |
1284 | |
1285 ## service management ## | |
1286 | |
1287 def _start_service(self, __=None): | |
1288 """Actually start the HTTP(S) server(s) after the profile for Libervia is connected. | |
1289 | |
1290 @raise ImportError: OpenSSL is not available | |
1291 @raise IOError: the certificate file doesn't exist | |
1292 @raise OpenSSL.crypto.Error: the certificate file is invalid | |
1293 """ | |
1294 # now that we have service profile connected, we add resource for its cache | |
1295 service_path = regex.path_escape(C.SERVICE_PROFILE) | |
1296 cache_dir = os.path.join(self.cache_root_dir, "profiles", service_path) | |
1297 self.cache_resource.putChild(service_path.encode('utf-8'), | |
1298 ProtectedFile(cache_dir)) | |
1299 self.service_cache_url = "/" + os.path.join(C.CACHE_DIR, service_path) | |
1300 session_iface.WebSession.service_cache_url = self.service_cache_url | |
1301 | |
1302 if self.options["connection_type"] in ("https", "both"): | |
1303 try: | |
1304 tls.tls_options_check(self.options) | |
1305 context_factory = tls.get_tls_context_factory(self.options) | |
1306 except exceptions.ConfigError as e: | |
1307 log.warning( | |
1308 f"There is a problem in TLS settings in your configuration file: {e}") | |
1309 self.quit(2) | |
1310 except exceptions.DataError as e: | |
1311 log.warning( | |
1312 f"Can't set TLS: {e}") | |
1313 self.quit(1) | |
1314 reactor.listenSSL(self.options["port_https"], self.site, context_factory) | |
1315 if self.options["connection_type"] in ("http", "both"): | |
1316 if ( | |
1317 self.options["connection_type"] == "both" | |
1318 and self.options["redirect_to_https"] | |
1319 ): | |
1320 reactor.listenTCP( | |
1321 self.options["port"], | |
1322 server.Site( | |
1323 RedirectToHTTPS( | |
1324 self.options["port"], self.options["port_https_ext"] | |
1325 ) | |
1326 ), | |
1327 ) | |
1328 else: | |
1329 reactor.listenTCP(self.options["port"], self.site) | |
1330 | |
1331 @defer.inlineCallbacks | |
1332 def stopService(self): | |
1333 log.info(_("launching cleaning methods")) | |
1334 for callback, args, kwargs in self._cleanup: | |
1335 callback(*args, **kwargs) | |
1336 try: | |
1337 yield self.bridge_call("disconnect", C.SERVICE_PROFILE) | |
1338 except Exception: | |
1339 log.warning("Can't disconnect service profile") | |
1340 | |
1341 def run(self): | |
1342 reactor.run() | |
1343 | |
1344 def stop(self): | |
1345 reactor.stop() | |
1346 | |
1347 def quit(self, exit_code=None): | |
1348 """Exit app when reactor is running | |
1349 | |
1350 @param exit_code(None, int): exit code | |
1351 """ | |
1352 self.stop() | |
1353 sys.exit(exit_code or 0) | |
1354 | |
1355 | |
1356 class RedirectToHTTPS(web_resource.Resource): | |
1357 def __init__(self, old_port, new_port): | |
1358 web_resource.Resource.__init__(self) | |
1359 self.isLeaf = True | |
1360 self.old_port = old_port | |
1361 self.new_port = new_port | |
1362 | |
1363 def render(self, request): | |
1364 netloc = request.URLPath().netloc.decode().replace( | |
1365 f":{self.old_port}", f":{self.new_port}" | |
1366 ) | |
1367 url = f"https://{netloc}{request.uri.decode()}" | |
1368 return web_util.redirectTo(url.encode(), request) | |
1369 | |
1370 | |
1371 registerAdapter(session_iface.WebSession, server.Session, session_iface.IWebSession) | |
1372 registerAdapter( | |
1373 session_iface.WebGuestSession, server.Session, session_iface.IWebGuestSession | |
1374 ) |