]> git.mxchange.org Git - fba.git/blob - fba/networks/lemmy.py
9b0351773b1f6405c3ebf4d4115ebbb30c352564
[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
78     peers = list()
79
80     # No CSRF by default, you don't have to add network.api_headers by yourself here
81     headers = tuple()
82
83     try:
84         logger.debug("Checking CSRF for domain='%s'", domain)
85         headers = csrf.determine(domain, dict())
86     except network.exceptions as exception:
87         logger.warning("Exception '%s' during checking CSRF (fetch_peers,%s)", type(exception), __name__)
88         instances.set_last_error(domain, exception)
89
90         logger.debug("Returning empty list ... - EXIT!")
91         return list()
92
93     try:
94         logger.debug("Fetching '/api/v3/site' from domain='%s' ...", domain)
95         data = network.get_json_api(
96             domain,
97             "/api/v3/site",
98             headers,
99             (config.get("connection_timeout"), config.get("read_timeout"))
100         )
101
102         logger.debug("data[]='%s'", type(data))
103         if "error_message" in data:
104             logger.warning("Could not reach any JSON API: domain='%s'", domain)
105             instances.set_last_error(domain, data)
106         elif "federated_instances" in data["json"] and isinstance(data["json"]["federated_instances"], dict):
107             logger.debug("Found federated_instances for domain='%s'", domain)
108             peers = peers + federation.add_peers(data["json"]["federated_instances"])
109
110             logger.debug("Marking domain='%s' as successfully handled ...", domain)
111             instances.set_success(domain)
112
113         if len(peers) == 0:
114             logger.warning("Fetching instances for domain='%s' from /instances ...", domain)
115             peers = fetch_instances(domain, origin)
116
117     except network.exceptions as exception:
118         logger.warning("Exception during fetching JSON: domain='%s',exception[%s]:'%s'", domain, type(exception), str(exception))
119         instances.set_last_error(domain, exception)
120
121     logger.debug("peers()=%d - EXIT!", len(peers))
122     return peers
123
124 def fetch_blocks(domain: str) -> list:
125     logger.debug("domain='%s - CALLED!", domain)
126     domain_helper.raise_on(domain)
127
128     if blacklist.is_blacklisted(domain):
129         raise Exception(f"domain='{domain}' is blacklisted but function is invoked.")
130     elif not instances.is_registered(domain):
131         raise Exception(f"domain='{domain}' is not registered but function is invoked.")
132
133     blocklist = list()
134
135     try:
136         # json endpoint for newer mastodongs
137         logger.debug("Fetching /instances from domain='%s'", domain)
138         response = network.fetch_response(
139             domain,
140             "/instances",
141             network.web_headers,
142             (config.get("connection_timeout"), config.get("read_timeout"))
143         )
144
145         logger.debug("response.ok='%s',response.status_code=%d,response.text()=%d", response.ok, response.status_code, len(response.text))
146         if response.ok and response.status_code == 200 and response.text != "":
147             logger.debug("Parsing %s Bytes ...", len(response.text))
148
149             doc = bs4.BeautifulSoup(response.text, "html.parser")
150             logger.debug("doc[]='%s'", type(doc))
151
152             found = None
153             for criteria in [{"class": "home-instances container-lg"}, {"class": "container"}]:
154                 logger.debug("criteria='%s'", criteria)
155                 containers = doc.findAll("div", criteria)
156
157                 logger.debug("Checking %d containers ...", len(containers))
158                 for container in containers:
159                     logger.debug("container[]='%s'", type(container))
160                     for header in container.find_all(["h2", "h3", "h4", "h5"]):
161                         content = header
162                         logger.debug("header[%s]='%s' - BEFORE!", type(header), header)
163                         if header is not None:
164                             content = str(header.contents[0])
165                         logger.debug("content[%s]='%s' - AFTER!", type(content), content)
166
167                         if content is None:
168                             logger.debug("domain='%s' has returned empty header='%s' - SKIPPED!", domain, header)
169                             continue
170                         elif not isinstance(content, str):
171                             logger.debug("content[]='%s' is not supported/wanted type 'str' - SKIPPED!", type(content))
172                             continue
173                         elif content.lower() in translations:
174                             logger.debug("Found header='%s' with blocked instances - BREAK(3) !", header)
175                             found = header
176                             break
177
178                     logger.debug("found[]='%s'", type(found))
179                     if found is not None:
180                         logger.debug("Found header with blocked instances - BREAK(2) !")
181                         break
182
183                 logger.debug("found[]='%s'", type(found))
184                 if found is not None:
185                     logger.debug("Found header with blocked instances - BREAK(1) !")
186                     break
187
188             logger.debug("found[]='%s'", type(found))
189             if found is None:
190                 logger.info("domain='%s' has no HTML blocklist, checking scripts ...", domain)
191                 peers = parse_script(doc, "blocked")
192
193                 logger.debug("domain='%s' has %d peer(s).", domain, len(peers))
194                 for blocked in peers:
195                     logger.debug("Appending blocker='%s',blocked='%s',block_level='reject' ...", domain, blocked)
196                     blocklist.append({
197                         "blocker"    : domain,
198                         "blocked"    : blocked,
199                         "reason"     : None,
200                         "block_level": "reject",
201                     })
202
203                 logger.debug("blocklist()=%d - EXIT!", len(blocklist))
204                 return blocklist
205
206             blocking = found.find_next(["ul", "table"]).findAll("a")
207             logger.debug("Found %d blocked instance(s) ...", len(blocking))
208             for tag in blocking:
209                 logger.debug("tag[]='%s'", type(tag))
210                 blocked = tidyup.domain(tag.contents[0]) if tag.contents[0] != "" else None
211                 logger.debug("blocked='%s'", blocked)
212
213                 if blocked is None or blocked == "":
214                     logger.warning("blocked='%s' is empty after tidyup.domain() - SKIPPED!", tag.contents[0])
215                     continue
216                 elif not domain_helper.is_wanted(blocked):
217                     logger.debug("blocked='%s' is not wanted - SKIPPED!", blocked)
218                     continue
219
220                 logger.debug("Appending blocker='%s',blocked='%s',block_level='reject' ...", domain, blocked)
221                 blocklist.append({
222                     "blocker"    : domain,
223                     "blocked"    : blocked,
224                     "reason"     : None,
225                     "block_level": "reject",
226                 })
227         else:
228             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)
229             instances.set_last_error(domain, response)
230
231     except network.exceptions as exception:
232         logger.warning("domain='%s',exception[%s]:'%s'", domain, type(exception), str(exception))
233         instances.set_last_error(domain, exception)
234
235     logger.debug("blocklist()=%d - EXIT!", len(blocklist))
236     return blocklist
237
238 def fetch_instances(domain: str, origin: str) -> list:
239     logger.debug("domain='%s',origin='%s' - CALLED!", domain, origin)
240     domain_helper.raise_on(domain)
241
242     if blacklist.is_blacklisted(domain):
243         raise Exception(f"domain='{domain}' is blacklisted but function is invoked.")
244
245     peers = list()
246
247     try:
248         # json endpoint for newer mastodongs
249         logger.debug("Fetching /instances from domain='%s'", domain)
250         response = network.fetch_response(
251             domain,
252             "/instances",
253             network.web_headers,
254             (config.get("connection_timeout"), config.get("read_timeout"))
255         )
256
257         logger.debug("response.ok='%s',response.status_code=%d,response.text()=%d", response.ok, response.status_code, len(response.text))
258         if response.ok and response.status_code == 200 and response.text != "":
259             logger.debug("Parsing %s Bytes ...", len(response.text))
260
261             doc = bs4.BeautifulSoup(response.text, "html.parser")
262             logger.debug("doc[]='%s'", type(doc))
263
264             for criteria in [{"class": "home-instances container-lg"}, {"class": "container"}]:
265                 logger.debug("criteria='%s'", criteria)
266                 containers = doc.findAll("div", criteria)
267
268                 logger.debug("Checking %d containers ...", len(containers))
269                 for header in containers:
270                     logger.debug("header[%s]='%s'", type(header), header)
271
272                     rows = header.find_next(["ul","table"]).findAll("a")
273                     logger.debug("Found %d instance(s) ...", len(rows))
274                     for tag in rows:
275                         logger.debug("tag[]='%s'", type(tag))
276                         text = tag.contents[0] if isinstance(tag.contents[0], str) else tag.contents[0].text
277                         logger.debug("text='%s' - BEFORE!", text)
278
279                         peer = tidyup.domain(text) if text != "" else None
280                         logger.debug("peer='%s' - AFTER", peer)
281
282                         if peer is None or peer == "":
283                             logger.warning("peer='%s' is empty, text='%s' - SKIPPED!", peer, text)
284                             continue
285                         elif not domain_helper.is_wanted(peer):
286                             logger.debug("peer='%s' is not wanted - SKIPPED!", peer)
287                             continue
288                         elif peer in peers:
289                             logger.debug("peer='%s' already added - SKIPPED!", peer)
290                             continue
291
292                         logger.debug("Appending peer='%s' ...", peer)
293                         peers.append(peer)
294
295             logger.debug("peers()=%d", len(peers))
296             if len(peers) == 0:
297                 logger.debug("Found no peers for domain='%s', trying script tag ...", domain)
298                 peers = parse_script(doc)
299         else:
300             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)
301             instances.set_last_error(domain, response)
302
303         logger.debug("Marking domain='%s' as successfully handled, peers()=%d ...", domain, len(peers))
304         instances.set_success(domain)
305
306     except network.exceptions as exception:
307         logger.warning("domain='%s',exception[%s]:'%s'", domain, type(exception), str(exception))
308         instances.set_last_error(domain, exception)
309
310     logger.debug("peers()=%d - EXIT!", len(peers))
311     return peers
312
313 def parse_script(doc: bs4.BeautifulSoup, only: str = None) -> list:
314     logger.debug("doc[]='%s',only='%s' - CALLED!")
315
316     if not isinstance(doc, bs4.BeautifulSoup):
317         raise ValueError(f"Parameter doc[]='{type(only)}' is not of type 'bs4.BeautifulSoup'")
318     elif not isinstance(only, str) and only is not None:
319         raise ValueError(f"Parameter only[]='{type(only)}' is not of type 'str'")
320     elif isinstance(only, str) and only == "":
321         raise ValueError("Parameter 'only' is empty")
322
323     scripts = doc.find_all("script")
324     peers = list()
325
326     logger.debug("scripts()=%d", len(scripts))
327     for script in scripts:
328         logger.debug("script[%s].contents()=%d", type(script), len(script.contents))
329         if len(script.contents) == 0:
330             logger.debug("script has no contents - SKIPPED!")
331             continue
332         elif not script.contents[0].startswith("window.isoData"):
333             logger.debug("script.contents[0]='%s' does not start with window.isoData - SKIPPED!", script.contents[0])
334             continue
335
336         logger.debug("script.contents[0][]='%s'", type(script.contents[0]))
337
338         iso_data = script.contents[0].split("=")[1].strip().replace(":undefined", ":\"undefined\"")
339         logger.debug("iso_data[%s]='%s'", type(iso_data), iso_data)
340
341         parsed = None
342         try:
343             parsed = json.loads(iso_data)
344         except json.decoder.JSONDecodeError as exception:
345             logger.warning("Exception '%s' during parsing %d Bytes: '%s' - EXIT!", type(exception), len(iso_data), str(exception))
346             return list()
347
348         logger.debug("parsed[%s]()=%d", type(parsed), len(parsed))
349
350         if "routeData" not in parsed:
351             logger.warning("parsed[%s]()=%d does not contain element 'routeData'", type(parsed), len(parsed))
352             continue
353         elif "federatedInstancesResponse" not in parsed["routeData"]:
354             logger.warning("parsed[routeData][%s]()=%d does not contain element 'federatedInstancesResponse'", type(parsed["routeData"]), len(parsed["routeData"]))
355             continue
356         elif "data" not in parsed["routeData"]["federatedInstancesResponse"]:
357             logger.warning("parsed[routeData][federatedInstancesResponse][%s]()=%d does not contain element 'data'", type(parsed["routeData"]["federatedInstancesResponse"]), len(parsed["routeData"]["federatedInstancesResponse"]))
358             continue
359         elif "federated_instances" not in parsed["routeData"]["federatedInstancesResponse"]["data"]:
360             logger.warning("parsed[routeData][federatedInstancesResponse][data][%s]()=%d does not contain element 'data'", type(parsed["routeData"]["federatedInstancesResponse"]["data"]), len(parsed["routeData"]["federatedInstancesResponse"]["data"]))
361             continue
362
363         data = parsed["routeData"]["federatedInstancesResponse"]["data"]["federated_instances"]
364         logger.debug("Checking %d data elements ...", len(data))
365         for element in data:
366             logger.debug("element='%s'", element)
367             if isinstance(only, str) and only != element:
368                 logger.debug("Skipping unwanted element='%s',only='%s'", element, only)
369                 continue
370
371             logger.debug("Checking data[%s]()=%d row(s) ...", element, len(data[element]))
372             for row in data[element]:
373                 logger.debug("row[]='%s'", type(row))
374                 if "domain" not in row:
375                     logger.warning("row()=%d has no element 'domain' - SKIPPED!", len(row))
376                     continue
377
378                 logger.debug("row[domain]='%s' - BEFORE!", row["domain"])
379                 peer = tidyup.domain(row["domain"])
380                 logger.debug("peer='%s' - AFTER!", peer)
381
382                 if peer is None or peer == "":
383                     logger.warning("peer='%s' is empty, row[domain]='%s' - SKIPPED!", peer, row["domain"])
384                     continue
385                 elif not domain_helper.is_wanted(peer):
386                     logger.debug("peer='%s' is not wanted - SKIPPED!", peer)
387                 elif peer in peers:
388                     logger.debug("peer='%s' already added - SKIPPED!", peer)
389                     continue
390
391                 logger.debug("Appending peer='%s' ...", peer)
392                 peers.append(peer)
393
394     logger.debug("peers()=%d - EXIT!", len(peers))
395     return peers