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