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