]> git.mxchange.org Git - fba.git/blob - fba/networks/lemmy.py
0b22f53399f83ba29502038c61112e28d856ba9d
[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 criteria in [{"class": "home-instances container-lg"}, {"class": "container"}]:
149                 logger.debug("criteria='%s'", criteria)
150                 containers = doc.findAll("div", criteria)
151
152                 logger.debug("Checking %d containers ...", len(containers))
153                 for container in containers:
154                     logger.debug("container[]='%s'", type(container))
155                     for header in container.find_all(["h2", "h3", "h4", "h5"]): 
156                         content = header
157                         logger.debug("header[%s]='%s' - BEFORE!", type(header), header)
158                         if header is not None:
159                             content = str(header.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='%s' with blocked instances - BREAK(3) !", header)
170                             found = header
171                             break
172
173                     logger.debug("found[]='%s'", type(found))
174                     if found is not None:
175                         logger.debug("Found header with blocked instances - BREAK(2) !")
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(1) !")
181                     break
182
183             logger.debug("found[]='%s'", type(found))
184             if found is None:
185                 logger.info("domain='%s' has no HTML blocklist, checking scripts ...", domain)
186                 peers = parse_script(doc, "blocked")
187
188                 logger.debug("domain='%s' has %d peer(s).", domain, len(peers))
189                 for blocked in peers:
190                     logger.debug("Appending blocker='%s',blocked='%s',block_level='reject' ...", domain, blocked)
191                     blocklist.append({
192                         "blocker"    : domain,
193                         "blocked"    : blocked,
194                         "reason"     : None,
195                         "block_level": "reject",
196                     })
197
198                 logger.debug("blocklist()=%d - EXIT!", len(blocklist))
199                 return blocklist
200
201             blocking = found.find_next(["ul", "table"]).findAll("a")
202             logger.debug("Found %d blocked instance(s) ...", len(blocking))
203             for tag in blocking:
204                 logger.debug("tag[]='%s'", type(tag))
205                 blocked = tidyup.domain(tag.contents[0])
206                 logger.debug("blocked='%s'", blocked)
207
208                 if blocked == "":
209                     logger.warning("blocked='%s' is empty after tidyup.domain() - SKIPPED!", tag.contents[0])
210                     continue
211                 elif not utils.is_domain_wanted(blocked):
212                     logger.debug("blocked='%s' is not wanted - SKIPPED!", blocked)
213                     continue
214
215                 logger.debug("Appending blocker='%s',blocked='%s',block_level='reject' ...", domain, blocked)
216                 blocklist.append({
217                     "blocker"    : domain,
218                     "blocked"    : blocked,
219                     "reason"     : None,
220                     "block_level": "reject",
221                 })
222
223     except network.exceptions as exception:
224         logger.warning("domain='%s',exception[%s]:'%s'", domain, type(exception), str(exception))
225         instances.set_last_error(domain, exception)
226
227     logger.debug("blocklist()=%d - EXIT!", len(blocklist))
228     return blocklist
229
230 def fetch_instances(domain: str, origin: str) -> list:
231     logger.debug("domain='%s',origin='%s' - CALLED!", domain, origin)
232     domain_helper.raise_on(domain)
233
234     peers = list()
235
236     try:
237         # json endpoint for newer mastodongs
238         logger.debug("Fetching /instances from domain='%s'", domain)
239         response = network.fetch_response(
240             domain,
241             "/instances",
242             network.web_headers,
243             (config.get("connection_timeout"), config.get("read_timeout"))
244         )
245
246         logger.debug("response.ok='%s',response.status_code=%d,response.text()=%d", response.ok, response.status_code, len(response.text))
247         if response.ok and response.status_code < 300 and response.text != "":
248             logger.debug("Parsing %s Bytes ...", len(response.text))
249
250             doc = bs4.BeautifulSoup(response.text, "html.parser")
251             logger.debug("doc[]='%s'", type(doc))
252
253             for criteria in [{"class": "home-instances container-lg"}, {"class": "container"}]:
254                 logger.debug("criteria='%s'", criteria)
255                 containers = doc.findAll("div", criteria)
256
257                 logger.debug("Checking %d containers ...", len(containers))
258                 for header in containers:
259                     logger.debug("header[%s]='%s'", type(header), header)
260
261                     rows = header.find_next(["ul","table"]).findAll("a")
262                     logger.debug("Found %d instance(s) ...", len(rows))
263                     for tag in rows:
264                         logger.debug("tag[]='%s'", type(tag))
265                         text = tag.contents[0] if isinstance(tag.contents[0], str) else tag.contents[0].text
266                         peer = tidyup.domain(text)
267                         logger.debug("peer='%s'", peer)
268
269                         if peer == "":
270                             logger.debug("peer is empty - SKIPPED!")
271                             continue
272                         elif not utils.is_domain_wanted(peer):
273                             logger.debug("peer='%s' is not wanted - SKIPPED!", peer)
274                             continue
275                         elif peer in peers:
276                             logger.debug("peer='%s' already added - SKIPPED!", peer)
277                             continue
278
279                         logger.debug("Appending peer='%s' ...", peer)
280                         peers.append(peer)
281
282             logger.debug("peers()=%d", len(peers))
283             if len(peers) == 0:
284                 logger.debug("Found no peers for domain='%s', trying script tag ...", domain)
285                 peers = parse_script(doc)
286
287         logger.debug("Marking domain='%s' as successfully handled, peers()=%d ...", domain, len(peers))
288         instances.set_success(domain)
289
290     except network.exceptions as exception:
291         logger.warning("domain='%s',exception[%s]:'%s'", domain, type(exception), str(exception))
292         instances.set_last_error(domain, exception)
293
294     logger.debug("peers()=%d - EXIT!", len(peers))
295     return peers
296
297 def parse_script(doc: bs4.BeautifulSoup, only: str = None) -> list:
298     logger.debug("doc[]='%s',only='%s' - CALLED!")
299     if not isinstance(doc, bs4.BeautifulSoup):
300         raise ValueError(f"Parameter doc[]='{type(only)}' is not of type 'bs4.BeautifulSoup'")
301     elif not isinstance(only, str) and only is not None:
302         raise ValueError(f"Parameter only[]='{type(only)}' is not of type 'str'")
303     elif isinstance(only, str) and only == "":
304         raise ValueError("Parameter 'only' is empty")
305
306     scripts = doc.find_all("script")
307     peers = list()
308
309     logger.debug("scripts()=%d", len(scripts))
310     for script in scripts:
311         logger.debug("script[%s].contents()=%d", type(script), len(script.contents))
312         if len(script.contents) == 0:
313             logger.debug("script has no contents - SKIPPED!")
314             continue
315         elif not script.contents[0].startswith("window.isoData"):
316             logger.debug("script.contents[0]='%s' does not start with window.isoData - SKIPPED!", script.contents[0])
317             continue
318
319         logger.debug("script.contents[0][]='%s'", type(script.contents[0]))
320
321         iso_data = script.contents[0].split("=")[1].strip().replace(":undefined", ":\"undefined\"")
322         logger.debug("iso_data[%s]='%s'", type(iso_data), iso_data)
323
324         parsed = None
325         try:
326             parsed = json.loads(iso_data)
327         except json.decoder.JSONDecodeError as exception:
328             logger.warning("Exception '%s' during parsing %d Bytes: '%s'", type(exception), len(iso_data), str(exception))
329             return list()
330
331         logger.debug("parsed[%s]()=%d", type(parsed), len(parsed))
332
333         if "routeData" not in parsed:
334             logger.warning("parsed[%s]()=%d does not contain element 'routeData'", type(parsed), len(parsed))
335             continue
336         elif "federatedInstancesResponse" not in parsed["routeData"]:
337             logger.warning("parsed[routeData][%s]()=%d does not contain element 'federatedInstancesResponse'", type(parsed["routeData"]), len(parsed["routeData"]))
338             continue
339         elif "data" not in parsed["routeData"]["federatedInstancesResponse"]:
340             logger.warning("parsed[routeData][federatedInstancesResponse][%s]()=%d does not contain element 'data'", type(parsed["routeData"]["federatedInstancesResponse"]), len(parsed["routeData"]["federatedInstancesResponse"]))
341             continue
342         elif "federated_instances" not in parsed["routeData"]["federatedInstancesResponse"]["data"]:
343             logger.warning("parsed[routeData][federatedInstancesResponse][data][%s]()=%d does not contain element 'data'", type(parsed["routeData"]["federatedInstancesResponse"]["data"]), len(parsed["routeData"]["federatedInstancesResponse"]["data"]))
344             continue
345
346         data = parsed["routeData"]["federatedInstancesResponse"]["data"]["federated_instances"]
347         logger.debug("Checking %d data elements ...", len(data))
348         for element in data:
349             logger.debug("element='%s'", element)
350             if isinstance(only, str) and only != element:
351                 logger.debug("Skipping unwanted element='%s',only='%s'", element, only)
352                 continue
353
354             logger.debug("Checking data[%s]()=%d row(s) ...", element, len(data[element]))
355             for row in data[element]:
356                 logger.debug("row[]='%s'", type(row))
357                 if "domain" not in row:
358                     logger.warning("row()=%d has no element 'domain' - SKIPPED!", len(row))
359                     continue
360
361                 logger.debug("row[domain]='%s' - BEFORE!", row["domain"])
362                 peer = tidyup.domain(row["domain"])
363                 logger.debug("peer='%s' - AFTER!", peer)
364
365                 if peer == "":
366                     logger.debug("peer is empty - SKIPPED!")
367                     continue
368                 elif not utils.is_domain_wanted(peer):
369                     logger.debug("peer='%s' is not wanted - SKIPPED!", peer)
370                     continue
371                 elif peer in peers:
372                     logger.debug("peer='%s' already added - SKIPPED!", peer)
373                     continue
374
375                 logger.debug("Appending peer='%s' ...", peer)
376                 peers.append(peer)
377
378     logger.debug("peers()=%d - EXIT!", len(peers))
379     return peers