Fix #100973: Node Wrangler: Previewing node if hierarchy not active
[blender-addons.git] / blender_id / communication.py
blob54cb4851c355db81e533bf73da9de1a2e8bd2243
1 # SPDX-FileCopyrightText: 2016-2022 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 import functools
6 import logging
7 import typing
9 log = logging.getLogger(__name__)
11 # Can be overridden by setting the environment variable BLENDER_ID_ENDPOINT.
12 BLENDER_ID_ENDPOINT = 'https://id.blender.org/'
14 # Will become a requests.Session at the first request to Blender ID.
15 requests_session = None
17 # Request timeout, in seconds.
18 REQUESTS_TIMEOUT = 5.0
21 class BlenderIdCommError(RuntimeError):
22 """Raised when there was an error communicating with Blender ID"""
25 class AuthResult:
26 def __init__(self, *, success: bool,
27 user_id: str=None, token: str=None, expires: str=None,
28 error_message: typing.Any=None): # when success=False
29 self.success = success
30 self.user_id = user_id
31 self.token = token
32 self.error_message = str(error_message)
33 self.expires = expires
36 @functools.lru_cache(maxsize=None)
37 def host_label():
38 import socket
40 return 'Blender running on %r' % socket.gethostname()
43 def blender_id_session():
44 """Returns the Requests session, creating it if necessary."""
45 global requests_session
46 import requests.adapters
48 if requests_session is not None:
49 return requests_session
51 requests_session = requests.session()
53 # Retry with backoff factor, so that a restart of Blender ID or hickup
54 # in the connection doesn't immediately fail the request.
55 retries = requests.packages.urllib3.util.retry.Retry(
56 total=5,
57 backoff_factor=0.05,
59 http_adapter = requests.adapters.HTTPAdapter(max_retries=retries)
60 requests_session.mount('https://', http_adapter)
61 requests_session.mount('http://', http_adapter)
63 # Construct the User-Agent header with Blender and add-on versions.
64 try:
65 import bpy
66 except ImportError:
67 blender_version = 'unknown'
68 else:
69 blender_version = '.'.join(str(component) for component in bpy.app.version)
71 from blender_id import bl_info
72 addon_version = '.'.join(str(component) for component in bl_info['version'])
73 requests_session.headers['User-Agent'] = f'Blender/{blender_version} Blender-ID-Addon/{addon_version}'
75 return requests_session
78 @functools.lru_cache(maxsize=None)
79 def blender_id_endpoint(endpoint_path=None):
80 """Gets the endpoint for the authentication API. If the BLENDER_ID_ENDPOINT env variable
81 is defined, it's possible to override the (default) production address.
82 """
83 import os
84 import urllib.parse
86 base_url = os.environ.get('BLENDER_ID_ENDPOINT')
87 if base_url:
88 log.warning('Using overridden Blender ID url %s', base_url)
89 else:
90 base_url = BLENDER_ID_ENDPOINT
91 log.info('Using standard Blender ID url %s', base_url)
93 # urljoin() is None-safe for the 2nd parameter.
94 return urllib.parse.urljoin(base_url, endpoint_path)
97 def blender_id_server_authenticate(username, password) -> AuthResult:
98 """Authenticate the user with the server with a single transaction
99 containing username and password (must happen via HTTPS).
101 If the transaction is successful, status will be 'successful' and we
102 return the user's unique blender id and a token (that will be used to
103 represent that username and password combination).
104 If there was a problem, status will be 'fail' and we return an error
105 message. Problems may be with the connection or wrong user/password.
108 import requests.exceptions
110 payload = dict(
111 username=username,
112 password=password,
113 host_label=host_label()
116 url = blender_id_endpoint('u/identify')
117 session = blender_id_session()
118 try:
119 r = session.post(url, data=payload, verify=True, timeout=REQUESTS_TIMEOUT)
120 except (requests.exceptions.SSLError,
121 requests.exceptions.HTTPError,
122 requests.exceptions.ConnectionError) as e:
123 msg = 'Exception POSTing to {}: {}'.format(url, e)
124 print(msg)
125 return AuthResult(success=False, error_message=msg)
127 if r.status_code == 200:
128 resp = r.json()
129 status = resp['status']
130 if status == 'success':
131 return AuthResult(success=True,
132 user_id=str(resp['data']['user_id']),
133 token=resp['data']['oauth_token']['access_token'],
134 expires=resp['data']['oauth_token']['expires'],
136 if status == 'fail':
137 return AuthResult(success=False, error_message='Username and/or password is incorrect')
139 return AuthResult(success=False,
140 error_message='There was a problem communicating with'
141 ' the server. Error code is: %s' % r.status_code)
144 def blender_id_server_validate(token) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]:
145 """Validate the auth token with the server.
147 @param token: the authentication token
148 @type token: str
149 @returns: tuple (expiry, error).
150 The expiry is the expiry date of the token if it is valid, else None.
151 The error is None if the token is valid, or an error message when it's invalid.
154 import requests.exceptions
156 url = blender_id_endpoint('u/validate_token')
157 session = blender_id_session()
158 try:
159 r = session.post(url, data={'token': token}, verify=True, timeout=REQUESTS_TIMEOUT)
160 except requests.exceptions.ConnectionError:
161 log.exception('error connecting to Blender ID at %s', url)
162 return None, 'Unable to connect to Blender ID'
163 except requests.exceptions.RequestException as e:
164 log.exception('error validating token at %s', url)
165 return None, str(e)
167 if r.status_code != 200:
168 return None, 'Authentication token invalid'
170 response = r.json()
171 return response['token_expires'], None
174 def blender_id_server_logout(user_id, token):
175 """Logs out of the Blender ID service by removing the token server-side.
177 @param user_id: the email address of the user.
178 @type user_id: str
179 @param token: the token to remove
180 @type token: str
181 @return: {'status': 'fail' or 'success', 'error_message': str}
182 @rtype: dict
185 import requests.exceptions
187 payload = dict(
188 user_id=user_id,
189 token=token
191 session = blender_id_session()
192 try:
193 r = session.post(blender_id_endpoint('u/delete_token'),
194 data=payload, verify=True, timeout=REQUESTS_TIMEOUT)
195 except (requests.exceptions.SSLError,
196 requests.exceptions.HTTPError,
197 requests.exceptions.ConnectionError) as e:
198 return dict(
199 status='fail',
200 error_message=format('There was a problem setting up a connection to '
201 'the server. Error type is: %s' % type(e).__name__)
204 if r.status_code != 200:
205 return dict(
206 status='fail',
207 error_message=format('There was a problem communicating with'
208 ' the server. Error code is: %s' % r.status_code)
211 resp = r.json()
212 return dict(
213 status=resp['status'],
214 error_message=None
218 def subclient_create_token(auth_token: str, subclient_id: str) -> dict:
219 """Creates a subclient-specific authentication token.
221 :returns: the token along with its expiry timestamp, in a {'scst': 'token',
222 'expiry': datetime.datetime} dict.
225 payload = {'subclient_id': subclient_id,
226 'host_label': host_label()}
228 r = make_authenticated_call('POST', 'subclients/create_token', auth_token, payload)
229 if r.status_code == 401:
230 raise BlenderIdCommError('Your Blender ID login is not valid, try logging in again.')
232 if r.status_code != 201:
233 raise BlenderIdCommError('Invalid response, HTTP code %i received' % r.status_code)
235 resp = r.json()
236 if resp['status'] != 'success':
237 raise BlenderIdCommError(resp['message'])
239 return resp['data']
242 def make_authenticated_call(method, url, auth_token, data):
243 """Makes a HTTP call authenticated with the OAuth token."""
245 import requests.exceptions
247 session = blender_id_session()
248 try:
249 r = session.request(method,
250 blender_id_endpoint(url),
251 data=data,
252 headers={'Authorization': 'Bearer %s' % auth_token},
253 verify=True,
254 timeout=REQUESTS_TIMEOUT)
255 except (requests.exceptions.HTTPError,
256 requests.exceptions.ConnectionError) as e:
257 raise BlenderIdCommError(str(e))
259 return r
262 def send_token_to_subclient(webservice_endpoint: str, user_id: str,
263 subclient_token: str, subclient_id: str) -> str:
264 """Sends the subclient-specific token to the subclient.
266 The subclient verifies this token with BlenderID. If it's accepted, the
267 subclient ensures there is a valid user created server-side. The ID of
268 that user is returned.
270 :returns: the user ID at the subclient.
273 import requests.exceptions
274 import urllib.parse
276 url = urllib.parse.urljoin(webservice_endpoint, 'blender_id/store_scst')
277 session = blender_id_session()
278 try:
279 r = session.post(url,
280 data={'user_id': user_id,
281 'subclient_id': subclient_id,
282 'token': subclient_token},
283 verify=True,
284 timeout=REQUESTS_TIMEOUT)
285 r.raise_for_status()
286 except (requests.exceptions.HTTPError,
287 requests.exceptions.ConnectionError) as e:
288 raise BlenderIdCommError(str(e))
289 resp = r.json()
291 if resp['status'] != 'success':
292 raise BlenderIdCommError('Error sending subclient-specific token to %s, error is: %s'
293 % (webservice_endpoint, resp))
295 return resp['subclient_user_id']