]> git.mxchange.org Git - fba.git/blob - fba/fba.py
d4df7c8037334ae26e593279a43b679b66eed26a
[fba.git] / fba / fba.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 bs4
18 import hashlib
19 import re
20 import reqto
21 import requests
22 import json
23 import sqlite3
24 import sys
25 import time
26 import urllib
27 import validators
28
29 from fba import cache
30 from fba import config
31 from fba import instances
32
33 # Don't check these, known trolls/flooders/testing/developing
34 blacklist = [
35     # Floods network with fake nodes as "research" project
36     "activitypub-troll.cf",
37     # Similar troll
38     "gab.best",
39     # Similar troll
40     "4chan.icu",
41     # Flooder (?)
42     "social.shrimpcam.pw",
43     # Flooder (?)
44     "mastotroll.netz.org",
45     # Testing/developing installations
46     "ngrok.io",
47     "ngrok-free.app",
48     "misskeytest.chn.moe",
49 ]
50
51 # Array with pending errors needed to be written to database
52 pending_errors = {
53 }
54
55 # "rel" identifiers (no real URLs)
56 nodeinfo_identifier = [
57     "https://nodeinfo.diaspora.software/ns/schema/2.1",
58     "https://nodeinfo.diaspora.software/ns/schema/2.0",
59     "https://nodeinfo.diaspora.software/ns/schema/1.1",
60     "https://nodeinfo.diaspora.software/ns/schema/1.0",
61     "http://nodeinfo.diaspora.software/ns/schema/2.1",
62     "http://nodeinfo.diaspora.software/ns/schema/2.0",
63     "http://nodeinfo.diaspora.software/ns/schema/1.1",
64     "http://nodeinfo.diaspora.software/ns/schema/1.0",
65 ]
66
67 # HTTP headers for non-API requests
68 headers = {
69     "User-Agent": config.get("useragent"),
70 }
71
72 # HTTP headers for API requests
73 api_headers = {
74     "User-Agent": config.get("useragent"),
75     "Content-Type": "application/json",
76 }
77
78 language_mapping = {
79     # English -> English
80     "Silenced instances"            : "Silenced servers",
81     "Suspended instances"           : "Suspended servers",
82     "Limited instances"             : "Limited servers",
83     # Mappuing German -> English
84     "Gesperrte Server"              : "Suspended servers",
85     "Gefilterte Medien"             : "Filtered media",
86     "Stummgeschaltete Server"       : "Silenced servers",
87     # Japanese -> English
88     "停止済みのサーバー"            : "Suspended servers",
89     "制限中のサーバー"              : "Limited servers",
90     "メディアを拒否しているサーバー": "Filtered media",
91     "サイレンス済みのサーバー"      : "Silenced servers",
92     # ??? -> English
93     "שרתים מושעים"                  : "Suspended servers",
94     "מדיה מסוננת"                   : "Filtered media",
95     "שרתים מוגבלים"                 : "Silenced servers",
96     # French -> English
97     "Serveurs suspendus"            : "Suspended servers",
98     "Médias filtrés"                : "Filtered media",
99     "Serveurs limités"              : "Limited servers",
100     "Serveurs modérés"              : "Limited servers",
101 }
102
103 # URL for fetching peers
104 get_peers_url = "/api/v1/instance/peers"
105
106 # Connect to database
107 connection = sqlite3.connect("blocks.db")
108 cursor = connection.cursor()
109
110 # Pattern instance for version numbers
111 patterns = [
112     # semantic version number (with v|V) prefix)
113     re.compile("^(?P<version>v|V{0,1})(\.{0,1})(?P<major>0|[1-9]\d*)\.(?P<minor>0+|[1-9]\d*)(\.(?P<patch>0+|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)?$"),
114     # non-sematic, e.g. 1.2.3.4
115     re.compile("^(?P<version>v|V{0,1})(\.{0,1})(?P<major>0|[1-9]\d*)\.(?P<minor>0+|[1-9]\d*)(\.(?P<patch>0+|[1-9]\d*)(\.(?P<subpatch>0|[1-9]\d*))?)$"),
116     # non-sematic, e.g. 2023-05[-dev]
117     re.compile("^(?P<year>[1-9]{1}[0-9]{3})\.(?P<month>[0-9]{2})(-dev){0,1}$"),
118     # non-semantic, e.g. abcdef0
119     re.compile("^[a-f0-9]{7}$"),
120 ]
121
122 ##### Other functions #####
123
124 def is_primitive(var: any) -> bool:
125     # NOISY-DEBUG: # DEBUG: print(f"DEBUG: var[]='{type(var)}' - CALLED!")
126     return type(var) in {int, str, float, bool} or var == None
127
128 def fetch_instances(domain: str, origin: str, software: str, script: str, path: str = None):
129     # DEBUG: print(f"DEBUG: domain={domain},origin={origin},software={software},path={path} - CALLED!")
130     if type(domain) != str:
131         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
132     elif domain == "":
133         raise ValueError(f"Parameter 'domain' cannot be empty")
134     elif type(origin) != str and origin != None:
135         raise ValueError(f"Parameter origin[]={type(origin)} is not 'str'")
136     elif type(script) != str:
137         raise ValueError(f"Parameter script[]={type(script)} is not 'str'")
138     elif domain == "":
139         raise ValueError(f"Parameter 'domain' cannot be empty")
140
141     if not is_instance_registered(domain):
142         # DEBUG: print("DEBUG: Adding new domain:", domain, origin)
143         add_instance(domain, origin, script, path)
144
145     # DEBUG: print("DEBUG: Fetching instances for domain:", domain, software)
146     peerlist = get_peers(domain, software)
147
148     if (peerlist is None):
149         print("ERROR: Cannot fetch peers:", domain)
150         return
151     elif instances.has_pending_instance_data(domain):
152         # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo data, flushing ...")
153         instances.update_instance_data(domain)
154
155     print(f"INFO: Checking {len(peerlist)} instances from {domain} ...")
156     for instance in peerlist:
157         if instance == None:
158             # Skip "None" types as tidup() cannot parse them
159             continue
160
161         # DEBUG: print(f"DEBUG: instance='{instance}' - BEFORE")
162         instance = tidyup_domain(instance)
163         # DEBUG: print(f"DEBUG: instance='{instance}' - AFTER")
164
165         if instance == "":
166             print("WARNING: Empty instance after tidyup_domain(), domain:", domain)
167             continue
168         elif not validators.domain(instance.split("/")[0]):
169             print(f"WARNING: Bad instance='{instance}' from domain='{domain}',origin='{origin}',software='{software}'")
170             continue
171         elif is_blacklisted(instance):
172             # DEBUG: print("DEBUG: instance is blacklisted:", instance)
173             continue
174
175         # DEBUG: print("DEBUG: Handling instance:", instance)
176         try:
177             if not is_instance_registered(instance):
178                 # DEBUG: print("DEBUG: Adding new instance:", instance, domain)
179                 add_instance(instance, domain, sys.argv[0])
180         except BaseException as e:
181             print(f"ERROR: instance='{instance}',exception[{type(e)}]:'{str(e)}'")
182             continue
183
184     # DEBUG: print("DEBUG: EXIT!")
185
186 def add_peers(rows: dict) -> list:
187     # DEBUG: print(f"DEBUG: rows()={len(rows)} - CALLED!")
188     peers = list()
189     for element in ["linked", "allowed", "blocked"]:
190         # DEBUG: print(f"DEBUG: Checking element='{element}'")
191         if element in rows and rows[element] != None:
192             # DEBUG: print(f"DEBUG: Adding {len(rows[element])} peer(s) to peers list ...")
193             for peer in rows[element]:
194                 # DEBUG: print(f"DEBUG: peer='{peer}' - BEFORE!")
195                 peer = tidyup_domain(peer)
196
197                 # DEBUG: print(f"DEBUG: peer='{peer}' - AFTER!")
198                 if is_blacklisted(peer):
199                     # DEBUG: print(f"DEBUG: peer='{peer}' is blacklisted, skipped!")
200                     continue
201
202                 # DEBUG: print(f"DEBUG: Adding peer='{peer}' ...")
203                 peers.append(peer)
204
205     # DEBUG: print(f"DEBUG: peers()={len(peers)} - EXIT!")
206     return peers
207
208 def remove_version(software: str) -> str:
209     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
210     if not "." in software and " " not in software:
211         print(f"WARNING: software='{software}' does not contain a version number.")
212         return software
213
214     temp = software
215     if ";" in software:
216         temp = software.split(";")[0]
217     elif "," in software:
218         temp = software.split(",")[0]
219     elif " - " in software:
220         temp = software.split(" - ")[0]
221
222     # DEBUG: print(f"DEBUG: software='{software}'")
223     version = None
224     if " " in software:
225         version = temp.split(" ")[-1]
226     elif "/" in software:
227         version = temp.split("/")[-1]
228     elif "-" in software:
229         version = temp.split("-")[-1]
230     else:
231         # DEBUG: print(f"DEBUG: Was not able to find common seperator, returning untouched software='{software}'")
232         return software
233
234     matches = None
235     match = None
236     # DEBUG: print(f"DEBUG: Checking {len(patterns)} patterns ...")
237     for pattern in patterns:
238         # Run match()
239         match = pattern.match(version)
240
241         # DEBUG: print(f"DEBUG: match[]={type(match)}")
242         if type(match) is re.Match:
243             break
244
245     # DEBUG: print(f"DEBUG: version[{type(version)}]='{version}',match='{match}'")
246     if type(match) is not re.Match:
247         print(f"WARNING: version='{version}' does not match regex, leaving software='{software}' untouched.")
248         return software
249
250     # DEBUG: print(f"DEBUG: Found valid version number: '{version}', removing it ...")
251     end = len(temp) - len(version) - 1
252
253     # DEBUG: print(f"DEBUG: end[{type(end)}]={end}")
254     software = temp[0:end].strip()
255     if " version" in software:
256         # DEBUG: print(f"DEBUG: software='{software}' contains word ' version'")
257         software = strip_until(software, " version")
258
259     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
260     return software
261
262 def strip_powered_by(software: str) -> str:
263     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
264     if software == "":
265         print(f"ERROR: Bad method call, 'software' is empty")
266         raise Exception("Parameter 'software' is empty")
267     elif not "powered by" in software:
268         print(f"WARNING: Cannot find 'powered by' in '{software}'!")
269         return software
270
271     start = software.find("powered by ")
272     # DEBUG: print(f"DEBUG: start[{type(start)}]='{start}'")
273
274     software = software[start + 11:].strip()
275     # DEBUG: print(f"DEBUG: software='{software}'")
276
277     software = strip_until(software, " - ")
278
279     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
280     return software
281
282 def strip_hosted_on(software: str) -> str:
283     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
284     if software == "":
285         print(f"ERROR: Bad method call, 'software' is empty")
286         raise Exception("Parameter 'software' is empty")
287     elif not "hosted on" in software:
288         print(f"WARNING: Cannot find 'hosted on' in '{software}'!")
289         return software
290
291     end = software.find("hosted on ")
292     # DEBUG: print(f"DEBUG: end[{type(end)}]='{end}'")
293
294     software = software[0, start].strip()
295     # DEBUG: print(f"DEBUG: software='{software}'")
296
297     software = strip_until(software, " - ")
298
299     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
300     return software
301
302 def strip_until(software: str, until: str) -> str:
303     # DEBUG: print(f"DEBUG: software='{software}',until='{until}' - CALLED!")
304     if software == "":
305         print(f"ERROR: Bad method call, 'software' is empty")
306         raise Exception("Parameter 'software' is empty")
307     elif until == "":
308         print(f"ERROR: Bad method call, 'until' is empty")
309         raise Exception("Parameter 'until' is empty")
310     elif not until in software:
311         print(f"WARNING: Cannot find '{until}' in '{software}'!")
312         return software
313
314     # Next, strip until part
315     end = software.find(until)
316
317     # DEBUG: print(f"DEBUG: end[{type(end)}]='{end}'")
318     if end > 0:
319         software = software[0:end].strip()
320
321     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
322     return software
323
324 def is_blacklisted(domain: str) -> bool:
325     if type(domain) != str:
326         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
327     elif domain == "":
328         raise ValueError(f"Parameter 'domain' cannot be empty")
329
330     blacklisted = False
331     for peer in blacklist:
332         if peer in domain:
333             blacklisted = True
334
335     return blacklisted
336
337 def remove_pending_error(domain: str):
338     if type(domain) != str:
339         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
340     elif domain == "":
341         raise ValueError(f"Parameter 'domain' cannot be empty")
342
343     try:
344         # Prevent updating any pending errors, nodeinfo was found
345         del pending_errors[domain]
346
347     except:
348         pass
349
350     # DEBUG: print("DEBUG: EXIT!")
351
352 def get_hash(domain: str) -> str:
353     if type(domain) != str:
354         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
355     elif domain == "":
356         raise ValueError(f"Parameter 'domain' cannot be empty")
357
358     return hashlib.sha256(domain.encode("utf-8")).hexdigest()
359
360 def update_last_blocked(domain: str):
361     if type(domain) != str:
362         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
363     elif domain == "":
364         raise ValueError(f"Parameter 'domain' cannot be empty")
365
366     # DEBUG: print("DEBUG: Updating last_blocked for domain", domain)
367     instances.set("last_blocked", domain, time.time())
368
369     # Running pending updated
370     # DEBUG: print(f"DEBUG: Invoking instances.update_instance_data({domain}) ...")
371     instances.update_instance_data(domain)
372
373     # DEBUG: print("DEBUG: EXIT!")
374
375 def log_error(domain: str, response: requests.models.Response):
376     # DEBUG: print("DEBUG: domain,response[]:", domain, type(response))
377     if type(domain) != str:
378         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
379     elif domain == "":
380         raise ValueError(f"Parameter 'domain' cannot be empty")
381
382     try:
383         # DEBUG: print("DEBUG: BEFORE response[]:", type(response))
384         if isinstance(response, BaseException) or isinstance(response, json.decoder.JSONDecodeError):
385             response = str(response)
386
387         # DEBUG: print("DEBUG: AFTER response[]:", type(response))
388         if type(response) is str:
389             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, 999, ?, ?)",[
390                 domain,
391                 response,
392                 time.time()
393             ])
394         else:
395             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, ?, ?, ?)",[
396                 domain,
397                 response.status_code,
398                 response.reason,
399                 time.time()
400             ])
401
402         # Cleanup old entries
403         # DEBUG: print(f"DEBUG: Purging old records (distance: {config.get('error_log_cleanup')})")
404         cursor.execute("DELETE FROM error_log WHERE created < ?", [time.time() - config.get("error_log_cleanup")])
405     except BaseException as e:
406         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
407         sys.exit(255)
408
409     # DEBUG: print("DEBUG: EXIT!")
410
411 def update_last_error(domain: str, response: requests.models.Response):
412     # DEBUG: print("DEBUG: domain,response[]:", domain, type(response))
413     if type(domain) != str:
414         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
415     elif domain == "":
416         raise ValueError(f"Parameter 'domain' cannot be empty")
417
418     # DEBUG: print("DEBUG: BEFORE response[]:", type(response))
419     if isinstance(response, BaseException) or isinstance(response, json.decoder.JSONDecodeError):
420         response = f"{type}:str(response)"
421
422     # DEBUG: print("DEBUG: AFTER response[]:", type(response))
423     if type(response) is str:
424         # DEBUG: print(f"DEBUG: Setting last_error_details='{response}'");
425         instances.set("last_status_code"  , domain, 999)
426         instances.set("last_error_details", domain, response)
427     else:
428         # DEBUG: print(f"DEBUG: Setting last_error_details='{response.reason}'");
429         instances.set("last_status_code"  , domain, response.status_code)
430         instances.set("last_error_details", domain, response.reason)
431
432     # Running pending updated
433     # DEBUG: print(f"DEBUG: Invoking instances.update_instance_data({domain}) ...")
434     instances.update_instance_data(domain)
435
436     log_error(domain, response)
437
438     # DEBUG: print("DEBUG: EXIT!")
439
440 def update_last_instance_fetch(domain: str):
441     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
442     if type(domain) != str:
443         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
444     elif domain == "":
445         raise ValueError(f"Parameter 'domain' cannot be empty")
446
447     # DEBUG: print("DEBUG: Updating last_instance_fetch for domain:", domain)
448     instances.set("last_instance_fetch", domain, time.time())
449
450     # Running pending updated
451     # DEBUG: print(f"DEBUG: Invoking instances.update_instance_data({domain}) ...")
452     instances.update_instance_data(domain)
453
454     # DEBUG: print("DEBUG: EXIT!")
455
456 def update_last_nodeinfo(domain: str):
457     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
458     if type(domain) != str:
459         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
460     elif domain == "":
461         raise ValueError(f"Parameter 'domain' cannot be empty")
462
463     # DEBUG: print("DEBUG: Updating last_nodeinfo for domain:", domain)
464     instances.set("last_nodeinfo", domain, time.time())
465     instances.set("last_updated" , domain, time.time())
466
467     # Running pending updated
468     # DEBUG: print(f"DEBUG: Invoking instances.update_instance_data({domain}) ...")
469     instances.update_instance_data(domain)
470
471     # DEBUG: print("DEBUG: EXIT!")
472
473 def get_peers(domain: str, software: str) -> list:
474     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},software={software} - CALLED!")
475     if type(domain) != str:
476         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
477     elif domain == "":
478         raise ValueError(f"Parameter 'domain' cannot be empty")
479     elif type(software) != str and software != None:
480         raise ValueError(f"software[]={type(software)} is not 'str'")
481
482     peers = list()
483
484     if software == "misskey":
485         # DEBUG: print(f"DEBUG: domain='{domain}' is misskey, sending API POST request ...")
486         offset = 0
487         step = config.get("misskey_limit")
488
489         # iterating through all "suspended" (follow-only in its terminology)
490         # instances page-by-page, since that troonware doesn't support
491         # sending them all at once
492         while True:
493             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
494             if offset == 0:
495                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
496                     "sort" : "+pubAt",
497                     "host" : None,
498                     "limit": step
499                 }), {
500                     "Origin": domain
501                 })
502             else:
503                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
504                     "sort"  : "+pubAt",
505                     "host"  : None,
506                     "limit" : step,
507                     "offset": offset - 1
508                 }), {
509                     "Origin": domain
510                 })
511
512             # DEBUG: print(f"DEBUG: fetched()={len(fetched)}")
513             if len(fetched) == 0:
514                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
515                 break
516             elif len(fetched) != config.get("misskey_limit"):
517                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config.get('misskey_limit')}'")
518                 offset = offset + (config.get("misskey_limit") - len(fetched))
519             else:
520                 # DEBUG: print("DEBUG: Raising offset by step:", step)
521                 offset = offset + step
522
523             # Check records
524             # DEBUG: print(f"DEBUG: fetched({len(fetched)})[]={type(fetched)}")
525             if isinstance(fetched, dict) and "error" in fetched and "message" in fetched["error"]:
526                 print(f"WARNING: post_json_api() returned error: {fetched['error']['message']}")
527                 update_last_error(domain, fetched["error"]["message"])
528                 break
529
530             already = 0
531             for row in fetched:
532                 # DEBUG: print(f"DEBUG: row():{len(row)}")
533                 if not "host" in row:
534                     print(f"WARNING: row()={len(row)} does not contain element 'host': {row},domain='{domain}'")
535                     continue
536                 elif type(row["host"]) != str:
537                     print(f"WARNING: row[host][]={type(row['host'])} is not 'str'")
538                     continue
539                 elif is_blacklisted(row["host"]):
540                     # DEBUG: print(f"DEBUG: row[host]='{row['host']}' is blacklisted. domain='{domain}'")
541                     continue
542                 elif row["host"] in peers:
543                     # DEBUG: print(f"DEBUG: Not adding row[host]='{row['host']}', already found.")
544                     already = already + 1
545                     continue
546
547                 # DEBUG: print(f"DEBUG: Adding peer: '{row['host']}'")
548                 peers.append(row["host"])
549
550             if already == len(fetched):
551                 print(f"WARNING: Host returned same set of '{already}' instances, aborting loop!")
552                 break
553
554         # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
555         instances.set("total_peers", domain, len(peers))
556
557         # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
558         update_last_instance_fetch(domain)
559
560         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
561         return peers
562     elif software == "lemmy":
563         # DEBUG: print(f"DEBUG: domain='{domain}' is Lemmy, fetching JSON ...")
564         try:
565             response = get_response(domain, "/api/v3/site", api_headers, (config.get("connection_timeout"), config.get("read_timeout")))
566
567             data = json_from_response(response)
568
569             # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code='{response.status_code}',data[]='{type(data)}'")
570             if not response.ok or response.status_code >= 400:
571                 print("WARNING: Could not reach any JSON API:", domain)
572                 update_last_error(domain, response)
573             elif response.ok and isinstance(data, list):
574                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
575                 sys.exit(255)
576             elif "federated_instances" in data:
577                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
578                 peers = peers + add_peers(data["federated_instances"])
579                 # DEBUG: print("DEBUG: Added instance(s) to peers")
580             else:
581                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
582                 update_last_error(domain, response)
583
584         except BaseException as e:
585             print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
586
587         # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
588         instances.set("total_peers", domain, len(peers))
589
590         # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
591         update_last_instance_fetch(domain)
592
593         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
594         return peers
595     elif software == "peertube":
596         # DEBUG: print(f"DEBUG: domain='{domain}' is a PeerTube, fetching JSON ...")
597
598         start = 0
599         for mode in ["followers", "following"]:
600             # DEBUG: print(f"DEBUG: domain='{domain}',mode='{mode}'")
601             while True:
602                 try:
603                     response = get_response(domain, "/api/v1/server/{mode}?start={start}&count=100", headers, (config.get("connection_timeout"), config.get("read_timeout")))
604
605                     data = json_from_response(response)
606                     # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code='{response.status_code}',data[]='{type(data)}'")
607                     if response.ok and isinstance(data, dict):
608                         # DEBUG: print("DEBUG: Success, data:", len(data))
609                         if "data" in data:
610                             # DEBUG: print(f"DEBUG: Found {len(data['data'])} record(s).")
611                             for record in data["data"]:
612                                 # DEBUG: print(f"DEBUG: record()={len(record)}")
613                                 if mode in record and "host" in record[mode]:
614                                     # DEBUG: print(f"DEBUG: Found host={record[mode]['host']}, adding ...")
615                                     peers.append(record[mode]["host"])
616                                 else:
617                                     print(f"WARNING: record from '{domain}' has no '{mode}' or 'host' record: {record}")
618
619                             if len(data["data"]) < 100:
620                                 # DEBUG: print("DEBUG: Reached end of JSON response:", domain)
621                                 break
622
623                         # Continue with next row
624                         start = start + 100
625
626                 except BaseException as e:
627                     print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
628
629         # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
630         instances.set("total_peers", domain, len(peers))
631
632         # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
633         update_last_instance_fetch(domain)
634
635         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
636         return peers
637
638     # DEBUG: print(f"DEBUG: Fetching get_peers_url='{get_peers_url}' from '{domain}' ...")
639     try:
640         response = get_response(domain, get_peers_url, api_headers, (config.get("connection_timeout"), config.get("read_timeout")))
641
642         data = json_from_response(response)
643
644         # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code={response.status_code},data[]='{type(data)}'")
645         if not response.ok or response.status_code >= 400:
646             # DEBUG: print(f"DEBUG: Was not able to fetch '{get_peers_url}', trying alternative ...")
647             response = get_response(domain, "/api/v3/site", api_headers, (config.get("connection_timeout"), config.get("read_timeout")))
648
649             data = json_from_response(response)
650             # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code={response.status_code},data[]='{type(data)}'")
651             if not response.ok or response.status_code >= 400:
652                 print("WARNING: Could not reach any JSON API:", domain)
653                 update_last_error(domain, response)
654             elif response.ok and isinstance(data, list):
655                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
656                 sys.exit(255)
657             elif "federated_instances" in data:
658                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
659                 peers = peers + add_peers(data["federated_instances"])
660                 # DEBUG: print("DEBUG: Added instance(s) to peers")
661             else:
662                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
663                 update_last_error(domain, response)
664         else:
665             # DEBUG: print("DEBUG: Querying API was successful:", domain, len(data))
666             peers = data
667
668     except BaseException as e:
669         print("WARNING: Some error during get():", domain, e)
670         update_last_error(domain, e)
671
672     # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
673     instances.set("total_peers", domain, len(peers))
674
675     # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
676     update_last_instance_fetch(domain)
677
678     # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
679     return peers
680
681 def post_json_api(domain: str, path: str, parameter: str, extra_headers: dict = {}) -> dict:
682     if type(domain) != str:
683         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
684     elif domain == "":
685         raise ValueError(f"Parameter 'domain' cannot be empty")
686     elif type(path) != str:
687         raise ValueError(f"path[]={type(path)} is not 'str'")
688     elif path == "":
689         raise ValueError("Parameter 'path' cannot be empty")
690     elif type(parameter) != str:
691         raise ValueError(f"parameter[]={type(parameter)} is not 'str'")
692
693     # DEBUG: print("DEBUG: Sending POST to domain,path,parameter:", domain, path, parameter, extra_headers)
694     data = {}
695     try:
696         response = reqto.post(
697             f"https://{domain}{path}",
698             data=parameter,
699             headers={**api_headers, **extra_headers},
700             timeout=(config.get("connection_timeout"), config.get("read_timeout"))
701         )
702
703         data = json_from_response(response)
704         # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code={response.status_code},data[]='{type(data)}'")
705         if not response.ok or response.status_code >= 400:
706             print(f"WARNING: Cannot query JSON API: domain='{domain}',path='{path}',parameter()={len(parameter)},response.status_code='{response.status_code}',data[]='{type(data)}'")
707             update_last_error(domain, response)
708
709     except BaseException as e:
710         print(f"WARNING: Some error during post(): domain='{domain}',path='{path}',parameter()={len(parameter)},exception[{type(e)}]:'{str(e)}'")
711
712     # DEBUG: print(f"DEBUG: Returning data({len(data)})=[]:{type(data)}")
713     return data
714
715 def fetch_nodeinfo(domain: str, path: str = None) -> list:
716     # DEBUG: print(f"DEBUG: domain='{domain}',path={path} - CALLED!")
717     if type(domain) != str:
718         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
719     elif domain == "":
720         raise ValueError(f"Parameter 'domain' cannot be empty")
721     elif type(path) != str and path != None:
722         raise ValueError(f"Parameter path[]={type(path)} is not 'str'")
723
724     # DEBUG: print(f"DEBUG: Fetching nodeinfo from domain='{domain}' ...")
725     nodeinfo = fetch_wellknown_nodeinfo(domain)
726
727     # DEBUG: print(f"DEBUG: nodeinfo({len(nodeinfo)})={nodeinfo}")
728     if len(nodeinfo) > 0:
729         # DEBUG: print("DEBUG: nodeinfo()={len(nodeinfo))} - EXIT!")
730         return nodeinfo
731
732     request_paths = [
733        "/nodeinfo/2.1.json",
734        "/nodeinfo/2.1",
735        "/nodeinfo/2.0.json",
736        "/nodeinfo/2.0",
737        "/nodeinfo/1.0",
738        "/api/v1/instance"
739     ]
740
741     data = {}
742     for request in request_paths:
743         if path != None and path != "" and path != f"https://{domain}{path}":
744             # DEBUG: print(f"DEBUG: path='{path}' does not match request='{request}' - SKIPPED!")
745             continue
746
747         try:
748             # DEBUG: print(f"DEBUG: Fetching request='{request}' from domain='{domain}' ...")
749             response = get_response(domain, request, api_headers, (config.get("nodeinfo_connection_timeout"), config.get("nodeinfo_read_timeout")))
750
751             data = json_from_response(response)
752             # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code={response.status_code},data[]='{type(data)}'")
753             if response.ok and isinstance(data, dict):
754                 # DEBUG: print("DEBUG: Success:", request)
755                 instances.set("detection_mode", domain, "STATIC_CHECK")
756                 instances.set("nodeinfo_url"  , domain, request)
757                 break
758             elif response.ok and isinstance(data, list):
759                 print(f"UNSUPPORTED: domain='{domain}' returned a list: '{data}'")
760                 sys.exit(255)
761             elif not response.ok or response.status_code >= 400:
762                 print("WARNING: Failed fetching nodeinfo from domain:", domain)
763                 update_last_error(domain, response)
764                 continue
765
766         except BaseException as e:
767             # DEBUG: print("DEBUG: Cannot fetch API request:", request)
768             update_last_error(domain, e)
769             pass
770
771     # DEBUG: print(f"DEBUG: data()={len(data)} - EXIT!")
772     return data
773
774 def fetch_wellknown_nodeinfo(domain: str) -> list:
775     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
776     if type(domain) != str:
777         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
778     elif domain == "":
779         raise ValueError(f"Parameter 'domain' cannot be empty")
780
781     # DEBUG: print("DEBUG: Fetching .well-known info for domain:", domain)
782     data = {}
783
784     try:
785         response = get_response(domain, "/.well-known/nodeinfo", api_headers, (config.get("nodeinfo_connection_timeout"), config.get("nodeinfo_read_timeout")))
786
787         data = json_from_response(response)
788         # DEBUG: print("DEBUG: domain,response.ok,data[]:", domain, response.ok, type(data))
789         if response.ok and isinstance(data, dict):
790             nodeinfo = data
791             # DEBUG: print("DEBUG: Found entries:", len(nodeinfo), domain)
792             if "links" in nodeinfo:
793                 # DEBUG: print("DEBUG: Found links in nodeinfo():", len(nodeinfo["links"]))
794                 for link in nodeinfo["links"]:
795                     # DEBUG: print("DEBUG: rel,href:", link["rel"], link["href"])
796                     if link["rel"] in nodeinfo_identifier:
797                         # DEBUG: print("DEBUG: Fetching nodeinfo from:", link["href"])
798                         response = get_url(link["href"])
799
800                         data = json_from_response(response)
801                         # DEBUG: print("DEBUG: href,response.ok,response.status_code:", link["href"], response.ok, response.status_code)
802                         if response.ok and isinstance(data, dict):
803                             # DEBUG: print("DEBUG: Found JSON nodeinfo():", len(data))
804                             instances.set("detection_mode", domain, "AUTO_DISCOVERY")
805                             instances.set("nodeinfo_url"  , domain, link["href"])
806                             break
807                     else:
808                         print("WARNING: Unknown 'rel' value:", domain, link["rel"])
809             else:
810                 print("WARNING: nodeinfo does not contain 'links':", domain)
811
812     except BaseException as e:
813         print("WARNING: Failed fetching .well-known info:", domain)
814         update_last_error(domain, e)
815         pass
816
817     # DEBUG: print("DEBUG: Returning data[]:", type(data))
818     return data
819
820 def fetch_generator_from_path(domain: str, path: str = "/") -> str:
821     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},path={path} - CALLED!")
822     if type(domain) != str:
823         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
824     elif domain == "":
825         raise ValueError(f"Parameter 'domain' cannot be empty")
826     elif type(path) != str:
827         raise ValueError(f"path[]={type(path)} is not 'str'")
828     elif path == "":
829         raise ValueError(f"Parameter 'domain' cannot be empty")
830
831     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}' - CALLED!")
832     software = None
833
834     try:
835         # DEBUG: print(f"DEBUG: Fetching path='{path}' from '{domain}' ...")
836         response = get_response(domain, path, headers, (config.get("connection_timeout"), config.get("read_timeout")))
837
838         # DEBUG: print("DEBUG: domain,response.ok,response.status_code,response.text[]:", domain, response.ok, response.status_code, type(response.text))
839         if response.ok and response.status_code < 300 and len(response.text) > 0:
840             # DEBUG: print("DEBUG: Search for <meta name='generator'>:", domain)
841             doc = bs4.BeautifulSoup(response.text, "html.parser")
842
843             # DEBUG: print("DEBUG: doc[]:", type(doc))
844             generator = doc.find("meta", {"name": "generator"})
845             site_name = doc.find("meta", {"property": "og:site_name"})
846
847             # DEBUG: print(f"DEBUG: generator='{generator}',site_name='{site_name}'")
848             if isinstance(generator, bs4.element.Tag):
849                 # DEBUG: print("DEBUG: Found generator meta tag:", domain)
850                 software = tidyup_domain(generator.get("content"))
851                 print(f"INFO: domain='{domain}' is generated by '{software}'")
852                 instances.set("detection_mode", domain, "GENERATOR")
853                 remove_pending_error(domain)
854             elif isinstance(site_name, bs4.element.Tag):
855                 # DEBUG: print("DEBUG: Found property=og:site_name:", domain)
856                 sofware = tidyup_domain(site_name.get("content"))
857                 print(f"INFO: domain='{domain}' has og:site_name='{software}'")
858                 instances.set("detection_mode", domain, "SITE_NAME")
859                 remove_pending_error(domain)
860
861     except BaseException as e:
862         # DEBUG: print(f"DEBUG: Cannot fetch / from '{domain}':", e)
863         update_last_error(domain, e)
864         pass
865
866     # DEBUG: print(f"DEBUG: software[]={type(software)}")
867     if type(software) is str and software == "":
868         # DEBUG: print(f"DEBUG: Corrected empty string to None for software of domain='{domain}'")
869         software = None
870     elif type(software) is str and ("." in software or " " in software):
871         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
872         software = remove_version(software)
873
874     # DEBUG: print(f"DEBUG: software[]={type(software)}")
875     if type(software) is str and " powered by " in software:
876         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
877         software = remove_version(strip_powered_by(software))
878     elif type(software) is str and " hosted on " in software:
879         # DEBUG: print(f"DEBUG: software='{software}' has 'hosted on' in it")
880         software = remove_version(strip_hosted_on(software))
881     elif type(software) is str and " by " in software:
882         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
883         software = strip_until(software, " by ")
884     elif type(software) is str and " see " in software:
885         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
886         software = strip_until(software, " see ")
887
888     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
889     return software
890
891 def determine_software(domain: str, path: str = None) -> str:
892     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},path={path} - CALLED!")
893     if type(domain) != str:
894         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
895     elif domain == "":
896         raise ValueError(f"Parameter 'domain' cannot be empty")
897     elif type(path) != str and path != None:
898         raise ValueError(f"Parameter path[]={type(path)} is not 'str'")
899
900     # DEBUG: print("DEBUG: Determining software for domain,path:", domain, path)
901     software = None
902
903     # DEBUG: print(f"DEBUG: Fetching nodeinfo from '{domain}' ...")
904     data = fetch_nodeinfo(domain, path)
905
906     # DEBUG: print("DEBUG: data[]:", type(data))
907     if not isinstance(data, dict) or len(data) == 0:
908         # DEBUG: print("DEBUG: Could not determine software type:", domain)
909         return fetch_generator_from_path(domain)
910
911     # DEBUG: print("DEBUG: data():", len(data), data)
912     if "status" in data and data["status"] == "error" and "message" in data:
913         print("WARNING: JSON response is an error:", data["message"])
914         update_last_error(domain, data["message"])
915         return fetch_generator_from_path(domain)
916     elif "message" in data:
917         print("WARNING: JSON response contains only a message:", data["message"])
918         update_last_error(domain, data["message"])
919         return fetch_generator_from_path(domain)
920     elif "software" not in data or "name" not in data["software"]:
921         # DEBUG: print(f"DEBUG: JSON response from domain='{domain}' does not include [software][name], fetching / ...")
922         software = fetch_generator_from_path(domain)
923
924         # DEBUG: print(f"DEBUG: Generator for domain='{domain}' is: {software}, EXIT!")
925         return software
926
927     software = tidyup_domain(data["software"]["name"])
928
929     # DEBUG: print("DEBUG: sofware after tidyup_domain():", software)
930     if software in ["akkoma", "rebased"]:
931         # DEBUG: print("DEBUG: Setting pleroma:", domain, software)
932         software = "pleroma"
933     elif software in ["hometown", "ecko"]:
934         # DEBUG: print("DEBUG: Setting mastodon:", domain, software)
935         software = "mastodon"
936     elif software in ["calckey", "groundpolis", "foundkey", "cherrypick", "meisskey"]:
937         # DEBUG: print("DEBUG: Setting misskey:", domain, software)
938         software = "misskey"
939     elif software.find("/") > 0:
940         print("WARNING: Spliting of slash:", software)
941         software = software.split("/")[-1];
942     elif software.find("|") > 0:
943         print("WARNING: Spliting of pipe:", software)
944         software = tidyup_domain(software.split("|")[0]);
945     elif "powered by" in software:
946         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
947         software = strip_powered_by(software)
948     elif type(software) is str and " by " in software:
949         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
950         software = strip_until(software, " by ")
951     elif type(software) is str and " see " in software:
952         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
953         software = strip_until(software, " see ")
954
955     # DEBUG: print(f"DEBUG: software[]={type(software)}")
956     if software == "":
957         print("WARNING: tidyup_domain() left no software name behind:", domain)
958         software = None
959
960     # DEBUG: print(f"DEBUG: software[]={type(software)}")
961     if str(software) == "":
962         # DEBUG: print(f"DEBUG: software for '{domain}' was not detected, trying generator ...")
963         software = fetch_generator_from_path(domain)
964     elif len(str(software)) > 0 and ("." in software or " " in software):
965         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
966         software = remove_version(software)
967
968     # DEBUG: print(f"DEBUG: software[]={type(software)}")
969     if type(software) is str and "powered by" in software:
970         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
971         software = remove_version(strip_powered_by(software))
972
973     # DEBUG: print("DEBUG: Returning domain,software:", domain, software)
974     return software
975
976 def update_block_reason(reason: str, blocker: str, blocked: str, block_level: str):
977     # DEBUG: print(f"DEBUG: reason='{reason}',blocker={blocker},blocked={blocked},block_level={block_level} - CALLED!")
978     if type(reason) != str and reason != None:
979         raise ValueError(f"Parameter reason[]='{type(reason)}' is not 'str'")
980     elif type(blocker) != str:
981         raise ValueError(f"Parameter blocker[]='{type(blocker)}' is not 'str'")
982     elif type(blocked) != str:
983         raise ValueError(f"Parameter blocked[]='{type(blocked)}' is not 'str'")
984     elif type(block_level) != str:
985         raise ValueError(f"Parameter block_level[]='{type(block_level)}' is not 'str'")
986
987     # DEBUG: print("DEBUG: Updating block reason:", reason, blocker, blocked, block_level)
988     try:
989         cursor.execute(
990             "UPDATE blocks SET reason = ?, last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? AND reason IN ('','unknown') LIMIT 1",
991             (
992                 reason,
993                 time.time(),
994                 blocker,
995                 blocked,
996                 block_level
997             ),
998         )
999
1000         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
1001         if cursor.rowcount == 0:
1002             # DEBUG: print(f"DEBUG: Did not update any rows: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',reason='{reason}' - EXIT!")
1003             return
1004
1005     except BaseException as e:
1006         print(f"ERROR: failed SQL query: reason='{reason}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
1007         sys.exit(255)
1008
1009     # DEBUG: print("DEBUG: EXIT!")
1010
1011 def update_last_seen(blocker: str, blocked: str, block_level: str):
1012     # DEBUG: print("DEBUG: Updating last_seen for:", blocker, blocked, block_level)
1013     try:
1014         cursor.execute(
1015             "UPDATE blocks SET last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? LIMIT 1",
1016             (
1017                 time.time(),
1018                 blocker,
1019                 blocked,
1020                 block_level
1021             )
1022         )
1023
1024         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
1025         if cursor.rowcount == 0:
1026             # DEBUG: print(f"DEBUG: Did not update any rows: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}' - EXIT!")
1027             return
1028
1029     except BaseException as e:
1030         print(f"ERROR: failed SQL query: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
1031         sys.exit(255)
1032
1033     # DEBUG: print("DEBUG: EXIT!")
1034
1035 def is_instance_blocked(blocker: str, blocked: str, block_level: str) -> bool:
1036     # DEBUG: print(f"DEBUG: blocker={blocker},blocked={blocked},block_level={block_level} - CALLED!")
1037     if type(blocker) != str:
1038         raise ValueError(f"Parameter blocker[]={type(blocker)} is not of type 'str'")
1039     elif blocker == "":
1040         raise ValueError("Parameter 'blocker' cannot be empty")
1041     elif type(blocked) != str:
1042         raise ValueError(f"Parameter blocked[]={type(blocked)} is not of type 'str'")
1043     elif blocked == "":
1044         raise ValueError("Parameter 'blocked' cannot be empty")
1045     elif type(block_level) != str:
1046         raise ValueError(f"Parameter block_level[]={type(block_level)} is not of type 'str'")
1047     elif block_level == "":
1048         raise ValueError("Parameter 'block_level' cannot be empty")
1049
1050     cursor.execute(
1051         "SELECT * FROM blocks WHERE blocker = ? AND blocked = ? AND block_level = ? LIMIT 1",
1052         (
1053             blocker,
1054             blocked,
1055             block_level
1056         ),
1057     )
1058
1059     is_blocked = cursor.fetchone() != None
1060
1061     # DEBUG: print(f"DEBUG: is_blocked='{is_blocked}' - EXIT!")
1062     return is_blocked
1063
1064 def block_instance(blocker: str, blocked: str, reason: str, block_level: str):
1065     # DEBUG: print("DEBUG: blocker,blocked,reason,block_level:", blocker, blocked, reason, block_level)
1066     if type(blocker) != str:
1067         raise ValueError(f"Parameter blocker[]={type(blocker)} is not 'str'")
1068     elif blocker == "":
1069         raise ValueError(f"Parameter 'blocker' cannot be empty")
1070     elif not validators.domain(blocker.split("/")[0]):
1071         raise ValueError(f"Bad blocker='{blocker}'")
1072     elif type(blocked) != str:
1073         raise ValueError(f"Parameter blocked[]={type(blocked)} is not 'str'")
1074     elif blocked == "":
1075         raise ValueError(f"Parameter 'blocked' cannot be empty")
1076     elif not validators.domain(blocked.split("/")[0]):
1077         raise ValueError(f"Bad blocked='{blocked}'")
1078     elif is_blacklisted(blocker):
1079         raise Exception(f"blocker='{blocker}' is blacklisted but function invoked")
1080     elif is_blacklisted(blocked):
1081         raise Exception(f"blocked='{blocked}' is blacklisted but function invoked")
1082
1083     if reason != None:
1084         # Maybe needs cleaning
1085         reason = tidyup_reason(reason)
1086
1087     print(f"INFO: New block: blocker='{blocker}',blocked='{blocked}', reason='{reason}', block_level='{block_level}'")
1088     try:
1089         cursor.execute(
1090             "INSERT INTO blocks (blocker, blocked, reason, block_level, first_seen, last_seen) VALUES(?, ?, ?, ?, ?, ?)",
1091              (
1092                  blocker,
1093                  blocked,
1094                  reason,
1095                  block_level,
1096                  time.time(),
1097                  time.time()
1098              ),
1099         )
1100     except BaseException as e:
1101         print(f"ERROR: failed SQL query: blocker='{blocker}',blocked='{blocked}',reason='{reason}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
1102         sys.exit(255)
1103
1104     # DEBUG: print("DEBUG: EXIT!")
1105
1106 def is_instance_registered(domain: str) -> bool:
1107     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1108     if type(domain) != str:
1109         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1110     elif domain == "":
1111         raise ValueError(f"Parameter 'domain' cannot be empty")
1112
1113     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
1114     if not cache.key_exists("is_registered"):
1115         # DEBUG: print(f"DEBUG: Cache for 'is_registered' not initialized, fetching all rows ...")
1116         try:
1117             cursor.execute("SELECT domain FROM instances")
1118
1119             # Check Set all
1120             cache.set_all("is_registered", cursor.fetchall(), True)
1121         except BaseException as e:
1122             print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
1123             sys.exit(255)
1124
1125     # Is cache found?
1126     registered = cache.sub_key_exists("is_registered", domain)
1127
1128     # DEBUG: print(f"DEBUG: registered='{registered}' - EXIT!")
1129     return registered
1130
1131 def add_instance(domain: str, origin: str, originator: str, path: str = None):
1132     # DEBUG: print(f"DEBUG: domain={domain},origin={origin},originator={originator},path={path} - CALLED!")
1133     if type(domain) != str:
1134         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1135     elif domain == "":
1136         raise ValueError(f"Parameter 'domain' cannot be empty")
1137     elif type(origin) != str and origin != None:
1138         raise ValueError(f"origin[]={type(origin)} is not 'str'")
1139     elif type(originator) != str:
1140         raise ValueError(f"originator[]={type(originator)} is not 'str'")
1141     elif originator == "":
1142         raise ValueError(f"originator cannot be empty")
1143     elif not validators.domain(domain.split("/")[0]):
1144         raise ValueError(f"Bad domain name='{domain}'")
1145     elif origin is not None and not validators.domain(origin.split("/")[0]):
1146         raise ValueError(f"Bad origin name='{origin}'")
1147     elif is_blacklisted(domain):
1148         raise Exception(f"domain='{domain}' is blacklisted, but method invoked")
1149
1150     # DEBUG: print("DEBUG: domain,origin,originator,path:", domain, origin, originator, path)
1151     software = determine_software(domain, path)
1152     # DEBUG: print("DEBUG: Determined software:", software)
1153
1154     print(f"INFO: Adding instance domain='{domain}' (origin='{origin}',software='{software}')")
1155     try:
1156         cursor.execute(
1157             "INSERT INTO instances (domain, origin, originator, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
1158             (
1159                domain,
1160                origin,
1161                originator,
1162                get_hash(domain),
1163                software,
1164                time.time()
1165             ),
1166         )
1167
1168         cache.set_sub_key("is_registered", domain, True)
1169
1170         if instances.has_pending_instance_data(domain):
1171             # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo being updated ...")
1172             instances.set("last_status_code"  , domain, None)
1173             instances.set("last_error_details", domain, None)
1174             instances.update_instance_data(domain)
1175             remove_pending_error(domain)
1176
1177         if domain in pending_errors:
1178             # DEBUG: print("DEBUG: domain has pending error being updated:", domain)
1179             update_last_error(domain, pending_errors[domain])
1180             remove_pending_error(domain)
1181
1182     except BaseException as e:
1183         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
1184         sys.exit(255)
1185     else:
1186         # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
1187         update_last_nodeinfo(domain)
1188
1189     # DEBUG: print("DEBUG: EXIT!")
1190
1191 def send_bot_post(instance: str, blocks: dict):
1192     # DEBUG: print(f"DEBUG: instance={instance},blocks()={len(blocks)} - CALLED!")
1193     message = instance + " has blocked the following instances:\n\n"
1194     truncated = False
1195
1196     if len(blocks) > 20:
1197         truncated = True
1198         blocks = blocks[0 : 19]
1199
1200     for block in blocks:
1201         if block["reason"] == None or block["reason"] == '':
1202             message = message + block["blocked"] + " with unspecified reason\n"
1203         else:
1204             if len(block["reason"]) > 420:
1205                 block["reason"] = block["reason"][0:419] + "[…]"
1206
1207             message = message + block["blocked"] + ' for "' + block["reason"].replace("@", "@\u200b") + '"\n'
1208
1209     if truncated:
1210         message = message + "(the list has been truncated to the first 20 entries)"
1211
1212     botheaders = {**api_headers, **{"Authorization": "Bearer " + config.get("bot_token")}}
1213
1214     req = reqto.post(
1215         f"{config.get('bot_instance')}/api/v1/statuses",
1216         data={
1217             "status"      : message,
1218             "visibility"  : config.get('bot_visibility'),
1219             "content_type": "text/plain"
1220         },
1221         headers=botheaders,
1222         timeout=10
1223     ).json()
1224
1225     return True
1226
1227 def get_mastodon_blocks(domain: str) -> dict:
1228     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1229     if type(domain) != str:
1230         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1231     elif domain == "":
1232         raise ValueError(f"Parameter 'domain' cannot be empty")
1233
1234     # DEBUG: print("DEBUG: Fetching mastodon blocks from domain:", domain)
1235     blocks = {
1236         "Suspended servers": [],
1237         "Filtered media"   : [],
1238         "Limited servers"  : [],
1239         "Silenced servers" : [],
1240     }
1241
1242     try:
1243         doc = bs4.BeautifulSoup(
1244             get_response(domain, "/about", headers, (config.get("connection_timeout"), config.get("read_timeout"))).text,
1245             "html.parser",
1246         )
1247     except BaseException as e:
1248         print("ERROR: Cannot fetch from domain:", domain, e)
1249         update_last_error(domain, e)
1250         return {}
1251
1252     for header in doc.find_all("h3"):
1253         header_text = tidyup_domain(header.text)
1254
1255         if header_text in language_mapping:
1256             # DEBUG: print(f"DEBUG: header_text='{header_text}'")
1257             header_text = language_mapping[header_text]
1258
1259         if header_text in blocks or header_text.lower() in blocks:
1260             # replaced find_next_siblings with find_all_next to account for instances that e.g. hide lists in dropdown menu
1261             for line in header.find_all_next("table")[0].find_all("tr")[1:]:
1262                 blocks[header_text].append(
1263                     {
1264                         "domain": tidyup_domain(line.find("span").text),
1265                         "hash"  : tidyup_domain(line.find("span")["title"][9:]),
1266                         "reason": tidyup_domain(line.find_all("td")[1].text),
1267                     }
1268                 )
1269
1270     # DEBUG: print("DEBUG: Returning blocks for domain:", domain)
1271     return {
1272         "reject"        : blocks["Suspended servers"],
1273         "media_removal" : blocks["Filtered media"],
1274         "followers_only": blocks["Limited servers"] + blocks["Silenced servers"],
1275     }
1276
1277 def get_friendica_blocks(domain: str) -> dict:
1278     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1279     if type(domain) != str:
1280         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1281     elif domain == "":
1282         raise ValueError(f"Parameter 'domain' cannot be empty")
1283
1284     # DEBUG: print("DEBUG: Fetching friendica blocks from domain:", domain)
1285     blocks = []
1286
1287     try:
1288         doc = bs4.BeautifulSoup(
1289             get_response(domain, "/friendica", headers, (config.get("connection_timeout"), config.get("read_timeout"))).text,
1290             "html.parser",
1291         )
1292     except BaseException as e:
1293         print("WARNING: Failed to fetch /friendica from domain:", domain, e)
1294         update_last_error(domain, e)
1295         return {}
1296
1297     blocklist = doc.find(id="about_blocklist")
1298
1299     # Prevents exceptions:
1300     if blocklist is None:
1301         # DEBUG: print("DEBUG: Instance has no block list:", domain)
1302         return {}
1303
1304     for line in blocklist.find("table").find_all("tr")[1:]:
1305         # DEBUG: print(f"DEBUG: line='{line}'")
1306         blocks.append({
1307             "domain": tidyup_domain(line.find_all("td")[0].text),
1308             "reason": tidyup_domain(line.find_all("td")[1].text)
1309         })
1310
1311     # DEBUG: print("DEBUG: Returning blocks() for domain:", domain, len(blocks))
1312     return {
1313         "reject": blocks
1314     }
1315
1316 def get_misskey_blocks(domain: str) -> dict:
1317     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1318     if type(domain) != str:
1319         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1320     elif domain == "":
1321         raise ValueError(f"Parameter 'domain' cannot be empty")
1322
1323     # DEBUG: print("DEBUG: Fetching misskey blocks from domain:", domain)
1324     blocks = {
1325         "suspended": [],
1326         "blocked"  : []
1327     }
1328
1329     offset = 0
1330     step = config.get("misskey_limit")
1331     while True:
1332         # iterating through all "suspended" (follow-only in its terminology)
1333         # instances page-by-page, since that troonware doesn't support
1334         # sending them all at once
1335         try:
1336             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
1337             if offset == 0:
1338                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1339                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1340                     "sort"     : "+pubAt",
1341                     "host"     : None,
1342                     "suspended": True,
1343                     "limit"    : step
1344                 }), {
1345                     "Origin": domain
1346                 })
1347             else:
1348                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1349                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1350                     "sort"     : "+pubAt",
1351                     "host"     : None,
1352                     "suspended": True,
1353                     "limit"    : step,
1354                     "offset"   : offset - 1
1355                 }), {
1356                     "Origin": domain
1357                 })
1358
1359             # DEBUG: print("DEBUG: fetched():", len(fetched))
1360             if len(fetched) == 0:
1361                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1362                 break
1363             elif len(fetched) != config.get("misskey_limit"):
1364                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config.get('misskey_limit')}'")
1365                 offset = offset + (config.get("misskey_limit") - len(fetched))
1366             else:
1367                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1368                 offset = offset + step
1369
1370             for instance in fetched:
1371                 # just in case
1372                 if instance["isSuspended"] and not has_element(blocks["suspended"], "domain", instance):
1373                     blocks["suspended"].append(
1374                         {
1375                             "domain": tidyup_domain(instance["host"]),
1376                             # no reason field, nothing
1377                             "reason": None
1378                         }
1379                     )
1380
1381         except BaseException as e:
1382             print("WARNING: Caught error, exiting loop:", domain, e)
1383             update_last_error(domain, e)
1384             offset = 0
1385             break
1386
1387     while True:
1388         # same shit, different asshole ("blocked" aka full suspend)
1389         try:
1390             if offset == 0:
1391                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1392                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1393                     "sort"   : "+pubAt",
1394                     "host"   : None,
1395                     "blocked": True,
1396                     "limit"  : step
1397                 }), {
1398                     "Origin": domain
1399                 })
1400             else:
1401                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1402                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1403                     "sort"   : "+pubAt",
1404                     "host"   : None,
1405                     "blocked": True,
1406                     "limit"  : step,
1407                     "offset" : offset - 1
1408                 }), {
1409                     "Origin": domain
1410                 })
1411
1412             # DEBUG: print("DEBUG: fetched():", len(fetched))
1413             if len(fetched) == 0:
1414                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1415                 break
1416             elif len(fetched) != config.get("misskey_limit"):
1417                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config.get('misskey_limit')}'")
1418                 offset = offset + (config.get("misskey_limit") - len(fetched))
1419             else:
1420                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1421                 offset = offset + step
1422
1423             for instance in fetched:
1424                 if instance["isBlocked"] and not has_element(blocks["blocked"], "domain", instance):
1425                     blocks["blocked"].append({
1426                         "domain": tidyup_domain(instance["host"]),
1427                         "reason": None
1428                     })
1429
1430         except BaseException as e:
1431             print("ERROR: Exception during POST:", domain, e)
1432             update_last_error(domain, e)
1433             offset = 0
1434             break
1435
1436     # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
1437     update_last_instance_fetch(domain)
1438
1439     # DEBUG: print("DEBUG: Returning for domain,blocked(),suspended():", domain, len(blocks["blocked"]), len(blocks["suspended"]))
1440     return {
1441         "reject"        : blocks["blocked"],
1442         "followers_only": blocks["suspended"]
1443     }
1444
1445 def tidyup_reason(reason: str) -> str:
1446     # DEBUG: print(f"DEBUG: reason='{reason}' - CALLED!")
1447     if type(reason) != str:
1448         raise ValueError(f"Parameter reason[]={type(reason)} is not expected")
1449
1450     # Strip string
1451     reason = reason.strip()
1452
1453     # Replace â with "
1454     reason = re.sub("â", "\"", reason)
1455
1456     ## DEBUG: print(f"DEBUG: reason='{reason}' - EXIT!")
1457     return reason
1458
1459 def tidyup_domain(domain: str) -> str:
1460     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
1461     if type(domain) != str:
1462         raise ValueError(f"Parameter domain[]={type(domain)} is not expected")
1463
1464     # All lower-case and strip spaces out + last dot
1465     domain = domain.lower().strip().rstrip(".")
1466
1467     # No port number
1468     domain = re.sub("\:\d+$", "", domain)
1469
1470     # No protocol, sometimes without the slashes
1471     domain = re.sub("^https?\:(\/*)", "", domain)
1472
1473     # No trailing slash
1474     domain = re.sub("\/$", "", domain)
1475
1476     # No @ sign
1477     domain = re.sub("^\@", "", domain)
1478
1479     # No individual users in block lists
1480     domain = re.sub("(.+)\@", "", domain)
1481
1482     # DEBUG: print(f"DEBUG: domain='{domain}' - EXIT!")
1483     return domain
1484
1485 def json_from_response(response: requests.models.Response) -> list:
1486     # DEBUG: print(f"DEBUG: response[]={type(response)} - CALLED!")
1487     if not isinstance(response, requests.models.Response):
1488         raise ValueError(f"Parameter response[]='{type(response)}' is not type of 'Response'")
1489
1490     data = list()
1491     if response.text.strip() != "":
1492         # DEBUG: print(f"DEBUG: response.text()={len(response.text)} is not empty, invoking response.json() ...")
1493         try:
1494             data = response.json()
1495         except json.decoder.JSONDecodeError:
1496             pass
1497
1498     # DEBUG: print(f"DEBUG: data[]={type(data)} - EXIT!")
1499     return data
1500
1501 def get_response(domain: str, path: str, headers: dict, timeout: list) -> requests.models.Response:
1502     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}',headers()={len(headers)},timeout={timeout} - CALLED!")
1503     if type(domain) != str:
1504         raise ValueError(f"Parameter domain[]='{type(domain)}' is not 'str'")
1505     elif domain == "":
1506         raise ValueError("Parameter 'domain' cannot be empty")
1507     elif type(path) != str:
1508         raise ValueError(f"Parameter path[]='{type(path)}' is not 'str'")
1509     elif path == "":
1510         raise ValueError("Parameter 'path' cannot be empty")
1511
1512     try:
1513         # DEBUG: print(f"DEBUG: Sending request to '{domain}{path}' ...")
1514         response = reqto.get(
1515             f"https://{domain}{path}",
1516             headers=headers,
1517             timeout=timeout
1518         );
1519     except requests.exceptions.ConnectionError as e:
1520         # DEBUG: print(f"DEBUG: Fetching '{path}' from '{domain}' failed. exception[{type(e)}]='{str(e)}'")
1521         update_last_error(domain, e)
1522         raise e
1523
1524     # DEBUG: print(f"DEBUG: response[]='{type(response)}' - EXXIT!")
1525     return response
1526
1527 def has_element(elements: list, key: str, value: any) -> bool:
1528     # DEBUG: print(f"DEBUG: element()={len(element)},key='{key}',value[]='{type(value)}' - CALLED!")
1529     if type(key) != str:
1530         raise ValueError(f"Parameter key[]='{type(key)}' is not 'str'")
1531     elif key == "":
1532         raise ValueError("Parameter 'key' cannot be empty")
1533
1534     has = False
1535     # DEBUG: print(f"DEBUG: Checking elements()={len(elements)} ...")
1536     for element in elements:
1537         # DEBUG: print(f"DEBUG: element[]='{type(element)}'")
1538         if type(element) != dict:
1539             raise ValueError(f"element[]='{type(element)}' is not 'dict'")
1540         elif not key in element:
1541             raise KeyError(f"Cannot find key='{key}'")
1542         elif element[key] == value:
1543             has = True
1544             break
1545
1546     # DEBUG: print(f"DEBUG: has={has} - EXIT!")
1547     return has
1548
1549 def find_domains(tag: bs4.element.Tag) -> list:
1550     # DEBUG: print(f"DEBUG: tag[]={type(tag)} - CALLED!")
1551     if not isinstance(tag, bs4.element.Tag):
1552         raise ValueError(f"Parameter tag[]={type(tag)} is not type of bs4.element.Tag")
1553     elif not isinstance(tag, bs4.element.Tag):
1554         raise KeyError("Cannot find table with instances!")
1555     elif len(tag.select("tr")) == 0:
1556         raise KeyError("No table rows found in table!")
1557
1558     domains = list()
1559     for element in tag.select("tr"):
1560         # DEBUG: print(f"DEBUG: element[]={type(element)}")
1561         if not element.find("td"):
1562             # DEBUG: print("DEBUG: Skipping element, no <td> found")
1563             continue
1564
1565         domain = tidyup_domain(element.find("td").text)
1566         reason = tidyup_reason(element.findAll("td")[1].text)
1567
1568         # DEBUG: print(f"DEBUG: domain='{domain}',reason='{reason}'")
1569
1570         if is_blacklisted(domain):
1571             print(f"WARNING: domain='{domain}' is blacklisted - skipped!")
1572             continue
1573         elif domain == "gab.com/.ai, develop.gab.com":
1574             # DEBUG: print(f"DEBUG: Multiple domains detected in one row")
1575             domains.append({
1576                 "domain": "gab.com",
1577                 "reason": reason,
1578             })
1579             domains.append({
1580                 "domain": "gab.ai",
1581                 "reason": reason,
1582             })
1583             domains.append({
1584                 "domain": "develop.gab.com",
1585                 "reason": reason,
1586             })
1587             continue
1588         elif not validators.domain(domain):
1589             print(f"WARNING: domain='{domain}' is not a valid domain - skipped!")
1590             continue
1591
1592         # DEBUG: print(f"DEBUG: Adding domain='{domain}' ...")
1593         domains.append({
1594             "domain": domain,
1595             "reason": reason,
1596         })
1597
1598     # DEBUG: print(f"DEBUG: domains()={len(domains)} - EXIT!")
1599     return domains
1600
1601 def get_url(url: str) -> requests.models.Response:
1602     # DEBUG: print(f"DEBUG: url='{url}' - CALLED!")
1603     if type(url) != str:
1604         raise ValueError(f"Parameter url[]='{type(url)}' is not 'str'")
1605     elif url == "":
1606         raise ValueError("Parameter 'url' cannot be empty")
1607
1608     # DEBUG: print(f"DEBUG: Parsing url='{url}'")
1609     components = urllib.parse(url)
1610
1611     # Invoke other function, avoid trailing ?
1612     if components.query != "":
1613         response = get_response(components.hostname, f"{components.path}?{components.query}")
1614     else:
1615         response = get_response(components.hostname, f"{components.path}")
1616
1617     # DEBUG: print(f"DEBUG: response[]='{type(response)}' - EXXIT!")
1618     return response