491
|
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 [] |