]> git.mxchange.org Git - fba.git/blob - fba/networks/lemmy.py
ebbe9a1bb877d9cd1520307793d4050e8effb0a3
[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 or 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                         logger.debug("text='%s' - BEFORE!", text)
268
269                         peer = tidyup.domain(text) if text != "" else None
270                         logger.debug("peer='%s' - AFTER", peer)
271
272                         if peer is None or peer == "":
273                             logger.warning("peer='%s' is empty, text='%s' - SKIPPED!", peer, text)
274                             continue
275                         elif not domain_helper.is_wanted(peer):
276                             logger.debug("peer='%s' is not wanted - SKIPPED!", peer)
277                             continue
278                         elif peer in peers:
279                             logger.debug("peer='%s' already added - SKIPPED!", peer)
280                             continue
281
282                         logger.debug("Appending peer='%s' ...", peer)
283                         peers.append(peer)
284
285             logger.debug("peers()=%d", len(peers))
286             if len(peers) == 0:
287                 logger.debug("Found no peers for domain='%s', trying script tag ...", domain)
288                 peers = parse_script(doc)
289         else:
290             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)
291             instances.set_last_error(domain, response)
292
293         logger.debug("Marking domain='%s' as successfully handled, peers()=%d ...", domain, len(peers))
294         instances.set_success(domain)
295
296     except network.exceptions as exception:
297         logger.warning("domain='%s',exception[%s]:'%s'", domain, type(exception), str(exception))
298         instances.set_last_error(domain, exception)
299
300     logger.debug("peers()=%d - EXIT!", len(peers))
301     return peers
302
303 def parse_script(doc: bs4.BeautifulSoup, only: str = None) -> list:
304     logger.debug("doc[]='%s',only='%s' - CALLED!")
305
306     if not isinstance(doc, bs4.BeautifulSoup):
307         raise ValueError(f"Parameter doc[]='{type(only)}' is not of type 'bs4.BeautifulSoup'")
308     elif not isinstance(only, str) and only is not None:
309         raise ValueError(f"Parameter only[]='{type(only)}' is not of type 'str'")
310     elif isinstance(only, str) and only == "":
311         raise ValueError("Parameter 'only' is empty")
312
313     scripts = doc.find_all("script")
314     peers = list()
315
316     logger.debug("scripts()=%d", len(scripts))
317     for script in scripts:
318         logger.debug("script[%s].contents()=%d", type(script), len(script.contents))
319         if len(script.contents) == 0:
320             logger.debug("script has no contents - SKIPPED!")
321             continue
322         elif not script.contents[0].startswith("window.isoData"):
323             logger.debug("script.contents[0]='%s' does not start with window.isoData - SKIPPED!", script.contents[0])
324             continue
325
326         logger.debug("script.contents[0][]='%s'", type(script.contents[0]))
327
328         iso_data = script.contents[0].split("=")[1].strip().replace(":undefined", ":\"undefined\"")
329         logger.debug("iso_data[%s]='%s'", type(iso_data), iso_data)
330
331         parsed = None
332         try:
333             parsed = json.loads(iso_data)
334         except json.decoder.JSONDecodeError as exception:
335             logger.warning("Exception '%s' during parsing %d Bytes: '%s' - EXIT!", type(exception), len(iso_data), str(exception))
336             return list()
337
338         logger.debug("parsed[%s]()=%d", type(parsed), len(parsed))
339
340         if "routeData" not in parsed:
341             logger.warning("parsed[%s]()=%d does not contain element 'routeData'", type(parsed), len(parsed))
342             continue
343         elif "federatedInstancesResponse" not in parsed["routeData"]:
344             logger.warning("parsed[routeData][%s]()=%d does not contain element 'federatedInstancesResponse'", type(parsed["routeData"]), len(parsed["routeData"]))
345             continue
346         elif "data" not in parsed["routeData"]["federatedInstancesResponse"]:
347             logger.warning("parsed[routeData][federatedInstancesResponse][%s]()=%d does not contain element 'data'", type(parsed["routeData"]["federatedInstancesResponse"]), len(parsed["routeData"]["federatedInstancesResponse"]))
348             continue
349         elif "federated_instances" not in parsed["routeData"]["federatedInstancesResponse"]["data"]:
350             logger.warning("parsed[routeData][federatedInstancesResponse][data][%s]()=%d does not contain element 'data'", type(parsed["routeData"]["federatedInstancesResponse"]["data"]), len(parsed["routeData"]["federatedInstancesResponse"]["data"]))
351             continue
352
353         data = parsed["routeData"]["federatedInstancesResponse"]["data"]["federated_instances"]
354         logger.debug("Checking %d data elements ...", len(data))
355         for element in data:
356             logger.debug("element='%s'", element)
357             if isinstance(only, str) and only != element:
358                 logger.debug("Skipping unwanted element='%s',only='%s'", element, only)
359                 continue
360
361             logger.debug("Checking data[%s]()=%d row(s) ...", element, len(data[element]))
362             for row in data[element]:
363                 logger.debug("row[]='%s'", type(row))
364                 if "domain" not in row:
365                     logger.warning("row()=%d has no element 'domain' - SKIPPED!", len(row))
366                     continue
367
368                 logger.debug("row[domain]='%s' - BEFORE!", row["domain"])
369                 peer = tidyup.domain(row["domain"])
370                 logger.debug("peer='%s' - AFTER!", peer)
371
372                 if peer is None or peer == "":
373                     logger.warning("peer='%s' is empty, row[domain]='%s' - SKIPPED!", peer, row["domain"])
374                     continue
375                 elif not domain_helper.is_wanted(peer):
376                     logger.debug("peer='%s' is not wanted - SKIPPED!", peer)
377                 elif peer in peers:
378                     logger.debug("peer='%s' already added - SKIPPED!", peer)
379                     continue
380
381                 logger.debug("Appending peer='%s' ...", peer)
382                 peers.append(peer)
383
384     logger.debug("peers()=%d - EXIT!", len(peers))
385     return peers