Mercurial > libervia-backend
comparison sat/plugins/plugin_comp_ap_gateway/pubsub_service.py @ 3824:6329ee6b6df4
component AP: convert AP identity data to XMPP:
metadata are converted to vCard4 (XEP-0292).
Avatar is converted to User Avatar (XEP-0084), it is downloaded, resized, converted and
cached on first request.
If avatar is bigger than 5 Mio, it won't be used.
rel 368
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 29 Jun 2022 12:12:09 +0200 |
parents | 865167c34b82 |
children | 59fbb66b2923 |
comparison
equal
deleted
inserted
replaced
3823:5d72dc52ee4a | 3824:6329ee6b6df4 |
---|---|
14 # GNU Affero General Public License for more details. | 14 # GNU Affero General Public License for more details. |
15 | 15 |
16 # 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 |
17 # 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/>. |
18 | 18 |
19 from typing import Optional, Tuple, List, Union | 19 from typing import Optional, Tuple, List, Dict, Any, Union |
20 | 20 from urllib.parse import urlparse |
21 from twisted.internet import defer | 21 from pathlib import Path |
22 from base64 import b64encode | |
23 import tempfile | |
24 | |
25 from twisted.internet import defer, threads | |
22 from twisted.words.protocols.jabber import jid, error | 26 from twisted.words.protocols.jabber import jid, error |
23 from twisted.words.xish import domish | 27 from twisted.words.xish import domish |
24 from wokkel import rsm, pubsub, disco | 28 from wokkel import rsm, pubsub, disco |
25 | 29 |
26 from sat.core.i18n import _ | 30 from sat.core.i18n import _ |
27 from sat.core import exceptions | 31 from sat.core import exceptions |
32 from sat.core.core_types import SatXMPPEntity | |
28 from sat.core.log import getLogger | 33 from sat.core.log import getLogger |
29 from sat.core.constants import Const as C | 34 from sat.core.constants import Const as C |
35 from sat.tools import image | |
30 from sat.tools.utils import ensure_deferred | 36 from sat.tools.utils import ensure_deferred |
37 from sat.tools.web import downloadFile | |
31 from sat.memory.sqla_mapping import PubsubSub, SubscriptionState | 38 from sat.memory.sqla_mapping import PubsubSub, SubscriptionState |
32 | 39 |
33 from .constants import ( | 40 from .constants import ( |
34 TYPE_ACTOR, | 41 TYPE_ACTOR, |
42 ST_AVATAR, | |
43 MAX_AVATAR_SIZE | |
35 ) | 44 ) |
36 | 45 |
37 | 46 |
38 log = getLogger(__name__) | 47 log = getLogger(__name__) |
39 | 48 |
129 actor_id = ap_item["id"] | 138 actor_id = ap_item["id"] |
130 actor_jid = await self.apg.getJIDFromId(actor_id) | 139 actor_jid = await self.apg.getJIDFromId(actor_id) |
131 subscriber_elt = self.apg._pps.buildSubscriberElt(actor_jid) | 140 subscriber_elt = self.apg._pps.buildSubscriberElt(actor_jid) |
132 item_elt = pubsub.Item(id=actor_id, payload=subscriber_elt) | 141 item_elt = pubsub.Item(id=actor_id, payload=subscriber_elt) |
133 return item_elt | 142 return item_elt |
143 | |
144 async def generateVCard(self, ap_account: str) -> domish.Element: | |
145 """Generate vCard4 (XEP-0292) item element from ap_account's metadata""" | |
146 actor_data = await self.apg.getAPActorDataFromAccount(ap_account) | |
147 identity_data = {} | |
148 | |
149 summary = actor_data.get("summary") | |
150 # summary is HTML, we have to convert it to text | |
151 if summary: | |
152 identity_data["description"] = await self.apg._t.convert( | |
153 summary, | |
154 self.apg._t.SYNTAX_XHTML, | |
155 self.apg._t.SYNTAX_TEXT, | |
156 False, | |
157 ) | |
158 | |
159 for field in ("name", "preferredUsername"): | |
160 value = actor_data.get(field) | |
161 if value: | |
162 identity_data.setdefault("nicknames", []).append(value) | |
163 vcard_elt = self.apg._v.dict2VCard(identity_data) | |
164 item_elt = domish.Element((pubsub.NS_PUBSUB, "item")) | |
165 item_elt.addChild(vcard_elt) | |
166 item_elt["id"] = self.apg._p.ID_SINGLETON | |
167 return item_elt | |
168 | |
169 async def getAvatarData( | |
170 self, | |
171 client: SatXMPPEntity, | |
172 ap_account: str | |
173 ) -> Dict[str, Any]: | |
174 """Retrieve actor's avatar if any, cache it and file actor_data | |
175 | |
176 ``cache_uid``, `path``` and ``media_type`` keys are always files | |
177 ``base64`` key is only filled if the file was not already in cache | |
178 """ | |
179 actor_data = await self.apg.getAPActorDataFromAccount(ap_account) | |
180 | |
181 for icon in await self.apg.apGetList(actor_data, "icon"): | |
182 url = icon.get("url") | |
183 if icon["type"] != "Image" or not url: | |
184 continue | |
185 parsed_url = urlparse(url) | |
186 if not parsed_url.scheme in ("http", "https"): | |
187 log.warning(f"unexpected URL scheme: {url!r}") | |
188 continue | |
189 filename = Path(parsed_url.path).name | |
190 if not filename: | |
191 log.warning(f"ignoring URL with invald path: {url!r}") | |
192 continue | |
193 break | |
194 else: | |
195 raise error.StanzaError("item-not-found") | |
196 | |
197 key = f"{ST_AVATAR}{url}" | |
198 cache_uid = await client._ap_storage.get(key) | |
199 | |
200 if cache_uid is None: | |
201 cache = None | |
202 else: | |
203 cache = self.apg.host.common_cache.getMetadata(cache_uid) | |
204 | |
205 if cache is None: | |
206 with tempfile.TemporaryDirectory() as dir_name: | |
207 dest_path = Path(dir_name, filename) | |
208 await downloadFile(url, dest_path, max_size=MAX_AVATAR_SIZE) | |
209 avatar_data = { | |
210 "path": dest_path, | |
211 "filename": filename, | |
212 'media_type': image.guess_type(dest_path), | |
213 } | |
214 | |
215 await self.apg._i.cacheAvatar( | |
216 self.apg.IMPORT_NAME, | |
217 avatar_data | |
218 ) | |
219 else: | |
220 avatar_data = { | |
221 "cache_uid": cache["uid"], | |
222 "path": cache["path"], | |
223 "media_type": cache["mime_type"] | |
224 } | |
225 | |
226 return avatar_data | |
227 | |
228 async def generateAvatarMetadata( | |
229 self, | |
230 client: SatXMPPEntity, | |
231 ap_account: str | |
232 ) -> domish.Element: | |
233 """Generate the metadata element for user avatar | |
234 | |
235 @raise StanzaError("item-not-found"): no avatar is present in actor data (in | |
236 ``icon`` field) | |
237 """ | |
238 avatar_data = await self.getAvatarData(client, ap_account) | |
239 return self.apg._a.buildItemMetadataElt(avatar_data) | |
240 | |
241 def _blockingB64EncodeAvatar(self, avatar_data: Dict[str, Any]) -> None: | |
242 with avatar_data["path"].open("rb") as f: | |
243 avatar_data["base64"] = b64encode(f.read()).decode() | |
244 | |
245 async def generateAvatarData( | |
246 self, | |
247 client: SatXMPPEntity, | |
248 ap_account: str, | |
249 itemIdentifiers: Optional[List[str]], | |
250 ) -> domish.Element: | |
251 """Generate the data element for user avatar | |
252 | |
253 @raise StanzaError("item-not-found"): no avatar cached with requested ID | |
254 """ | |
255 if not itemIdentifiers: | |
256 avatar_data = await self.getAvatarData(client, ap_account) | |
257 if "base64" not in avatar_data: | |
258 await threads.deferToThread(self._blockingB64EncodeAvatar, avatar_data) | |
259 else: | |
260 if len(itemIdentifiers) > 1: | |
261 # only a single item ID is supported | |
262 raise error.StanzaError("item-not-found") | |
263 item_id = itemIdentifiers[0] | |
264 # just to be sure that that we don't have an empty string | |
265 assert item_id | |
266 cache_data = self.apg.host.common_cache.getMetadata(item_id) | |
267 if cache_data is None: | |
268 raise error.StanzaError("item-not-found") | |
269 avatar_data = { | |
270 "cache_uid": item_id, | |
271 "path": cache_data["path"] | |
272 } | |
273 await threads.deferToThread(self._blockingB64EncodeAvatar, avatar_data) | |
274 | |
275 return self.apg._a.buildItemDataElt(avatar_data) | |
134 | 276 |
135 @ensure_deferred | 277 @ensure_deferred |
136 async def items( | 278 async def items( |
137 self, | 279 self, |
138 requestor: jid.JID, | 280 requestor: jid.JID, |
159 elif node.startswith(self.apg._pps.subscribers_node_prefix): | 301 elif node.startswith(self.apg._pps.subscribers_node_prefix): |
160 collection_name = "followers" | 302 collection_name = "followers" |
161 parser = self.apFollower2Elt | 303 parser = self.apFollower2Elt |
162 kwargs["only_ids"] = True | 304 kwargs["only_ids"] = True |
163 use_cache = False | 305 use_cache = False |
306 elif node == self.apg._v.node: | |
307 # vCard4 request | |
308 item_elt = await self.generateVCard(ap_account) | |
309 return [item_elt], None | |
310 elif node == self.apg._a.namespace_metadata: | |
311 item_elt = await self.generateAvatarMetadata(self.apg.client, ap_account) | |
312 return [item_elt], None | |
313 elif node == self.apg._a.namespace_data: | |
314 item_elt = await self.generateAvatarData( | |
315 self.apg.client, ap_account, itemIdentifiers | |
316 ) | |
317 return [item_elt], None | |
164 else: | 318 else: |
165 if not node.startswith(self.apg._m.namespace): | 319 if not node.startswith(self.apg._m.namespace): |
166 raise error.StanzaError( | 320 raise error.StanzaError( |
167 "feature-not-implemented", | 321 "feature-not-implemented", |
168 text=f"AP Gateway {C.APP_VERSION} only supports " | 322 text=f"AP Gateway {C.APP_VERSION} only supports " |