comparison libervia/server/pages.py @ 1504:409d10211b20

server, browser: dynamic pages refactoring: dynamic pages has been reworked, to change the initial basic implementation. Pages are now dynamic by default, and a websocket is established by the first connected page of a session. The socket is used to transmit bridge signals, and then the signal is broadcasted to other tabs using broadcast channel. If the connecting tab is closed, an other one is chosen. Some tests are made to retry connecting in case of problem, and sometimes reload the pages (e.g. if profile is connected). Signals (or other data) are cached during reconnection phase, to avoid lost of data. All previous partial rendering mechanism have been removed, chat page is temporarily not working anymore, but will be eventually redone (one of the goal of this work is to have proper chat).
author Goffi <goffi@goffi.org>
date Wed, 01 Mar 2023 18:02:44 +0100
parents 1671d187e71d
children ce879da7fcf7
comparison
equal deleted inserted replaced
1503:2796e73ed50c 1504:409d10211b20
110 request.postpath = self._postpath[:] 110 request.postpath = self._postpath[:]
111 111
112 112
113 class LiberviaPage(web_resource.Resource): 113 class LiberviaPage(web_resource.Resource):
114 isLeaf = True #  we handle subpages ourself 114 isLeaf = True #  we handle subpages ourself
115 signals_handlers = {}
116 cache = {} 115 cache = {}
117 #  Set of tuples (service/node/sub_id) of nodes subscribed for caching 116 #  Set of tuples (service/node/sub_id) of nodes subscribed for caching
118 # sub_id can be empty string if not handled by service 117 # sub_id can be empty string if not handled by service
119 cache_pubsub_sub = set() 118 cache_pubsub_sub = set()
120 119
121 def __init__( 120 def __init__(
122 self, host, vhost_root, root_dir, url, name=None, label=None, redirect=None, 121 self, host, vhost_root, root_dir, url, name=None, label=None, redirect=None,
123 access=None, dynamic=False, parse_url=None, add_breadcrumb=None, 122 access=None, dynamic=True, parse_url=None, add_breadcrumb=None,
124 prepare_render=None, render=None, template=None, on_data_post=None, on_data=None, 123 prepare_render=None, render=None, template=None, on_data_post=None, on_data=None,
125 on_signal=None, url_cache=False, replace_on_conflict=False 124 url_cache=False, replace_on_conflict=False
126 ): 125 ):
127 """Initiate LiberviaPage instance 126 """Initiate LiberviaPage instance
128 127
129 LiberviaPages are the main resources of Libervia, using easy to set python files 128 LiberviaPages are the main resources of Libervia, using easy to set python files
130 The non mandatory arguments are the variables found in page_meta.py 129 The non mandatory arguments are the variables found in page_meta.py
171 on_data_post can raise following exceptions: 170 on_data_post can raise following exceptions:
172 - exceptions.DataError: value is incorrect, message will be displayed 171 - exceptions.DataError: value is incorrect, message will be displayed
173 as a notification 172 as a notification
174 @param on_data(callable, None): method to call when dynamic data is sent 173 @param on_data(callable, None): method to call when dynamic data is sent
175 this method is used with Libervia's websocket mechanism 174 this method is used with Libervia's websocket mechanism
176 @param on_signal(callable, None): method to call when a registered signal is
177 received. This method is used with Libervia's websocket mechanism
178 @param url_cache(boolean): if set, result of parse_url is cached (per profile). 175 @param url_cache(boolean): if set, result of parse_url is cached (per profile).
179 Useful when costly calls (e.g. network) are done while parsing URL. 176 Useful when costly calls (e.g. network) are done while parsing URL.
180 @param replace_on_conflict(boolean): if True, don't raise ConflictError if a 177 @param replace_on_conflict(boolean): if True, don't raise ConflictError if a
181 page of this name already exists, but replace it 178 page of this name already exists, but replace it
182 """ 179 """
229 self.prepare_render = prepare_render 226 self.prepare_render = prepare_render
230 self.template = template 227 self.template = template
231 self.render_method = render 228 self.render_method = render
232 self.on_data_post = on_data_post 229 self.on_data_post = on_data_post
233 self.on_data = on_data 230 self.on_data = on_data
234 self.on_signal = on_signal
235 self.url_cache = url_cache 231 self.url_cache = url_cache
236 if access == C.PAGES_ACCESS_NONE: 232 if access == C.PAGES_ACCESS_NONE:
237 # none pages just return a 404, no further check is needed 233 # none pages just return a 404, no further check is needed
238 return 234 return
239 if template is not None and render is not None: 235 if template is not None and render is not None:
303 url="/" + "/".join(url_elts), 299 url="/" + "/".join(url_elts),
304 name=page_data.get("name"), 300 name=page_data.get("name"),
305 label=page_data.get("label"), 301 label=page_data.get("label"),
306 redirect=page_data.get("redirect"), 302 redirect=page_data.get("redirect"),
307 access=page_data.get("access"), 303 access=page_data.get("access"),
308 dynamic=page_data.get("dynamic", False), 304 dynamic=page_data.get("dynamic", True),
309 parse_url=page_data.get("parse_url"), 305 parse_url=page_data.get("parse_url"),
310 add_breadcrumb=page_data.get("add_breadcrumb"), 306 add_breadcrumb=page_data.get("add_breadcrumb"),
311 prepare_render=page_data.get("prepare_render"), 307 prepare_render=page_data.get("prepare_render"),
312 render=page_data.get("render"), 308 render=page_data.get("render"),
313 template=page_data.get("template"), 309 template=page_data.get("template"),
314 on_data_post=page_data.get("on_data_post"), 310 on_data_post=page_data.get("on_data_post"),
315 on_data=page_data.get("on_data"), 311 on_data=page_data.get("on_data"),
316 on_signal=page_data.get("on_signal"),
317 url_cache=page_data.get("url_cache", False), 312 url_cache=page_data.get("url_cache", False),
318 replace_on_conflict=replace_on_conflict 313 replace_on_conflict=replace_on_conflict
319 ) 314 )
320 315
321 @staticmethod 316 @staticmethod
322 def createBrowserData( 317 def createBrowserData(
323 vhost_root, 318 vhost_root,
324 resource: Optional(LiberviaPage), 319 resource: Optional[LiberviaPage],
325 browser_path: Path, 320 browser_path: Path,
326 path_elts: Optional(List[str]), 321 path_elts: Optional[List[str]],
327 engine: str = "brython" 322 engine: str = "brython"
328 ) -> None: 323 ) -> None:
329 """create and store data for browser dynamic code""" 324 """create and store data for browser dynamic code"""
330 dyn_data = { 325 dyn_data = {
331 "path": browser_path, 326 "path": browser_path,
603 if uri_tuple in self.uri_callbacks: 598 if uri_tuple in self.uri_callbacks:
604 log.info(_("{}/{} URIs are already handled, replacing by the new handler") 599 log.info(_("{}/{} URIs are already handled, replacing by the new handler")
605 .format( *uri_tuple)) 600 .format( *uri_tuple))
606 self.uri_callbacks[uri_tuple] = (self, get_uri_cb) 601 self.uri_callbacks[uri_tuple] = (self, get_uri_cb)
607 602
608 def getSignalId(self, request):
609 """Retrieve signal_id for a request
610
611 signal_id is used for dynamic page, to associate a initial request with a
612 signal handler. For WebsocketRequest, signal_id attribute is used (which must
613 be orginal request's id)
614 For server.Request it's id(request)
615 """
616 return getattr(request, 'signal_id', id(request))
617
618 def registerSignal(self, request, signal, check_profile=True):
619 r"""register a signal handler
620
621 the page must be dynamic
622 when signal is received, self.on_signal will be called with:
623 - request
624 - signal name
625 - signal arguments
626 signal handler will be removed when connection with dynamic page will be lost
627 @param signal(unicode): name of the signal
628 last arg of signal must be profile, as it will be checked to filter signals
629 @param check_profile(bool): if True, signal profile (which MUST be last arg)
630 will be checked against session profile.
631 /!\ if False, profile will not be checked/filtered, be sure to know what you
632 are doing if you unset this option /!\
633 """
634 # FIXME: add a timeout; if socket is not opened before it, signal handler
635 # must be removed
636 if not self.dynamic:
637 log.error(_("You can't register signal if page is not dynamic"))
638 return
639 signal_id = self.getSignalId(request)
640 LiberviaPage.signals_handlers.setdefault(signal, {})[signal_id] = [
641 self,
642 request,
643 check_profile,
644 ]
645 request._signals_registered.append(signal)
646
647 def getConfig(self, key, default=None, value_type=None): 603 def getConfig(self, key, default=None, value_type=None):
648 return self.host.getConfig(self.vhost_root, key=key, default=default, 604 return self.host.getConfig(self.vhost_root, key=key, default=default,
649 value_type=value_type) 605 value_type=value_type)
650 606
651 def getBuildPath(self, session_data): 607 def getBuildPath(self, session_data):
1211 'identityGet', e, [], True, profile) 1167 'identityGet', e, [], True, profile)
1212 identities[e] = data_format.deserialise(id_raw) 1168 identities[e] = data_format.deserialise(id_raw)
1213 1169
1214 # signals, server => browser communication 1170 # signals, server => browser communication
1215 1171
1216 @classmethod
1217 def onSignal(cls, host, signal, *args):
1218 """Generic method which receive registered signals
1219
1220 if a callback is registered for this signal, call it
1221 @param host: Libervia instance
1222 @param signal(unicode): name of the signal
1223 @param *args: args of the signals
1224 """
1225 for page, request, check_profile in cls.signals_handlers.get(
1226 signal, {}
1227 ).values():
1228 if check_profile:
1229 signal_profile = args[-1]
1230 request_profile = page.getProfile(request)
1231 if not request_profile:
1232 # if you want to use signal without session, unset check_profile
1233 # (be sure to know what you are doing)
1234 log.error(_("no session started, signal can't be checked"))
1235 continue
1236 if signal_profile != request_profile:
1237 #  we ignore the signal, it's not for our profile
1238 continue
1239 if request._signals_cache is not None:
1240 # socket is not yet opened, we cache the signal
1241 request._signals_cache.append((request, signal, args))
1242 log.debug(
1243 "signal [{signal}] cached: {args}".format(signal=signal, args=args)
1244 )
1245 else:
1246 page.on_signal(page, request, signal, *args)
1247
1248 def onSocketOpen(self, request):
1249 """Called for dynamic pages when socket has just been opened
1250
1251 we send all cached signals
1252 """
1253 assert request._signals_cache is not None
1254 # we need to replace corresponding original requests by this websocket request
1255 # in signals_handlers
1256 signal_id = request.signal_id
1257 for signal_handlers_map in self.__class__.signals_handlers.values():
1258 if signal_id in signal_handlers_map:
1259 signal_handlers_map[signal_id][1] = request
1260
1261 cache = request._signals_cache
1262 request._signals_cache = None
1263 for request, signal, args in cache:
1264 self.on_signal(self, request, signal, *args)
1265
1266 def onSocketClose(self, request):
1267 """Called for dynamic pages when socket has just been closed
1268
1269 we remove signal handler
1270 """
1271 for signal in request._signals_registered:
1272 signal_id = self.getSignalId(request)
1273 try:
1274 del LiberviaPage.signals_handlers[signal][signal_id]
1275 except KeyError:
1276 log.error(_("Can't find signal handler for [{signal}], this should not "
1277 "happen").format(signal=signal))
1278 else:
1279 log.debug(_("Removed signal handler"))
1280
1281 def delegateToResource(self, request, resource): 1172 def delegateToResource(self, request, resource):
1282 """continue workflow with Twisted Resource""" 1173 """continue workflow with Twisted Resource"""
1283 buf = resource.render(request) 1174 buf = resource.render(request)
1284 if buf == server.NOT_DONE_YET: 1175 if buf == server.NOT_DONE_YET:
1285 pass 1176 pass
1445 else: 1336 else:
1446 child.render(request) 1337 child.render(request)
1447 raise failure.Failure(exceptions.CancelError("subpage page is used")) 1338 raise failure.Failure(exceptions.CancelError("subpage page is used"))
1448 1339
1449 def _prepare_dynamic(self, request): 1340 def _prepare_dynamic(self, request):
1341 session_data = self.host.getSessionData(request, session_iface.ISATSession)
1450 # we need to activate dynamic page 1342 # we need to activate dynamic page
1451 # we set data for template, and create/register token 1343 # we set data for template, and create/register token
1452 socket_token = str(uuid.uuid4()) 1344 # socket_token = str(uuid.uuid4())
1453 socket_url = self.host.getWebsocketURL(request) 1345 socket_url = self.host.get_websocket_url(request)
1346 # as for CSRF, it is important to not let the socket token if we use the service
1347 # profile, as those pages can be cached, and then the token leaked.
1348 socket_token = '' if session_data.profile is None else session_data.ws_token
1454 socket_debug = C.boolConst(self.host.debug) 1349 socket_debug = C.boolConst(self.host.debug)
1455 request.template_data["websocket"] = WebsocketMeta( 1350 request.template_data["websocket"] = WebsocketMeta(
1456 socket_url, socket_token, socket_debug 1351 socket_url, socket_token, socket_debug
1457 ) 1352 )
1458 # we will keep track of handlers to remove 1353 # we will keep track of handlers to remove
1459 request._signals_registered = [] 1354 request._signals_registered = []
1460 # we will cache registered signals until socket is opened 1355 # we will cache registered signals until socket is opened
1461 request._signals_cache = [] 1356 request._signals_cache = []
1462 self.host.registerWSToken(socket_token, self, request)
1463 1357
1464 def _render_template(self, request): 1358 def _render_template(self, request):
1465 template_data = request.template_data 1359 template_data = request.template_data
1466 1360
1467 # if confirm variable is set in case of successfuly data post 1361 # if confirm variable is set in case of successfuly data post
1757 session_data = self.host.getSessionData(request, 1651 session_data = self.host.getSessionData(request,
1758 session_iface.ISATSession) 1652 session_iface.ISATSession)
1759 session_data.locale = a 1653 session_data.locale = a
1760 return 1654 return
1761 1655
1762 def renderPartial(self, request, template, template_data):
1763 """Render a template to be inserted in dynamic page
1764
1765 this is NOT the normal page rendering method, it is used only to update
1766 dynamic pages
1767 @param template(unicode): path of the template to render
1768 @param template_data(dict): template_data to use
1769 """
1770 if not self.dynamic:
1771 raise exceptions.InternalError(
1772 _("renderPartial must only be used with dynamic pages")
1773 )
1774 session_data = self.host.getSessionData(request, session_iface.ISATSession)
1775 if session_data.locale is not None:
1776 template_data['locale'] = session_data.locale
1777 if self.vhost_root.site_name:
1778 template_data['site'] = self.vhost_root.site_name
1779
1780 return self.host.renderer.render(
1781 template,
1782 theme=session_data.theme or self.default_theme,
1783 site_themes=self.site_themes,
1784 page_url=self.getURL(),
1785 media_path=f"/{C.MEDIA_DIR}",
1786 build_path=f"/{C.BUILD_DIR}/",
1787 cache_path=session_data.cache_dir,
1788 main_menu=self.main_menu,
1789 **template_data
1790 )
1791
1792 def renderAndUpdate(
1793 self, request, template, selectors, template_data_update, update_type="append"
1794 ):
1795 """Helper method to render a partial page element and update the page
1796
1797 this is NOT the normal page rendering method, it is used only to update
1798 dynamic pages
1799 @param request(server.Request): current HTTP request
1800 @param template: same as for [renderPartial]
1801 @param selectors: CSS selectors to use
1802 @param template_data_update: template data to use
1803 template data cached in request will be copied then updated
1804 with this data
1805 @parap update_type(unicode): one of:
1806 append: append rendered element to selected element
1807 """
1808 template_data = request.template_data.copy()
1809 template_data.update(template_data_update)
1810 html = self.renderPartial(request, template, template_data)
1811 try:
1812 request.sendData(
1813 "dom", selectors=selectors, update_type=update_type, html=html)
1814 except Exception as e:
1815 log.error("Can't renderAndUpdate, html was: {html}".format(html=html))
1816 raise e
1817
1818 async def renderPage(self, request, skip_parse_url=False): 1656 async def renderPage(self, request, skip_parse_url=False):
1819 """Main method to handle the workflow of a LiberviaPage""" 1657 """Main method to handle the workflow of a LiberviaPage"""
1820 # template_data are the variables passed to template 1658 # template_data are the variables passed to template
1821 if not hasattr(request, "template_data"): 1659 if not hasattr(request, "template_data"):
1822 # if template_data doesn't exist, it's the beginning of the request workflow 1660 # if template_data doesn't exist, it's the beginning of the request workflow