Mercurial > libervia-web
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} |