]> git.mxchange.org Git - fba.git/blob - fba/networks/lemmy.py
c64ae477f1ee055703c9820ec6e5afb6d80144b7
[fba.git] / fba / networks / lemmy.py
1 # Fedi API Block - An aggregator for fetching blocking data from fediverse nodes
2 # Copyright (C) 2023 Free Software Foundation
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published
6 # by the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17 import json
18 import logging
19
20 import bs4
21
22 from fba import csrf
23 from fba import utils
24
25 from fba.helpers import config
26 from fba.helpers import domain as domain_helper
27 from fba.helpers import tidyup
28
29 from fba.http import federation
30 from fba.http import network
31
32 from fba.models import instances
33
34 logging.basicConfig(level=logging.INFO)
35 logger = logging.getLogger(__name__)
36 #logger.setLevel(logging.DEBUG)
37
38 def fetch_peers(domain: str, origin: str) -> list:
39     logger.debug("domain='%s',origin='%s' - CALLED!", domain, origin)
40     domain_helper.raise_on(domain)
41
42     peers = list()
43
44     # No CSRF by default, you don't have to add network.api_headers by yourself here
45     headers = tuple()
46
47     try:
48         logger.debug("Checking CSRF for domain='%s'", domain)
49         headers = csrf.determine(domain, dict())
50     except network.exceptions as exception:
51         logger.warning("Exception '%s' during checking CSRF (fetch_peers,%s) - EXIT!", type(exception), __name__)
52         instances.set_last_error(domain, exception)
53         return list()
54
55     try:
56         logger.debug("Fetching '/api/v3/site' from domain='%s' ...", domain)
57         data = network.get_json_api(
58             domain,
59             "/api/v3/site",
60             headers,
61             (config.get("connection_timeout"), config.get("read_timeout"))
62         )
63
64         logger.debug("data[]='%s'", type(data))
65         if "error_message" in data:
66             logger.warning("Could not reach any JSON API: domain='%s'", domain)
67             instances.set_last_error(domain, data)
68         elif "federated_instances" in data["json"] and isinstance(data["json"]["federated_instances"], dict):
69             logger.debug("Found federated_instances for domain='%s'", domain)
70             peers = peers + federation.add_peers(data["json"]["federated_instances"])
71
72             logger.debug("Marking domain='%s' as successfully handled ...", domain)
73             instances.set_success(domain)
74
75         if len(peers) == 0:
76             logger.warning("Fetching instances for domain='%s' from /instances ...", domain)
77             peers = fetch_instances(domain, origin)
78
79     except network.exceptions as exception:
80         logger.warning("Exception during fetching JSON: domain='%s',exception[%s]:'%s'", domain, type(exception), str(exception))
81         instances.set_last_error(domain, exception)
82
83     logger.debug("peers()=%d - EXIT!", len(peers))
84     return peers
85
86 def fetch_blocks(domain: str, nodeinfo_url: str) -> list:
87     logger.debug("domain='%s,nodeinfo_url='%s' - CALLED!", domain, nodeinfo_url)
88     domain_helper.raise_on(domain)
89
90     if not isinstance(nodeinfo_url, str):
91         raise ValueError(f"Parameter nodeinfo_url[]='{type(nodeinfo_url)}' is not of type 'str'")
92     elif nodeinfo_url == "":
93         raise ValueError("Parameter 'nodeinfo_url' is empty")
94
95     translations = [
96         "Blocked Instances".lower(),
97         "Instàncies bloquejades".lower(),
98         "Blocáilte Ásc".lower(),
99         "封锁实例".lower(),
100         "Blokované instance".lower(),
101         "Geblokkeerde instanties".lower(),
102         "Blockerade instanser".lower(),
103         "Instàncias blocadas".lower(),
104         "Istanze bloccate".lower(),
105         "Instances bloquées".lower(),
106         "Letiltott példányok".lower(),
107         "Instancias bloqueadas".lower(),
108         "Blokeatuta dauden instantziak".lower(),
109         "차단된 인스턴스".lower(),
110         "Peladen Yang Diblokir".lower(),
111         "Blokerede servere".lower(),
112         "Blokitaj nodoj".lower(),
113         "Блокирани Инстанции".lower(),
114         "Blockierte Instanzen".lower(),
115         "Estetyt instanssit".lower(),
116         "Instâncias bloqueadas".lower(),
117         "Zablokowane instancje".lower(),
118         "Blokované inštancie".lower(),
119         "المثلاء المحجوبون".lower(),
120         "Užblokuoti serveriai".lower(),
121         "ブロックしたインスタンス".lower(),
122         "Блокированные Инстансы".lower(),
123         "Αποκλεισμένοι διακομιστές".lower(),
124         "封鎖站台".lower(),
125         "Instâncias bloqueadas".lower(),
126     ]
127
128     blocklist = list()
129
130     try:
131         # json endpoint for newer mastodongs
132         logger.debug("Fetching /instances from domain='%s'", domain)
133         response = network.fetch_response(
134             domain,
135             "/instances",
136             network.web_headers,
137             (config.get("connection_timeout"), config.get("read_timeout"))
138         )
139
140         logger.debug("response.ok='%s',response.status_code=%d,response.text()=%d", response.ok, response.status_code, len(response.text))
141         if response.ok and response.status_code < 300 and response.text != "":
142             logger.debug("Parsing %s Bytes ...", len(response.text))
143
144             doc = bs4.BeautifulSoup(response.text, "html.parser")
145             logger.debug("doc[]='%s'", type(doc))
146
147             found = None
148             for container in [{"class": "home-instances container-lg"}, {"class": "container"}]:
149                 logger.debug("container='%s'", container)
150                 headers = doc.findAll("div", container)
151
152                 logger.debug("Checking %d header(s) ...", len(headers))
153                 for header in headers:
154                     logger.debug("header[]='%s'", type(header))
155                     content = header.find(["h2", "h3", "h4", "h5"])
156
157                     logger.debug("content[%s]='%s' - BEFORE!", type(content), content)
158                     if content is not None:
159                         content = content.contents[0]
160                     logger.debug("content[%s]='%s' - AFTER!", type(content), content)
161
162                     if content is None:
163                         logger.debug("domain='%s' has returned empty header='%s' - SKIPPED!", domain, header)
164                         continue
165                     elif not isinstance(content, str):
166                         logger.debug("content[]='%s' is not supported/wanted type 'str' - SKIPPED!", type(content))
167                         continue
168                     elif content.lower() in translations:
169                         logger.debug("Found header with blocked instances - BREAK!")
170                         found = header
171                         break
172
173             logger.debug("found[]='%s'", type(found))
174             if found is None:
175                 logger.info("domain='%s' has no HTML blocklist, checking scripts ...", domain)
176                 peers = parse_script(doc, "blocked")
177
178                 logger.debug("domain='%s' has %d peer(s).", domain, len(peers))
179                 for blocked in peers:
180                     logger.debug("Appending blocker='%s',blocked='%s',block_level='reject' ...", domain, blocked)
181                     blocklist.append({
182                         "blocker"    : domain,
183                         "blocked"    : blocked,
184                         "reason"     : None,
185                         "block_level": "reject",
186                     })
187
188                 logger.debug("blocklist()=%d - EXIT!", len(blocklist))
189                 return blocklist
190
191             blocking = found.find_next(["ul","table"]).findAll("a")
192             logger.debug("Found %d blocked instance(s) ...", len(blocking))
193             for tag in blocking:
194                 logger.debug("tag[]='%s'", type(tag))
195                 blocked = tidyup.domain(tag.contents[0])
196                 logger.debug("blocked='%s'", blocked)
197
198                 if blocked == "":
199                     logger.warning("blocked='%s' is empty after tidyup.domain() - SKIPPED!", tag.contents[0])
200                     continue
201                 elif not utils.is_domain_wanted(blocked):
202                     logger.debug("blocked='%s' is not wanted - SKIPPED!", blocked)
203                     continue
204
205                 logger.debug("Appending blocker='%s',blocked='%s',block_level='reject' ...", domain, blocked)
206                 blocklist.append({
207                     "blocker"    : domain,
208                     "blocked"    : blocked,
209                     "reason"     : None,
210                     "block_level": "reject",
211                 })
212
213     except network.exceptions as exception:
214         logger.warning("domain='%s',exception[%s]:'%s'", domain, type(exception), str(exception))
215         instances.set_last_error(domain, exception)
216
217     logger.debug("blocklist()=%d - EXIT!", len(blocklist))
218     return blocklist
219
220 def fetch_instances(domain: str, origin: str) -> list:
221     logger.debug("domain='%s',origin='%s' - CALLED!", domain, origin)
222     domain_helper.raise_on(domain)
223
224     peers = list()
225
226     try:
227         # json endpoint for newer mastodongs
228         logger.debug("Fetching /instances from domain='%s'", domain)
229         response = network.fetch_response(
230             domain,
231             "/instances",
232             network.web_headers,
233             (config.get("connection_timeout"), config.get("read_timeout"))
234         )
235
236         logger.debug("response.ok='%s',response.status_code=%d,response.text()=%d", response.ok, response.status_code, len(response.text))
237         if response.ok and response.status_code < 300 and response.text != "":
238             logger.debug("Parsing %s Bytes ...", len(response.text))
239
240             doc = bs4.BeautifulSoup(response.text, "html.parser")
241             logger.debug("doc[]='%s'", type(doc))
242
243             for container in [{"class": "home-instances container-lg"}, {"class": "container"}]:
244                 logger.debug("container='%s'", container)
245                 headers = doc.findAll("div", container)
246
247                 logger.debug("Checking %d headers ...", len(headers))
248                 for header in headers:
249                     logger.debug("header[%s]='%s'", type(header), header)
250
251                     rows = header.find_next(["ul","table"]).findAll("a")
252                     logger.debug("Found %d blocked instance(s) ...", len(rows))
253                     for tag in rows:
254                         logger.debug("tag[]='%s'", type(tag))
255                         text = tag.contents[0] if isinstance(tag.contents[0], str) else tag.contents[0].text
256                         peer = tidyup.domain(text)
257                         logger.debug("peer='%s'", peer)
258
259                         if peer == "":
260                             logger.debug("peer is empty - SKIPPED!")
261                             continue
262                         elif not utils.is_domain_wanted(peer):
263                             logger.debug("peer='%s' is not wanted - SKIPPED!", peer)
264                             continue
265                         elif peer in peers:
266                             logger.debug("peer='%s' already added - SKIPPED!", peer)
267                             continue
268
269                         logger.debug("Appending peer='%s' ...", peer)
270                         peers.append(peer)
271
272             logger.debug("peers()=%d", len(peers))
273             if len(peers) == 0:
274                 logger.debug("Found no peers for domain='%s', trying script tag ...", domain)
275                 peers = parse_script(doc)
276
277         logger.debug("Marking domain='%s' as successfully handled, peers()=%d ...", domain, len(peers))
278         instances.set_success(domain)
279
280     except network.exceptions as exception:
281         logger.warning("domain='%s',exception[%s]:'%s'", domain, type(exception), str(exception))
282         instances.set_last_error(domain, exception)
283
284     logger.debug("peers()=%d - EXIT!", len(peers))
285     return peers
286
287 def parse_script(doc: bs4.BeautifulSoup, only: str = None) -> list:
288     logger.debug("doc[]='%s',only='%s' - CALLED!")
289     if not isinstance(doc, bs4.BeautifulSoup):
290         raise ValueError(f"Parameter doc[]='{type(only)}' is not of type 'bs4.BeautifulSoup'")
291     elif not isinstance(only, str) and only != None:
292         raise ValueError(f"Parameter only[]='{type(only)}' is not of type 'str'")
293     elif isinstance(only, str) and only == "":
294         raise ValueError("Parameter 'only' is empty")
295
296     scripts = doc.find_all("script")
297     peers = list()
298
299     logger.debug("scripts()=%d", len(scripts))
300     for script in scripts:
301         logger.debug("script[%s].contents()=%d", type(script), len(script.contents))
302         if len(script.contents) == 0:
303             logger.debug("script has no contents - SKIPPED!")
304             continue
305         elif not script.contents[0].startswith("window.isoData"):
306             logger.debug("script.contents[0]='%s' does not start with window.isoData - SKIPPED!", script.contents[0])
307             continue
308
309         logger.debug("script.contents[0][]='%s'", type(script.contents[0]))
310
311         iso_data = script.contents[0].split("=")[1].strip().replace(":undefined", ":\"undefined\"")
312         logger.debug("iso_data[%s]='%s'", type(iso_data), iso_data)
313
314         parsed = None
315         try:
316             parsed = json.loads(iso_data)
317         except json.decoder.JSONDecodeError as exception:
318             logger.warning("Exception '%s' during parsing %d Bytes: '%s'", type(exception), len(iso_data), str(exception))
319             return list()
320
321         logger.debug("parsed[%s]()=%d", type(parsed), len(parsed))
322
323         if "routeData" not in parsed:
324             logger.warning("parsed[%s]()=%d does not contain element 'routeData'", type(parsed), len(parsed))
325             continue
326         elif "federatedInstancesResponse" not in parsed["routeData"]:
327             logger.warning("parsed[routeData][%s]()=%d does not contain element 'federatedInstancesResponse'", type(parsed["routeData"]), len(parsed["routeData"]))
328             continue
329         elif "data" not in parsed["routeData"]["federatedInstancesResponse"]:
330             logger.warning("parsed[routeData][federatedInstancesResponse][%s]()=%d does not contain element 'data'", type(parsed["routeData"]["federatedInstancesResponse"]), len(parsed["routeData"]["federatedInstancesResponse"]))
331             continue
332         elif "federated_instances" not in parsed["routeData"]["federatedInstancesResponse"]["data"]:
333             logger.warning("parsed[routeData][federatedInstancesResponse][data][%s]()=%d does not contain element 'data'", type(parsed["routeData"]["federatedInstancesResponse"]["data"]), len(parsed["routeData"]["federatedInstancesResponse"]["data"]))
334             continue
335
336         data = parsed["routeData"]["federatedInstancesResponse"]["data"]["federated_instances"]
337         logger.debug("Checking %d data elements ...", len(data))
338         for element in data:
339             logger.debug("element='%s'", element)
340             if isinstance(only, str) and only != element:
341                 logger.debug("Skipping unwanted element='%s',only='%s'", element, only)
342                 continue
343
344             logger.debug("Checking data[%s]()=%d row(s) ...", element, len(data[element]))
345             for row in data[element]:
346                 logger.debug("row[]='%s'", type(row))
347                 if "domain" not in row:
348                     logger.warning("row()=%d has no element 'domain' - SKIPPED!", len(row))
349                     continue
350
351                 logger.debug("row[domain]='%s' - BEFORE!", row["domain"])
352                 peer = tidyup.domain(row["domain"])
353                 logger.debug("peer='%s' - AFTER!", peer)
354
355                 if peer == "":
356                     logger.debug("peer is empty - SKIPPED!")
357                     continue
358                 elif not utils.is_domain_wanted(peer):
359                     logger.debug("peer='%s' is not wanted - SKIPPED!", peer)
360                     continue
361                 elif peer in peers:
362                     logger.debug("peer='%s' already added - SKIPPED!", peer)
363                     continue
364
365                 logger.debug("Appending peer='%s' ...", peer)
366                 peers.append(peer)
367
368     logger.debug("peers()=%d - EXIT!", len(peers))
369     return peers