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