comparison sat_pubsub/bookmark_compat.py @ 491:4e8e8788bc86

Bookmark compatibility layer: The new `bookmark_compat` module add a compatibility layer between XEP-0048 (with XEP-0049 private XML storage) and XEP-0402, i.e. it implements the `urn:xmpp:bookmarks:1#compat` feature.
author Goffi <goffi@goffi.org>
date Thu, 21 Nov 2024 11:03:51 +0100
parents
children 468b7cd6c344
comparison
equal deleted inserted replaced
490:cab491b1b563 491:4e8e8788bc86
1 #!/usr/bin/env python3
2 #
3 # Copyright (c) 2015-2024 Jérôme Poisson
4
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
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/>.
18
19 "This module implements a compatibility layer for XEP-0048."
20
21
22 from twisted.internet import defer
23 from twisted.python import log
24 from twisted.words.protocols.jabber import error as jabber_error, jid, xmlstream
25 from twisted.words.xish import domish
26 from wokkel import disco, iwokkel, pubsub
27 from wokkel.iwokkel import IPubSubService
28 from zope.interface import implementer
29
30 from sat_pubsub import const
31
32 from . import error
33
34 NS_IQ_PRIVATE = "jabber:iq:private"
35 NS_STORAGE_BOOKMARKS = "storage:bookmarks"
36 NS_BOOKMARKS2 = "urn:xmpp:bookmarks:1"
37 NS_BOOKMARKS_COMPAT = f"{NS_BOOKMARKS2}#compat"
38 IQ_PRIVATE_GET = f'/iq[@type="get"]/query[@xmlns="{NS_IQ_PRIVATE}"]'
39 IQ_PRIVATE_SET = f'/iq[@type="set"]/query[@xmlns="{NS_IQ_PRIVATE}"]'
40
41
42 @implementer(iwokkel.IDisco)
43 class BookmarkCompatHandler(disco.DiscoClientProtocol):
44
45 def __init__(self, service_jid):
46 super().__init__()
47 self.backend = None
48
49 def connectionInitialized(self):
50 for handler in self.parent.handlers:
51 if IPubSubService.providedBy(handler):
52 self._pubsub_service = handler
53 break
54 self.backend = self.parent.parent.getServiceNamed("backend")
55 self.xmlstream.addObserver(IQ_PRIVATE_GET, self._on_get)
56 self.xmlstream.addObserver(IQ_PRIVATE_SET, self._on_set)
57
58 def _on_get(self, iq_elt: domish.Element) -> None:
59 """Handle incoming legacy IQ GET requests for bookmarks.
60
61 This method processes legacy XEP-0048 IQ GET requests. It does some checks and
62 then proceeds to return the bookmarks.
63
64 @param iq_elt: The incoming IQ element.
65 """
66 if not iq_elt.delegated:
67 return
68 assert self.xmlstream is not None
69 iq_elt.handled = True
70 from_jid = jid.JID(iq_elt["from"])
71 to_jid = jid.JID(iq_elt["to"])
72 if from_jid.userhostJID() != to_jid:
73 msg = (
74 f"{from_jid.userhost()} is not allowed to access private storage of "
75 f"{to_jid.userhost()}!"
76 )
77 log.msg(f"Hack attempt? {msg}")
78 error_elt = jabber_error.StanzaError("forbidden", text=msg).toResponse(iq_elt)
79 self.xmlstream.send(error_elt)
80 return
81
82 query_elt = iq_elt.query
83 assert query_elt is not None
84
85 storage_elt = query_elt.firstChildElement()
86 if (
87 storage_elt is None
88 or storage_elt.name != "storage"
89 or storage_elt.uri != NS_STORAGE_BOOKMARKS
90 ):
91 error_elt = jabber_error.StanzaError(
92 "not-allowed",
93 text=(
94 f'"{NS_STORAGE_BOOKMARKS}" is the only private XML storage allowed '
95 "on this server"
96 ),
97 ).toResponse(iq_elt)
98 self.xmlstream.send(error_elt)
99 return
100
101 defer.ensureDeferred(self.return_bookmarks(iq_elt, to_jid))
102
103 async def return_bookmarks(self, iq_elt: domish.Element, requestor: jid.JID) -> None:
104 """Send IQ result for bookmark request on private XML.
105
106 Retrieve bookmark from Bookmarks2 PEP node, convert them to private XML
107 XEP-0048 format, and send the IQ result.
108 @param iq_elt: The incoming IQ element.
109 @param requestor: The JID of the entity requesting the bookmarks.
110 """
111 assert self.backend is not None
112 assert self.xmlstream is not None
113 items, __ = await self.backend.getItems(
114 NS_BOOKMARKS2, requestor, requestor, ext_data={"pep": True}
115 )
116 iq_result_elt = xmlstream.toResponse(iq_elt, "result")
117
118 query_elt = iq_result_elt.addElement((NS_IQ_PRIVATE, "query"))
119 storage_elt = query_elt.addElement((NS_STORAGE_BOOKMARKS, "storage"))
120
121 # For simply add all bookmarks to get XEP-0048 private XML elements.
122 for item_elt in items:
123 conference_elt = next(item_elt.elements(NS_BOOKMARKS2, "conference"), None)
124 if conference_elt is not None:
125 # The namespace is not the same for XEP-0048
126 conference_elt.uri = NS_STORAGE_BOOKMARKS
127 for elt in conference_elt.children:
128 elt.uri = NS_STORAGE_BOOKMARKS
129 conference_elt["jid"] = item_elt["id"]
130 storage_elt.addChild(conference_elt)
131 else:
132 log.msg(
133 "Warning: Unexpectedly missing conference element: "
134 f"{item_elt.toXml()}"
135 )
136
137 self.xmlstream.send(iq_result_elt)
138
139 def _on_set(self, iq_elt: domish.Element) -> None:
140 if not iq_elt.delegated:
141 return
142 assert self.xmlstream is not None
143 iq_elt.handled = True
144 from_jid = jid.JID(iq_elt["from"])
145 to_jid = jid.JID(iq_elt["to"])
146 if from_jid.userhostJID() != to_jid:
147 msg = (
148 f"{from_jid.userhost()} is not allowed to access private storage of "
149 f"{to_jid.userhost()}!"
150 )
151 log.msg(f"Hack attempt? {msg}")
152 error_elt = jabber_error.StanzaError("forbidden", text=msg).toResponse(iq_elt)
153 self.xmlstream.send(error_elt)
154 return
155 query_elt = iq_elt.query
156 assert query_elt is not None
157 storage_elt = query_elt.firstChildElement()
158 if (
159 storage_elt is None
160 or storage_elt.name != "storage"
161 or storage_elt.uri != NS_STORAGE_BOOKMARKS
162 ):
163 error_elt = jabber_error.StanzaError(
164 "not-allowed",
165 text=(
166 f'"{NS_STORAGE_BOOKMARKS}" is the only private XML storage allowed '
167 "on this server"
168 ),
169 ).toResponse(iq_elt)
170 self.xmlstream.send(error_elt)
171 return
172 defer.ensureDeferred(self.on_set(iq_elt, from_jid))
173
174 async def publish_bookmarks(
175 self, iq_elt: domish.Element, items: list[domish.Element], requestor: jid.JID
176 ) -> None:
177 """Publish bookmarks on Bookmarks2 PEP node"""
178 assert self.backend is not None
179 assert self.xmlstream is not None
180 try:
181 await self.backend.publish(
182 NS_BOOKMARKS2,
183 items,
184 requestor,
185 options={const.OPT_ACCESS_MODEL: const.VAL_AMODEL_WHITELIST},
186 pep=True,
187 recipient=requestor,
188 )
189 except error.NodeNotFound:
190 # The node doesn't exist, we create it
191 await self.backend.createNode(
192 NS_BOOKMARKS2,
193 requestor,
194 options={const.OPT_ACCESS_MODEL: const.VAL_AMODEL_WHITELIST},
195 pep=True,
196 recipient=requestor,
197 )
198 await self.publish_bookmarks(iq_elt, items, requestor)
199 except Exception as e:
200 log.err(f"Error while publishing converted bookmarks: {e}")
201 error_elt = jabber_error.StanzaError(
202 "internal-server-error",
203 text=(f"Something went wrong while publishing the bookmark items: {e}"),
204 ).toResponse(iq_elt)
205 self.xmlstream.send(error_elt)
206 raise e
207
208 async def on_set(self, iq_elt: domish.Element, requestor: jid.JID) -> None:
209 """Handle IQ set request for bookmarks on private XML.
210
211 This method processes an IQ set request to update bookmarks stored in private XML.
212 It extracts conference elements from the request, transforms them into XEP-0402
213 format, and publishes them to XEP-0402 PEP node.
214
215 @param iq_elt: The incoming IQ element containing the set request.
216 @param requestor: The JID of the entity making the request.
217 """
218 # TODO: We should check if items already exist in Bookmarks 2 and avoid updating
219 # them if there is no change. However, considering that XEP-0048 is deprecated and
220 # active implementations without XEP-0402 are rare, it might not be worth the
221 # trouble.
222 assert self.backend is not None
223 assert self.xmlstream is not None
224 query_elt = iq_elt.query
225 assert query_elt is not None
226 # <storage> presence has been checked in ``_on_set``, we know that we have it
227 # here.
228 storage_elt = next(query_elt.elements(NS_STORAGE_BOOKMARKS, "storage"))
229
230 items = []
231 for conference_elt in storage_elt.elements(NS_STORAGE_BOOKMARKS, "conference"):
232 item_elt = domish.Element((pubsub.NS_PUBSUB, "item"))
233 try:
234 item_elt["id"] = conference_elt["jid"]
235 except AttributeError:
236 log.msg(
237 "Warning: ignoring <conference> with missing jid: "
238 f"conference_elt.toXml()"
239 )
240 continue
241 new_conference_elt = item_elt.addElement((NS_BOOKMARKS2, "conference"))
242 for attr, value in conference_elt.attributes.items():
243 if attr == "jid":
244 continue
245 new_conference_elt[attr] = value
246 for child in conference_elt.children:
247 new_child = domish.Element((NS_BOOKMARKS2, child.name))
248 new_child.addContent(str(child))
249 new_conference_elt.addChild(new_child)
250 items.append(item_elt)
251
252 await self.publish_bookmarks(iq_elt, items, requestor)
253
254 iq_result_elt = xmlstream.toResponse(iq_elt, "result")
255 self.xmlstream.send(iq_result_elt)
256
257 def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
258 return [
259 disco.DiscoFeature(NS_IQ_PRIVATE),
260 disco.DiscoFeature(NS_BOOKMARKS_COMPAT),
261 ]
262
263 def getDiscoItems(self, requestor, service, nodeIdentifier=""):
264 return []