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 "