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