]> git.mxchange.org Git - fba.git/blob - fba/networks/lemmy.py
efc85de953927d325a9a76f6f84d14dde05752ff
[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 config
23 from fba.helpers import domain as domain_helper
24 from fba.helpers import tidyup
25
26 from fba.http import csrf
27 from fba.http import federation
28 from fba.http import network
29
30 from fba.models import instances
31
32 logging.basicConfig(level=logging.INFO)
33 logger = logging.getLogger(__name__)
34 #logger.setLevel(logging.DEBUG)
35
36 def fetch_peers(domain: str, origin: str) -> list:
37     logger.debug("domain='%s',origin='%s' - CALLED!", domain, origin)
38     domain_helper.raise_on(domain)
39
40     peers = list()
41
42     # No CSRF by default, you don't have to add network.api_headers by yourself here
43     headers = tuple()
44
45     try:
46         logger.debug("Checking CSRF for domain='%s'", domain)
47         headers = csrf.determine(domain, dict())
48     except network.exceptions as exception:
49         logger.warning("Exception '%s' during checking CSRF (fetch_peers,%s)", type(exception), __name__)
50         instances.set_last_error(domain, exception)
51
52         logger.debug("Returning empty list ... - EXIT!")
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) -> list:
87     logger.debug("domain='%s - CALLED!", domain)
88     domain_helper.raise_on(domain)
89
90     if not instances.is_registered(domain):
91         raise Exception(f"domain='{domain}' is not registered but function is invoked.")
92
93     translations = [
94         "Blocked Instances".lower(),
95         "Instàncies bloquejades".lower(),
96         "Blocáilte Ásc".lower(),
97         "封锁实例".lower(),
98         "Blokované instance".lower(),
99         "Geblokkeerde instanties".lower(),
100         "Blockerade instanser".lower(),
101         "Instàncias blocadas".lower(),
102         "Istanze bloccate".lower(),
103         "Instances bloquées".lower(),
104         "Letiltott példányok".lower(),
105         "Instancias bloqueadas".lower(),
106         "Blokeatuta dauden instantziak".lower(),
107         "차단된 인스턴스".lower(),
108         "Peladen Yang Diblokir".lower(),
109         "Blokerede servere".lower(),
110         "Blokitaj nodoj".lower(),
111         "Блокирани Инстанции".lower(),
112         "Blockierte Instanzen".lower(),
113         "Estetyt instanssit".lower(),
114         "Instâncias bloqueadas".lower(),
115         "Zablokowane instancje".lower(),
116         "Blokované inštancie".lower(),
117         "المثلاء المحجوبون".lower(),
118         "Užblokuoti serveriai".lower(),
119         "ブロックしたインスタンス".lower(),
120         "Блокированные Инстансы".lower(),
121         "Αποκλεισμένοι διακομιστές".lower(),
122         "封鎖站台".lower(),
123         "Instâncias bloqueadas".lower(),
124     ]
125
126     blocklist = list()
127
128     try:
129         # json endpoint for newer mastodongs
130         logger.debug("Fetching /instances from domain='%s'", domain)
131         response = network.fetch_response(
132             domain,
133             "/instances",
134             network.web_headers,
135             (config.get("connection_timeout"), config.get("read_timeout"))
136         )
137
138         logger.debug("response.ok='%s',response.status_code=%d,response.text()=%d", response.ok, response.status_code, len(response.text))
139         if response.ok and response.status_code == 200 and response.text != "":
140             logger.debug("Parsing %s Bytes ...", len(response.text))
141
142             doc = bs4.BeautifulSoup(response.text, "html.parser")
143             logger.debug("doc[]='%s'", type(doc))
144
145             found = None
146             for criteria in [{"class": "home-instances container-lg"}, {"class": "container"}]:
147                 logger.debug("criteria='%s'", criteria)
148                 containers = doc.findAll("div", criteria)
149
150                 logger.debug("Checking %d containers ...", len(containers))
151                 for container in containers:
152                     logger.debug("container[]='%s'", type(container))
153                     for header in container.find_all(["h2", "h3", "h4", "h5"]):
154                         content = header
155                         logger.debug("header[%s]='%s' - BEFORE!", type(header), header)
156                         if header is not None:
157                             content = str(header.contents[0])
158                         logger.debug("content[%s]='%s' - AFTER!", type(content), content)
159
160                         if content is None:
161                             logger.debug("domain='%s' has returned empty header='%s' - SKIPPED!", domain, header)
162                             continue
163                         elif not isinstance(content, str):
164                             logger.debug("content[]='%s' is not supported/wanted type 'str' - SKIPPED!", type(content))
165                             continue
166                         elif content.lower() in translations:
167                             logger.debug("Found header='%s' with blocked instances - BREAK(3) !", header)
168                             found = header
169                             break
170
171                     logger.debug("found[]='%s'", type(found))
172                     if found is not None:
173                         logger.debug("Found header with blocked instances - BREAK(2) !")
174                         break
175
176                 logger.debug("found[]='%s'", type(found))
177                 if found is not None:
178                     logger.debug("Found header with blocked instances - BREAK(1) !")
179                     break
180
181             logger.debug("found[]='%s'", type(found))
182             if found is None:
183                 logger.info("domain='%s' has no HTML blocklist, checking scripts ...", domain)
184                 peers = parse_script(doc, "blocked")
185
186                 logger.debug("domain='%s' has %d peer(s).", domain, len(peers))
187                 for blocked in peers:
188                     logger.debug("Appending blocker='%s',blocked='%s',block_level='reject' ...", domain, blocked)
189                     blocklist.append({
190                         "blocker"    : domain,
191                         "blocked"    : blocked,
192                         "reason"     : None,
193                         "block_level": "reject",
194                     })
195
196                 logger.debug("blocklist()=%d - EXIT!", len(blocklist))
197                 return blocklist
198
199             blocking = found.find_next(["ul", "table"]).findAll("a")
200             logger.debug("Found %d blocked instance(s) ...", len(blocking))
201             for tag in blocking:
202                 logger.debug("tag[]='%s'", type(tag))
203                 blocked = tidyup.domain(tag.contents[0])
204                 logger.debug("blocked='%s'", blocked)
205
206                 if blocked == "":
207                     logger.warning("blocked='%s' is empty after tidyup.domain() - SKIPPED!", tag.contents[0])
208                     continue
209                 elif not domain_helper.is_wanted(blocked):
210                     logger.debug("blocked='%s' is not wanted - SKIPPED!", blocked)
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 not domain_helper.is_wanted(peer):
274                             logger.debug("peer='%s' is not wanted - SKIPPED!", peer)
275                             continue
276                         elif peer in peers:
277                             logger.debug("peer='%s' already added - SKIPPED!", peer)
278                             continue
279
280                         logger.debug("Appending peer='%s' ...", peer)
281                         peers.append(peer)
282
283             logger.debug("peers()=%d", len(peers))
284             if len(peers) == 0:
285                 logger.debug("Found no peers for domain='%s', trying script tag ...", domain)
286                 peers = parse_script(doc)
287         else:
288             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)
289             instances.set_last_error(domain, response)
290
291         logger.debug("Marking domain='%s' as successfully handled, peers()=%d ...", domain, len(peers))
292         instances.set_success(domain)
293
294     except network.exceptions as exception:
295         logger.warning("domain='%s',exception[%s]:'%s'", domain, type(exception), str(exception))
296         instances.set_last_error(domain, exception)
297
298     logger.debug("peers()=%d - EXIT!", len(peers))
299     return peers
300
301 def parse_script(doc: bs4.BeautifulSoup, only: str = None) -> list:
302     logger.debug("doc[]='%s',only='%s' - CALLED!")
303
304     if not isinstance(doc, bs4.BeautifulSoup):
305         raise ValueError(f"Parameter doc[]='{type(only)}' is not of type 'bs4.BeautifulSoup'")
306     elif not isinstance(only, str) and only is not None:
307         raise ValueError(f"Parameter only[]='{type(only)}' is not of type 'str'")
308     elif isinstance(only, str) and only == "":
309         raise ValueError("Parameter 'only' is empty")
310
311     scripts = doc.find_all("script")
312     peers = list()
313
314     logger.debug("scripts()=%d", len(scripts))
315     for script in scripts:
316         logger.debug("script[%s].contents()=%d", type(script), len(script.contents))
317         if len(script.contents) == 0:
318             logger.debug("script has no contents - SKIPPED!")
319             continue
320         elif not script.contents[0].startswith("window.isoData"):
321             logger.debug("script.contents[0]='%s' does not start with window.isoData - SKIPPED!", script.contents[0])
322             continue
323
324         logger.debug("script.contents[0][]='%s'", type(script.contents[0]))
325
326         iso_data = script.contents[0].split("=")[1].strip().replace(":undefined", ":\"undefined\"")
327         logger.debug("iso_data[%s]='%s'", type(iso_data), iso_data)
328
329         parsed = None
330         try:
331             parsed = json.loads(iso_data)
332         except json.decoder.JSONDecodeError as exception:
333             logger.warning("Exception '%s' during parsing %d Bytes: '%s' - EXIT!", type(exception), len(iso_data), str(exception))
334             return list()
335
336         logger.debug("parsed[%s]()=%d", type(parsed), len(parsed))
337
338         if "routeData" not in parsed:
339             logger.warning("parsed[%s]()=%d does not contain element 'routeData'", type(parsed), len(parsed))
340             continue
341         elif "federatedInstancesResponse" not in parsed["routeData"]:
342             logger.warning("parsed[routeData][%s]()=%d does not contain element 'federatedInstancesResponse'", type(parsed["routeData"]), len(parsed["routeData"]))
343             continue
344         elif "data" not in parsed["routeData"]["federatedInstancesResponse"]:
345             logger.warning("parsed[routeData][federatedInstancesResponse][%s]()=%d does not contain element 'data'", type(parsed["routeData"]["federatedInstancesResponse"]), len(parsed["routeData"]["federatedInstancesResponse"]))
346             continue
347         elif "federated_instances" not in parsed["routeData"]["federatedInstancesResponse"]["data"]:
348             logger.warning("parsed[routeData][federatedInstancesResponse][data][%s]()=%d does not contain element 'data'", type(parsed["routeData"]["federatedInstancesResponse"]["data"]), len(parsed["routeData"]["federatedInstancesResponse"]["data"]))
349             continue
350
351         data = parsed["routeData"]["federatedInstancesResponse"]["data"]["federated_instances"]
352         logger.debug("Checking %d data elements ...", len(data))
353         for element in data:
354             logger.debug("element='%s'", element)
355             if isinstance(only, str) and only != element:
356                 logger.debug("Skipping unwanted element='%s',only='%s'", element, only)
357                 continue
358
359             logger.debug("Checking data[%s]()=%d row(s) ...", element, len(data[element]))
360             for row in data[element]:
361                 logger.debug("row[]='%s'", type(row))
362                 if "domain" not in row:
363                     logger.warning("row()=%d has no element 'domain' - SKIPPED!", len(row))
364                     continue
365
366                 logger.debug("row[domain]='%s' - BEFORE!", row["domain"])
367                 peer = tidyup.domain(row["domain"])
368                 logger.debug("peer='%s' - AFTER!", peer)
369
370                 if peer == "":
371                     logger.debug("peer is empty - SKIPPED!")
372                     continue
373                 elif not domain_helper.is_wanted(peer):
374                     logger.debug("peer='%s' is not wanted - SKIPPED!", peer)
375                 elif peer in peers:
376                     logger.debug("peer='%s' already added - SKIPPED!", peer)
377                     continue
378
379                 logger.debug("Appending peer='%s' ...", peer)
380                 peers.append(peer)
381
382     logger.debug("peers()=%d - EXIT!", len(peers))
383     return peers