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