comparison libervia/server/websockets.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 822bd0139769
children ce879da7fcf7
comparison
equal deleted inserted replaced
1503:2796e73ed50c 1504:409d10211b20
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2
3 2
4 # Libervia: a Salut à Toi frontend 3 # Libervia: a Salut à Toi frontend
5 # Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> 4 # Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
6 5
7 # This program is free software: you can redistribute it and/or modify 6 # This program is free software: you can redistribute it and/or modify
17 # You should have received a copy of the GNU Affero General Public License 16 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. 17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 18
20 19
21 import json 20 import json
22 from twisted.internet import error 21 from typing import Optional
22
23 from autobahn.twisted import websocket 23 from autobahn.twisted import websocket
24 from autobahn.twisted import resource as resource 24 from autobahn.twisted import resource as resource
25 from autobahn.websocket import types 25 from autobahn.websocket import types
26 from sat.core import exceptions 26 from sat.core import exceptions
27 from sat.core.i18n import _ 27 from sat.core.i18n import _
28 from sat.core.log import getLogger 28 from sat.core.log import getLogger
29 29
30 from . import session_iface
31 from .constants import Const as C
32
30 log = getLogger(__name__) 33 log = getLogger(__name__)
31 34
32 LIBERVIA_PROTOCOL = "libervia_page" 35 host = None
33 36
34 37
35 class WebsocketRequest(object): 38 class LiberviaPageWSProtocol(websocket.WebSocketServerProtocol):
36 """Wrapper around autobahn's ConnectionRequest and Twisted's server.Request 39
37 40 def __init__(self):
38 This is used to have a common interface in Libervia page with request object 41 super().__init__()
39 """ 42 self._init_ok: bool = False
40 43 self.__profile: Optional[str] = None
41 def __init__(self, ws_protocol, connection_request, server_request): 44 self.__session: Optional[session_iface.SATSession] = None
42 """ 45
43 @param connection_request: websocket request 46 @property
44 @param serveur_request: original request of the page 47 def init_ok(self):
45 """ 48 return self._init_ok
46 self.ws_protocol = ws_protocol 49
47 self.ws_request = connection_request 50 def send(self, data_type: str, data: dict) -> None:
48 if self.isSecure(): 51 """Send data to frontend"""
49 cookie_name = "TWISTED_SECURE_SESSION" 52 if not self._init_ok and data_type != "error":
53 raise exceptions.InternalError(
54 "send called when not initialized, this should not happend! Please use "
55 "SATSession.send which takes care of sending correctly the data to all "
56 "sessions."
57 )
58
59 data_root = {
60 "type": data_type,
61 "data": data
62 }
63 self.sendMessage(json.dumps(data_root, ensure_ascii=False).encode())
64
65 def close(self) -> None:
66 log.debug(f"closing websocket for profile {self.__profile}")
67
68 def error(self, error_type: str, msg: str) -> None:
69 """Send an error message to frontend and log it locally"""
70 log.warning(
71 f"websocket error {error_type}: {msg}"
72 )
73 self.send("error", {
74 "type": error_type,
75 "msg": msg,
76 })
77
78 def onConnect(self, request):
79 if "libervia-page" not in request.protocols:
80 raise types.ConnectionDeny(
81 types.ConnectionDeny.NOT_IMPLEMENTED, "No supported protocol"
82 )
83 self._init_ok = False
84 cookies = {}
85 for cookie in request.headers.get("cookie", "").split(";"):
86 k, __, v = cookie.partition("=")
87 cookies[k.strip()] = v.strip()
88 session_uid = (
89 cookies.get("TWISTED_SECURE_SESSION")
90 or cookies.get("TWISTED_SESSION")
91 or ""
92 )
93 if not session_uid:
94 raise types.ConnectionDeny(
95 types.ConnectionDeny.FORBIDDEN, "No session set"
96 )
97 try:
98 session = host.site.getSession(session_uid.encode())
99 except KeyError:
100 raise types.ConnectionDeny(
101 types.ConnectionDeny.FORBIDDEN, "Invalid session"
102 )
103
104 session.touch()
105 session_data = session.getComponent(session_iface.ISATSession)
106 if session_data.ws_socket is not None:
107 log.warning("Session socket is already set, force closing it")
108 session_data.ws_socket.send(
109 "force_close", {"reason": "duplicate connection detected"}
110 )
111 session_data.ws_socket = self
112 self.__session = session_data
113 self.__profile = session_data.profile or C.SERVICE_PROFILE
114 log.debug(f"websocket connection connected for profile {self.__profile}")
115 return "libervia-page"
116
117 def onOpen(self):
118 log.debug("websocket connection opened")
119
120 def onMessage(self, payload: bytes, isBinary: bool) -> None:
121 if self.__session is None:
122 raise exceptions.InternalError("empty session, this should never happen")
123 try:
124 data_full = json.loads(payload.decode())
125 data_type = data_full["type"]
126 data = data_full["data"]
127 except ValueError as e:
128 self.error(
129 "bad_request",
130 f"Not valid JSON, ignoring data ({e}): {payload!r}"
131 )
132 return
133 except KeyError:
134 self.error(
135 "bad_request",
136 'Invalid request (missing "type" or "data")'
137 )
138 return
139
140 if data_type == "init":
141 if self._init_ok:
142 self.error(
143 "bad_request",
144 "double init"
145 )
146 self.sendClose(4400, "Bad Request")
147 return
148
149 try:
150 profile = data["profile"] or C.SERVICE_PROFILE
151 token = data["token"]
152 except KeyError:
153 self.error(
154 "bad_request",
155 "Invalid init data (missing profile or token)"
156 )
157 self.sendClose(4400, "Bad Request")
158 return
159 if ((
160 profile != self.__profile
161 or (token != self.__session.ws_token and profile != C.SERVICE_PROFILE)
162 )):
163 log.debug(
164 f"profile got {profile}, was expecting {self.__profile}, "
165 f"token got {token}, was expecting {self.__session.ws_token}, "
166 )
167 self.error(
168 "Unauthorized",
169 "Invalid profile or token"
170 )
171 self.sendClose(4401, "Unauthorized")
172 return
173 else:
174 log.debug(f"websocket connection initialized for {profile}")
175 self._init_ok = True
176 # we now send all cached data, if any
177 while True:
178 try:
179 session_kw = self.__session.ws_buffer.popleft()
180 except IndexError:
181 break
182 else:
183 self.send(**session_kw)
184
185 if not self._init_ok:
186 self.error(
187 "Unauthorized",
188 "session not authorized"
189 )
190 self.sendClose(4401, "Unauthorized")
191 return
192
193 def onClose(self, wasClean, code, reason):
194 log.debug(f"closing websocket (profile: {self.__profile}, reason: {reason})")
195 if self.__profile is None:
196 log.error("self.__profile should not be None")
197 self.__profile = C.SERVICE_PROFILE
198
199 if self.__session is None:
200 log.warning("closing a socket without attached session")
201 elif self.__session.ws_socket != self:
202 log.error("session socket is not linked to our instance")
50 else: 203 else:
51 cookie_name = "TWISTED_SESSION" 204 log.debug(f"reseting websocket session for {self.__profile}")
52 cookie_value = server_request.getCookie(cookie_name.encode('utf-8')) 205 self.__session.ws_socket = None
53 try: 206 sessions = session_iface.SATSession.get_profile_sessions(self.__profile)
54 raw_cookies = ws_protocol.http_headers['cookie'] 207 log.debug(f"websocket connection for profile {self.__profile} closed")
55 except KeyError: 208 self.__profile = None
56 raise ValueError("missing expected cookie header")
57 self.cookies = {k:v for k,v in (c.split('=') for c in raw_cookies.split(';'))}
58 if self.cookies[cookie_name] != cookie_value.decode('utf-8'):
59 raise exceptions.PermissionError(
60 "Bad cookie value, this should never happen.\n"
61 "headers: {headers}".format(headers=ws_protocol.http_headers))
62
63 self.template_data = server_request.template_data
64 self.data = server_request.data
65 self.session = server_request.getSession()
66 self._signals_registered = server_request._signals_registered
67 self._signals_cache = server_request._signals_cache
68 # signal id is needed to link original request with signal handler
69 self.signal_id = server_request._signal_id
70
71 def isSecure(self):
72 return self.ws_protocol.factory.isSecure
73
74 def getSession(self, sessionInterface=None):
75 try:
76 self.session.touch()
77 except (error.AlreadyCalled, error.AlreadyCancelled):
78 # Session has already expired.
79 self.session = None
80
81 if sessionInterface:
82 return self.session.getComponent(sessionInterface)
83
84 return self.session
85
86 def sendData(self, type_, **data):
87 assert "type" not in data
88 data["type"] = type_
89 self.ws_protocol.sendMessage(json.dumps(data, ensure_ascii=False).encode("utf8"))
90
91
92 class LiberviaPageWSProtocol(websocket.WebSocketServerProtocol):
93 host = None
94 tokens_map = {}
95
96 def onConnect(self, request):
97 prefix = LIBERVIA_PROTOCOL + "_"
98 for protocol in request.protocols:
99 if protocol.startswith(prefix):
100 token = protocol[len(prefix) :].strip()
101 if token:
102 break
103 else:
104 raise types.ConnectionDeny(
105 types.ConnectionDeny.NOT_IMPLEMENTED, "Can't use this subprotocol"
106 )
107
108 if token not in self.tokens_map:
109 log.warning(_("Can't activate page socket: unknown token"))
110 raise types.ConnectionDeny(
111 types.ConnectionDeny.FORBIDDEN, "Bad token, please reload page"
112 )
113 self.token = token
114 token_map = self.tokens_map.pop(token)
115 self.page = token_map["page"]
116 self.request = WebsocketRequest(self, request, token_map["request"])
117 return protocol
118
119 def onOpen(self):
120 log.debug(
121 _(
122 "Websocket opened for {page} (token: {token})".format(
123 page=self.page, token=self.token
124 )
125 )
126 )
127 self.page.onSocketOpen(self.request)
128
129 def onMessage(self, payload, isBinary):
130 try:
131 data_json = json.loads(payload.decode("utf8"))
132 except ValueError as e:
133 log.warning(
134 _("Not valid JSON, ignoring data: {msg}\n{data}").format(
135 msg=e, data=payload
136 )
137 )
138 return
139 #  we request page first, to raise an AttributeError
140 # if it is not set (which should never happen)
141 page = self.page
142 try:
143 cb = page.on_data
144 except AttributeError:
145 log.warning(
146 _(
147 'No "on_data" method set on dynamic page, ignoring data:\n{data}'
148 ).format(data=data_json)
149 )
150 else:
151 cb(page, self.request, data_json)
152
153 def onClose(self, wasClean, code, reason):
154 try:
155 page = self.page
156 except AttributeError:
157 log.debug(
158 "page is not available, the socket was probably not opened cleanly.\n"
159 "reason: {reason}".format(reason=reason))
160 return
161 page.onSocketClose(self.request)
162
163 log.debug(
164 _(
165 "Websocket closed for {page} (token: {token}). {reason}".format(
166 page=self.page,
167 token=self.token,
168 reason=""
169 if wasClean
170 else _("Reason: {reason}").format(reason=reason),
171 )
172 )
173 )
174 209
175 @classmethod 210 @classmethod
176 def getBaseURL(cls, host, secure): 211 def getBaseURL(cls, secure):
177 return "ws{sec}://localhost:{port}".format( 212 return "ws{sec}://localhost:{port}".format(
178 sec="s" if secure else "", 213 sec="s" if secure else "",
179 port=cls.host.options["port_https" if secure else "port"], 214 port=host.options["port_https" if secure else "port"],
180 ) 215 )
181 216
182 @classmethod 217 @classmethod
183 def getResource(cls, host, secure): 218 def getResource(cls, secure):
184 if cls.host is None: 219 factory = websocket.WebSocketServerFactory(cls.getBaseURL(secure))
185 cls.host = host
186 factory = websocket.WebSocketServerFactory(cls.getBaseURL(host, secure))
187 factory.protocol = cls 220 factory.protocol = cls
188 return resource.WebSocketResource(factory) 221 return resource.WebSocketResource(factory)
189
190 @classmethod
191 def registerToken(cls, token, page, request):
192 if token in cls.tokens_map:
193 raise exceptions.ConflictError(_("This token is already registered"))
194 cls.tokens_map[token] = {"page": page, "request": request}