]> git.mxchange.org Git - fba.git/blob - fba/fba.py
1bab14f0bb4e10db185627846b3d996fbf2392ef
[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     if type(domain) != str:
129         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
130     elif domain == "":
131         raise ValueError(f"Parameter 'domain' cannot be empty")
132     elif type(origin) != str and origin != None:
133         raise ValueError(f"Parameter origin[]={type(origin)} is not 'str'")
134     elif type(script) != str:
135         raise ValueError(f"Parameter script[]={type(script)} is not 'str'")
136     elif domain == "":
137         raise ValueError(f"Parameter 'domain' cannot be empty")
138
139     # DEBUG: print("DEBUG: domain,origin,software,path:", domain, origin, software, path)
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[{type(instance}]={instance} - BEFORE")
161         instance = tidyup_domain(instance)
162         # DEBUG: print(f"DEBUG: instance[{type(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, res: any):
375     # DEBUG: print("DEBUG: domain,res[]:", domain, type(res))
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 res[]:", type(res))
383         if isinstance(res, BaseException) or isinstance(res, json.JSONDecodeError):
384             res = str(res)
385
386         # DEBUG: print("DEBUG: AFTER res[]:", type(res))
387         if type(res) is str:
388             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, 999, ?, ?)",[
389                 domain,
390                 res,
391                 time.time()
392             ])
393         else:
394             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, ?, ?, ?)",[
395                 domain,
396                 res.status_code,
397                 res.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, res: any):
411     # DEBUG: print("DEBUG: domain,res[]:", domain, type(res))
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 res[]:", type(res))
418     if isinstance(res, BaseException) or isinstance(res, json.JSONDecodeError):
419         res = f"{type}:str(res)"
420
421     # DEBUG: print("DEBUG: AFTER res[]:", type(res))
422     if type(res) is str:
423         # DEBUG: print(f"DEBUG: Setting last_error_details='{res}'");
424         instances.set("last_status_code"  , domain, 999)
425         instances.set("last_error_details", domain, res)
426     else:
427         # DEBUG: print(f"DEBUG: Setting last_error_details='{res.reason}'");
428         instances.set("last_status_code"  , domain, res.status_code)
429         instances.set("last_error_details", domain, res.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, res)
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                 }), {"Origin": domain})
499             else:
500                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
501                     "sort"  : "+pubAt",
502                     "host"  : None,
503                     "limit" : step,
504                     "offset": offset - 1
505                 }), {"Origin": domain})
506
507             # DEBUG: print(f"DEBUG: fetched()={len(fetched))}")
508             if len(fetched) == 0:
509                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
510                 break
511             elif len(fetched) != config.get("misskey_limit"):
512                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config.get('misskey_limit')}'")
513                 offset = offset + (config.get("misskey_limit") - len(fetched))
514             else:
515                 # DEBUG: print("DEBUG: Raising offset by step:", step)
516                 offset = offset + step
517
518             # Check records
519             # DEBUG: print(f"DEBUG: fetched({len(fetched)})[]={type(fetched)}")
520             if isinstance(fetched, dict) and "error" in fetched and "message" in fetched["error"]:
521                 print(f"WARNING: post_json_api() returned error: {fetched['error']['message']}")
522                 update_last_error(domain, fetched["error"]["message"])
523                 break
524
525             already = 0
526             for row in fetched:
527                 # DEBUG: print(f"DEBUG: row():{len(row)}")
528                 if not "host" in row:
529                     print(f"WARNING: row()={len(row)} does not contain element 'host': {row},domain='{domain}'")
530                     continue
531                 elif type(row["host"]) != str:
532                     print(f"WARNING: row[host][]={type(row['host'])} is not 'str'")
533                     continue
534                 elif is_blacklisted(row["host"]):
535                     # DEBUG: print(f"DEBUG: row[host]='{row['host']}' is blacklisted. domain='{domain}'")
536                     continue
537                 elif row["host"] in peers:
538                     # DEBUG: print(f"DEBUG: Not adding row[host]='{row['host']}', already found.")
539                     already = already + 1
540                     continue
541
542                 # DEBUG: print(f"DEBUG: Adding peer: '{row['host']}'")
543                 peers.append(row["host"])
544
545             if already == len(fetched):
546                 print(f"WARNING: Host returned same set of '{already}' instances, aborting loop!")
547                 break
548
549         # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
550         instances.set("total_peers", domain, len(peers))
551
552         # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
553         update_last_instance_fetch(domain)
554
555         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
556         return peers
557     elif software == "lemmy":
558         # DEBUG: print(f"DEBUG: domain='{domain}' is Lemmy, fetching JSON ...")
559         try:
560             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config.get("connection_timeout"), config.get("read_timeout")))
561
562             data = json_from_response(res)
563
564             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',data[]='{type(data)}'")
565             if not res.ok or res.status_code >= 400:
566                 print("WARNING: Could not reach any JSON API:", domain)
567                 update_last_error(domain, res)
568             elif res.ok and isinstance(data, list):
569                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
570                 sys.exit(255)
571             elif "federated_instances" in data:
572                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
573                 peers = peers + add_peers(data["federated_instances"])
574                 # DEBUG: print("DEBUG: Added instance(s) to peers")
575             else:
576                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
577                 update_last_error(domain, res)
578
579         except BaseException as e:
580             print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
581
582         # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
583         instances.set("total_peers", domain, len(peers))
584
585         # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
586         update_last_instance_fetch(domain)
587
588         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
589         return peers
590     elif software == "peertube":
591         # DEBUG: print(f"DEBUG: domain='{domain}' is a PeerTube, fetching JSON ...")
592
593         start = 0
594         for mode in ["followers", "following"]:
595             # DEBUG: print(f"DEBUG: domain='{domain}',mode='{mode}'")
596             while True:
597                 try:
598                     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")))
599
600                     data = json_from_response(res)
601                     # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',data[]='{type(data)}'")
602                     if res.ok and isinstance(data, dict):
603                         # DEBUG: print("DEBUG: Success, data:", len(data))
604                         if "data" in data:
605                             # DEBUG: print(f"DEBUG: Found {len(data['data'])} record(s).")
606                             for record in data["data"]:
607                                 # DEBUG: print(f"DEBUG: record()={len(record)}")
608                                 if mode in record and "host" in record[mode]:
609                                     # DEBUG: print(f"DEBUG: Found host={record[mode]['host']}, adding ...")
610                                     peers.append(record[mode]["host"])
611                                 else:
612                                     print(f"WARNING: record from '{domain}' has no '{mode}' or 'host' record: {record}")
613
614                             if len(data["data"]) < 100:
615                                 # DEBUG: print("DEBUG: Reached end of JSON response:", domain)
616                                 break
617
618                         # Continue with next row
619                         start = start + 100
620
621                 except BaseException as e:
622                     print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
623
624         # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
625         instances.set("total_peers", domain, len(peers))
626
627         # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
628         update_last_instance_fetch(domain)
629
630         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
631         return peers
632
633     # DEBUG: print(f"DEBUG: Fetching get_peers_url='{get_peers_url}' from '{domain}' ...")
634     try:
635         res = reqto.get(f"https://{domain}{get_peers_url}", headers=api_headers, timeout=(config.get("connection_timeout"), config.get("read_timeout")))
636
637         data = json_from_response(res)
638
639         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
640         if not res.ok or res.status_code >= 400:
641             # DEBUG: print(f"DEBUG: Was not able to fetch '{get_peers_url}', trying alternative ...")
642             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config.get("connection_timeout"), config.get("read_timeout")))
643
644             data = json_from_response(res)
645             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
646             if not res.ok or res.status_code >= 400:
647                 print("WARNING: Could not reach any JSON API:", domain)
648                 update_last_error(domain, res)
649             elif res.ok and isinstance(data, list):
650                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
651                 sys.exit(255)
652             elif "federated_instances" in data:
653                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
654                 peers = peers + add_peers(data["federated_instances"])
655                 # DEBUG: print("DEBUG: Added instance(s) to peers")
656             else:
657                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
658                 update_last_error(domain, res)
659         else:
660             # DEBUG: print("DEBUG: Querying API was successful:", domain, len(data))
661             peers = data
662
663     except BaseException as e:
664         print("WARNING: Some error during get():", domain, e)
665         update_last_error(domain, e)
666
667     # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
668     instances.set("total_peers", domain, len(peers))
669
670     # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
671     update_last_instance_fetch(domain)
672
673     # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
674     return peers
675
676 def post_json_api(domain: str, path: str, parameter: str, extra_headers: dict = {}) -> dict:
677     if type(domain) != str:
678         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
679     elif domain == "":
680         raise ValueError(f"Parameter 'domain' cannot be empty")
681     elif type(path) != str:
682         raise ValueError(f"path[]={type(path)} is not 'str'")
683     elif path == "":
684         raise ValueError("Parameter 'path' cannot be empty")
685     elif type(parameter) != str:
686         raise ValueError(f"parameter[]={type(parameter)} is not 'str'")
687
688     # DEBUG: print("DEBUG: Sending POST to domain,path,parameter:", domain, path, parameter, extra_headers)
689     data = {}
690     try:
691         res = reqto.post(f"https://{domain}{path}", data=parameter, headers={**api_headers, **extra_headers}, timeout=(config.get("connection_timeout"), config.get("read_timeout")))
692
693         data = json_from_response(res)
694         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
695         if not res.ok or res.status_code >= 400:
696             print(f"WARNING: Cannot query JSON API: domain='{domain}',path='{path}',parameter()={len(parameter)},res.status_code='{res.status_code}',data[]='{type(data)}'")
697             update_last_error(domain, res)
698
699     except BaseException as e:
700         print(f"WARNING: Some error during post(): domain='{domain}',path='{path}',parameter()={len(parameter)},exception[{type(e)}]:'{str(e)}'")
701
702     # DEBUG: print(f"DEBUG: Returning data({len(data)})=[]:{type(data)}")
703     return data
704
705 def fetch_nodeinfo(domain: str, path: str = None) -> list:
706     # DEBUG: print(f"DEBUG: domain='{domain}',path={path} - CALLED!")
707     if type(domain) != str:
708         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
709     elif domain == "":
710         raise ValueError(f"Parameter 'domain' cannot be empty")
711     elif type(path) != str and path != None:
712         raise ValueError(f"Parameter path[]={type(path)} is not 'str'")
713
714     # DEBUG: print("DEBUG: Fetching nodeinfo from domain,path:", domain, path)
715     nodeinfo = fetch_wellknown_nodeinfo(domain)
716
717     # DEBUG: print(f"DEBUG: nodeinfo({len(nodeinfo)})={nodeinfo}")
718     if len(nodeinfo) > 0:
719         # DEBUG: print("DEBUG: nodeinfo()={len(nodeinfo))} - EXIT!")
720         return nodeinfo
721
722     requests = [
723        f"https://{domain}/nodeinfo/2.1.json",
724        f"https://{domain}/nodeinfo/2.1",
725        f"https://{domain}/nodeinfo/2.0.json",
726        f"https://{domain}/nodeinfo/2.0",
727        f"https://{domain}/nodeinfo/1.0",
728        f"https://{domain}/api/v1/instance"
729     ]
730
731     data = {}
732     for request in requests:
733         if path != None and path != "" and request != path:
734             # DEBUG: print(f"DEBUG: path='{path}' does not match request='{request}' - SKIPPED!")
735             continue
736
737         try:
738             # DEBUG: print("DEBUG: Fetching request:", request)
739             res = reqto.get(request, headers=api_headers, timeout=(config.get("connection_timeout"), config.get("read_timeout")))
740
741             data = json_from_response(res)
742             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
743             if res.ok and isinstance(data, dict):
744                 # DEBUG: print("DEBUG: Success:", request)
745                 instances.set("detection_mode", domain, "STATIC_CHECK")
746                 instances.set("nodeinfo_url"  , domain, request)
747                 break
748             elif res.ok and isinstance(data, list):
749                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
750                 sys.exit(255)
751             elif not res.ok or res.status_code >= 400:
752                 print("WARNING: Failed fetching nodeinfo from domain:", domain)
753                 update_last_error(domain, res)
754                 continue
755
756         except BaseException as e:
757             # DEBUG: print("DEBUG: Cannot fetch API request:", request)
758             update_last_error(domain, e)
759             pass
760
761     # DEBUG: print(f"DEBUG: data()={len(data)} - EXIT!")
762     return data
763
764 def fetch_wellknown_nodeinfo(domain: str) -> list:
765     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
766     if type(domain) != str:
767         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
768     elif domain == "":
769         raise ValueError(f"Parameter 'domain' cannot be empty")
770
771     # DEBUG: print("DEBUG: Fetching .well-known info for domain:", domain)
772     data = {}
773
774     try:
775         res = reqto.get(f"https://{domain}/.well-known/nodeinfo", headers=api_headers, timeout=(config.get("connection_timeout"), config.get("read_timeout")))
776
777         data = json_from_response(res)
778         # DEBUG: print("DEBUG: domain,res.ok,data[]:", domain, res.ok, type(data))
779         if res.ok and isinstance(data, dict):
780             nodeinfo = data
781             # DEBUG: print("DEBUG: Found entries:", len(nodeinfo), domain)
782             if "links" in nodeinfo:
783                 # DEBUG: print("DEBUG: Found links in nodeinfo():", len(nodeinfo["links"]))
784                 for link in nodeinfo["links"]:
785                     # DEBUG: print("DEBUG: rel,href:", link["rel"], link["href"])
786                     if link["rel"] in nodeinfo_identifier:
787                         # DEBUG: print("DEBUG: Fetching nodeinfo from:", link["href"])
788                         res = reqto.get(link["href"])
789
790                         data = json_from_response(res)
791                         # DEBUG: print("DEBUG: href,res.ok,res.status_code:", link["href"], res.ok, res.status_code)
792                         if res.ok and isinstance(data, dict):
793                             # DEBUG: print("DEBUG: Found JSON nodeinfo():", len(data))
794                             instances.set("detection_mode", domain, "AUTO_DISCOVERY")
795                             instances.set("nodeinfo_url"  , domain, link["href"])
796                             break
797                     else:
798                         print("WARNING: Unknown 'rel' value:", domain, link["rel"])
799             else:
800                 print("WARNING: nodeinfo does not contain 'links':", domain)
801
802     except BaseException as e:
803         print("WARNING: Failed fetching .well-known info:", domain)
804         update_last_error(domain, e)
805         pass
806
807     # DEBUG: print("DEBUG: Returning data[]:", type(data))
808     return data
809
810 def fetch_generator_from_path(domain: str, path: str = "/") -> str:
811     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},path={path} - CALLED!")
812     if type(domain) != str:
813         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
814     elif domain == "":
815         raise ValueError(f"Parameter 'domain' cannot be empty")
816     elif type(path) != str:
817         raise ValueError(f"path[]={type(path)} is not 'str'")
818     elif path == "":
819         raise ValueError(f"Parameter 'domain' cannot be empty")
820
821     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}' - CALLED!")
822     software = None
823
824     try:
825         # DEBUG: print(f"DEBUG: Fetching path='{path}' from '{domain}' ...")
826         res = reqto.get(f"https://{domain}{path}", headers=headers, timeout=(config.get("connection_timeout"), config.get("read_timeout")))
827
828         # DEBUG: print("DEBUG: domain,res.ok,res.status_code,res.text[]:", domain, res.ok, res.status_code, type(res.text))
829         if res.ok and res.status_code < 300 and len(res.text) > 0:
830             # DEBUG: print("DEBUG: Search for <meta name='generator'>:", domain)
831             doc = bs4.BeautifulSoup(res.text, "html.parser")
832
833             # DEBUG: print("DEBUG: doc[]:", type(doc))
834             generator = doc.find("meta", {"name": "generator"})
835             site_name = doc.find("meta", {"property": "og:site_name"})
836
837             # DEBUG: print(f"DEBUG: generator='{generator}',site_name='{site_name}'")
838             if isinstance(generator, bs4.element.Tag):
839                 # DEBUG: print("DEBUG: Found generator meta tag:", domain)
840                 software = tidyup_domain(generator.get("content"))
841                 print(f"INFO: domain='{domain}' is generated by '{software}'")
842                 instances.set("detection_mode", domain, "GENERATOR")
843                 remove_pending_error(domain)
844             elif isinstance(site_name, bs4.element.Tag):
845                 # DEBUG: print("DEBUG: Found property=og:site_name:", domain)
846                 sofware = tidyup_domain(site_name.get("content"))
847                 print(f"INFO: domain='{domain}' has og:site_name='{software}'")
848                 instances.set("detection_mode", domain, "SITE_NAME")
849                 remove_pending_error(domain)
850
851     except BaseException as e:
852         # DEBUG: print(f"DEBUG: Cannot fetch / from '{domain}':", e)
853         update_last_error(domain, e)
854         pass
855
856     # DEBUG: print(f"DEBUG: software[]={type(software)}")
857     if type(software) is str and software == "":
858         # DEBUG: print(f"DEBUG: Corrected empty string to None for software of domain='{domain}'")
859         software = None
860     elif type(software) is str and ("." in software or " " in software):
861         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
862         software = remove_version(software)
863
864     # DEBUG: print(f"DEBUG: software[]={type(software)}")
865     if type(software) is str and " powered by " in software:
866         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
867         software = remove_version(strip_powered_by(software))
868     elif type(software) is str and " hosted on " in software:
869         # DEBUG: print(f"DEBUG: software='{software}' has 'hosted on' in it")
870         software = remove_version(strip_hosted_on(software))
871     elif type(software) is str and " by " in software:
872         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
873         software = strip_until(software, " by ")
874     elif type(software) is str and " see " in software:
875         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
876         software = strip_until(software, " see ")
877
878     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
879     return software
880
881 def determine_software(domain: str, path: str = None) -> str:
882     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},path={path} - CALLED!")
883     if type(domain) != str:
884         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
885     elif domain == "":
886         raise ValueError(f"Parameter 'domain' cannot be empty")
887     elif type(path) != str and path != None:
888         raise ValueError(f"Parameter path[]={type(path)} is not 'str'")
889
890     # DEBUG: print("DEBUG: Determining software for domain,path:", domain, path)
891     software = None
892
893     # DEBUG: print(f"DEBUG: Fetching nodeinfo from '{domain}' ...")
894     data = fetch_nodeinfo(domain, path)
895
896     # DEBUG: print("DEBUG: data[]:", type(data))
897     if not isinstance(data, dict) or len(data) == 0:
898         # DEBUG: print("DEBUG: Could not determine software type:", domain)
899         return fetch_generator_from_path(domain)
900
901     # DEBUG: print("DEBUG: data():", len(data), data)
902     if "status" in data and data["status"] == "error" and "message" in data:
903         print("WARNING: JSON response is an error:", data["message"])
904         update_last_error(domain, data["message"])
905         return fetch_generator_from_path(domain)
906     elif "message" in data:
907         print("WARNING: JSON response contains only a message:", data["message"])
908         update_last_error(domain, data["message"])
909         return fetch_generator_from_path(domain)
910     elif "software" not in data or "name" not in data["software"]:
911         # DEBUG: print(f"DEBUG: JSON response from domain='{domain}' does not include [software][name], fetching / ...")
912         software = fetch_generator_from_path(domain)
913
914         # DEBUG: print(f"DEBUG: Generator for domain='{domain}' is: {software}, EXIT!")
915         return software
916
917     software = tidyup_domain(data["software"]["name"])
918
919     # DEBUG: print("DEBUG: sofware after tidyup_domain():", software)
920     if software in ["akkoma", "rebased"]:
921         # DEBUG: print("DEBUG: Setting pleroma:", domain, software)
922         software = "pleroma"
923     elif software in ["hometown", "ecko"]:
924         # DEBUG: print("DEBUG: Setting mastodon:", domain, software)
925         software = "mastodon"
926     elif software in ["calckey", "groundpolis", "foundkey", "cherrypick", "meisskey"]:
927         # DEBUG: print("DEBUG: Setting misskey:", domain, software)
928         software = "misskey"
929     elif software.find("/") > 0:
930         print("WARNING: Spliting of slash:", software)
931         software = software.split("/")[-1];
932     elif software.find("|") > 0:
933         print("WARNING: Spliting of pipe:", software)
934         software = tidyup_domain(software.split("|")[0]);
935     elif "powered by" in software:
936         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
937         software = strip_powered_by(software)
938     elif type(software) is str and " by " in software:
939         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
940         software = strip_until(software, " by ")
941     elif type(software) is str and " see " in software:
942         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
943         software = strip_until(software, " see ")
944
945     # DEBUG: print(f"DEBUG: software[]={type(software)}")
946     if software == "":
947         print("WARNING: tidyup_domain() left no software name behind:", domain)
948         software = None
949
950     # DEBUG: print(f"DEBUG: software[]={type(software)}")
951     if str(software) == "":
952         # DEBUG: print(f"DEBUG: software for '{domain}' was not detected, trying generator ...")
953         software = fetch_generator_from_path(domain)
954     elif len(str(software)) > 0 and ("." in software or " " in software):
955         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
956         software = remove_version(software)
957
958     # DEBUG: print(f"DEBUG: software[]={type(software)}")
959     if type(software) is str and "powered by" in software:
960         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
961         software = remove_version(strip_powered_by(software))
962
963     # DEBUG: print("DEBUG: Returning domain,software:", domain, software)
964     return software
965
966 def update_block_reason(reason: str, blocker: str, blocked: str, block_level: str):
967     # DEBUG: print(f"DEBUG: reason='{reason}',blocker={blocker},blocked={blocked},block_level={block_level} - CALLED!")
968     if type(reason) != str and reason != None:
969         raise ValueError(f"Parameter reason[]='{type(reason)}' is not 'str'")
970     elif type(blocker) != str:
971         raise ValueError(f"Parameter blocker[]='{type(blocker)}' is not 'str'")
972     elif type(blocked) != str:
973         raise ValueError(f"Parameter blocked[]='{type(blocked)}' is not 'str'")
974     elif type(block_level) != str:
975         raise ValueError(f"Parameter block_level[]='{type(block_level)}' is not 'str'")
976
977     # DEBUG: print("DEBUG: Updating block reason:", reason, blocker, blocked, block_level)
978     try:
979         cursor.execute(
980             "UPDATE blocks SET reason = ?, last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? AND reason IN ('','unknown') LIMIT 1",
981             (
982                 reason,
983                 time.time(),
984                 blocker,
985                 blocked,
986                 block_level
987             ),
988         )
989
990         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
991         if cursor.rowcount == 0:
992             # DEBUG: print(f"DEBUG: Did not update any rows: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',reason='{reason}' - EXIT!")
993             return
994
995     except BaseException as e:
996         print(f"ERROR: failed SQL query: reason='{reason}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
997         sys.exit(255)
998
999     # DEBUG: print("DEBUG: EXIT!")
1000
1001 def update_last_seen(blocker: str, blocked: str, block_level: str):
1002     # DEBUG: print("DEBUG: Updating last_seen for:", blocker, blocked, block_level)
1003     try:
1004         cursor.execute(
1005             "UPDATE blocks SET last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? LIMIT 1",
1006             (
1007                 time.time(),
1008                 blocker,
1009                 blocked,
1010                 block_level
1011             )
1012         )
1013
1014         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
1015         if cursor.rowcount == 0:
1016             # DEBUG: print(f"DEBUG: Did not update any rows: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}' - EXIT!")
1017             return
1018
1019     except BaseException as e:
1020         print(f"ERROR: failed SQL query: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
1021         sys.exit(255)
1022
1023     # DEBUG: print("DEBUG: EXIT!")
1024
1025 def is_instance_blocked(blocker: str, blocked: str, block_level: str) -> bool:
1026     # DEBUG: print(f"DEBUG: blocker={blocker},blocked={blocked},block_level={block_level} - CALLED!")
1027     if type(blocker) != str:
1028         raise ValueError(f"Parameter blocker[]={type(blocker)} is not of type 'str'")
1029     elif blocker == "":
1030         raise ValueError("Parameter 'blocker' cannot be empty")
1031     elif type(blocked) != str:
1032         raise ValueError(f"Parameter blocked[]={type(blocked)} is not of type 'str'")
1033     elif blocked == "":
1034         raise ValueError("Parameter 'blocked' cannot be empty")
1035     elif type(block_level) != str:
1036         raise ValueError(f"Parameter block_level[]={type(block_level)} is not of type 'str'")
1037     elif block_level == "":
1038         raise ValueError("Parameter 'block_level' cannot be empty")
1039
1040     cursor.execute(
1041         "SELECT * FROM blocks WHERE blocker = ? AND blocked = ? AND block_level = ? LIMIT 1",
1042         (
1043             blocker,
1044             blocked,
1045             block_level
1046         ),
1047     )
1048
1049     is_blocked = cursor.fetchone() != None
1050
1051     # DEBUG: print(f"DEBUG: is_blocked='{is_blocked}' - EXIT!")
1052     return is_blocked
1053
1054 def block_instance(blocker: str, blocked: str, reason: str, block_level: str):
1055     # DEBUG: print("DEBUG: blocker,blocked,reason,block_level:", blocker, blocked, reason, block_level)
1056     if type(blocker) != str:
1057         raise ValueError(f"Parameter blocker[]={type(blocker)} is not 'str'")
1058     elif blocker == "":
1059         raise ValueError(f"Parameter 'blocker' cannot be empty")
1060     elif not validators.domain(blocker.split("/")[0]):
1061         raise ValueError(f"Bad blocker='{blocker}'")
1062     elif type(blocked) != str:
1063         raise ValueError(f"Parameter blocked[]={type(blocked)} is not 'str'")
1064     elif blocked == "":
1065         raise ValueError(f"Parameter 'blocked' cannot be empty")
1066     elif not validators.domain(blocked.split("/")[0]):
1067         raise ValueError(f"Bad blocked='{blocked}'")
1068     elif is_blacklisted(blocker):
1069         raise Exception(f"blocker='{blocker}' is blacklisted but function invoked")
1070     elif is_blacklisted(blocked):
1071         raise Exception(f"blocked='{blocked}' is blacklisted but function invoked")
1072
1073     print(f"INFO: New block: blocker='{blocker}',blocked='{blocked}', reason='{reason}', block_level='{block_level}'")
1074     try:
1075         cursor.execute(
1076             "INSERT INTO blocks (blocker, blocked, reason, block_level, first_seen, last_seen) VALUES(?, ?, ?, ?, ?, ?)",
1077              (
1078                  blocker,
1079                  blocked,
1080                  reason,
1081                  block_level,
1082                  time.time(),
1083                  time.time()
1084              ),
1085         )
1086     except BaseException as e:
1087         print(f"ERROR: failed SQL query: blocker='{blocker}',blocked='{blocked}',reason='{reason}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
1088         sys.exit(255)
1089
1090     # DEBUG: print("DEBUG: EXIT!")
1091
1092 def is_instance_registered(domain: str) -> bool:
1093     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1094     if type(domain) != str:
1095         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1096     elif domain == "":
1097         raise ValueError(f"Parameter 'domain' cannot be empty")
1098
1099     # NOISY-DEBUG: # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
1100     if not cache.key_exists("is_registered"):
1101         # NOISY-DEBUG: # DEBUG: print(f"DEBUG: Cache for 'is_registered' not initialized, fetching all rows ...")
1102         try:
1103             cursor.execute("SELECT domain FROM instances")
1104
1105             # Check Set all
1106             cache.set_all("is_registered", cursor.fetchall(), True)
1107         except BaseException as e:
1108             print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
1109             sys.exit(255)
1110
1111     # Is cache found?
1112     registered = cache.sub_key_exists("is_registered", domain)
1113
1114     # NOISY-DEBUG: # DEBUG: print(f"DEBUG: registered='{registered}' - EXIT!")
1115     return registered
1116
1117 def add_instance(domain: str, origin: str, originator: str, path: str = None):
1118     # DEBUG: print(f"DEBUG: domain={domain},origin={origin},originator={originator},path={path} - CALLED!")
1119     if type(domain) != str:
1120         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1121     elif domain == "":
1122         raise ValueError(f"Parameter 'domain' cannot be empty")
1123     elif type(origin) != str and origin != None:
1124         raise ValueError(f"origin[]={type(origin)} is not 'str'")
1125     elif type(originator) != str:
1126         raise ValueError(f"originator[]={type(originator)} is not 'str'")
1127     elif originator == "":
1128         raise ValueError(f"originator cannot be empty")
1129     elif not validators.domain(domain.split("/")[0]):
1130         raise ValueError(f"Bad domain name='{domain}'")
1131     elif origin is not None and not validators.domain(origin.split("/")[0]):
1132         raise ValueError(f"Bad origin name='{origin}'")
1133     elif is_blacklisted(domain):
1134         raise Exception(f"domain='{domain}' is blacklisted, but method invoked")
1135
1136     # DEBUG: print("DEBUG: domain,origin,originator,path:", domain, origin, originator, path)
1137     software = determine_software(domain, path)
1138     # DEBUG: print("DEBUG: Determined software:", software)
1139
1140     print(f"INFO: Adding instance domain='{domain}' (origin='{origin}',software='{software}')")
1141     try:
1142         cursor.execute(
1143             "INSERT INTO instances (domain, origin, originator, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
1144             (
1145                domain,
1146                origin,
1147                originator,
1148                get_hash(domain),
1149                software,
1150                time.time()
1151             ),
1152         )
1153
1154         cache.set_sub_key("is_registered", domain, True)
1155
1156         if instances.has_pending_instance_data(domain):
1157             # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo being updated ...")
1158             instances.set("last_status_code"  , domain, None)
1159             instances.set("last_error_details", domain, None)
1160             instances.update_instance_data(domain)
1161             remove_pending_error(domain)
1162
1163         if domain in pending_errors:
1164             # DEBUG: print("DEBUG: domain has pending error being updated:", domain)
1165             update_last_error(domain, pending_errors[domain])
1166             remove_pending_error(domain)
1167
1168     except BaseException as e:
1169         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
1170         sys.exit(255)
1171     else:
1172         # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
1173         update_last_nodeinfo(domain)
1174
1175     # DEBUG: print("DEBUG: EXIT!")
1176
1177 def send_bot_post(instance: str, blocks: dict):
1178     # DEBUG: print(f"DEBUG: instance={instance},blocks()={len(blocks)} - CALLED!")
1179     message = instance + " has blocked the following instances:\n\n"
1180     truncated = False
1181
1182     if len(blocks) > 20:
1183         truncated = True
1184         blocks = blocks[0 : 19]
1185
1186     for block in blocks:
1187         if block["reason"] == None or block["reason"] == '':
1188             message = message + block["blocked"] + " with unspecified reason\n"
1189         else:
1190             if len(block["reason"]) > 420:
1191                 block["reason"] = block["reason"][0:419] + "[…]"
1192
1193             message = message + block["blocked"] + ' for "' + block["reason"].replace("@", "@\u200b") + '"\n'
1194
1195     if truncated:
1196         message = message + "(the list has been truncated to the first 20 entries)"
1197
1198     botheaders = {**api_headers, **{"Authorization": "Bearer " + config.get("bot_token")}}
1199
1200     req = reqto.post(
1201         f"{config.get('bot_instance')}/api/v1/statuses",
1202         data={
1203             "status"      : message,
1204             "visibility"  : config.get('bot_visibility'),
1205             "content_type": "text/plain"
1206         },
1207         headers=botheaders,
1208         timeout=10
1209     ).json()
1210
1211     return True
1212
1213 def get_mastodon_blocks(domain: str) -> dict:
1214     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1215     if type(domain) != str:
1216         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1217     elif domain == "":
1218         raise ValueError(f"Parameter 'domain' cannot be empty")
1219
1220     # DEBUG: print("DEBUG: Fetching mastodon blocks from domain:", domain)
1221     blocks = {
1222         "Suspended servers": [],
1223         "Filtered media"   : [],
1224         "Limited servers"  : [],
1225         "Silenced servers" : [],
1226     }
1227
1228     try:
1229         doc = bs4.BeautifulSoup(
1230             reqto.get(f"https://{domain}/about", headers=headers, timeout=(config.get("connection_timeout"), config.get("read_timeout"))).text,
1231             "html.parser",
1232         )
1233     except BaseException as e:
1234         print("ERROR: Cannot fetch from domain:", domain, e)
1235         update_last_error(domain, e)
1236         return {}
1237
1238     for header in doc.find_all("h3"):
1239         header_text = tidyup_domain(header.text)
1240
1241         if header_text in language_mapping:
1242             # DEBUG: print(f"DEBUG: header_text='{header_text}'")
1243             header_text = language_mapping[header_text]
1244
1245         if header_text in blocks or header_text.lower() in blocks:
1246             # replaced find_next_siblings with find_all_next to account for instances that e.g. hide lists in dropdown menu
1247             for line in header.find_all_next("table")[0].find_all("tr")[1:]:
1248                 blocks[header_text].append(
1249                     {
1250                         "domain": tidyup_domain(line.find("span").text),
1251                         "hash"  : tidyup_domain(line.find("span")["title"][9:]),
1252                         "reason": tidyup_domain(line.find_all("td")[1].text),
1253                     }
1254                 )
1255
1256     # DEBUG: print("DEBUG: Returning blocks for domain:", domain)
1257     return {
1258         "reject"        : blocks["Suspended servers"],
1259         "media_removal" : blocks["Filtered media"],
1260         "followers_only": blocks["Limited servers"] + blocks["Silenced servers"],
1261     }
1262
1263 def get_friendica_blocks(domain: str) -> dict:
1264     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1265     if type(domain) != str:
1266         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1267     elif domain == "":
1268         raise ValueError(f"Parameter 'domain' cannot be empty")
1269
1270     # DEBUG: print("DEBUG: Fetching friendica blocks from domain:", domain)
1271     blocks = []
1272
1273     try:
1274         doc = bs4.BeautifulSoup(
1275             reqto.get(f"https://{domain}/friendica", headers=headers, timeout=(config.get("connection_timeout"), config.get("read_timeout"))).text,
1276             "html.parser",
1277         )
1278     except BaseException as e:
1279         print("WARNING: Failed to fetch /friendica from domain:", domain, e)
1280         update_last_error(domain, e)
1281         return {}
1282
1283     blocklist = doc.find(id="about_blocklist")
1284
1285     # Prevents exceptions:
1286     if blocklist is None:
1287         # DEBUG: print("DEBUG: Instance has no block list:", domain)
1288         return {}
1289
1290     for line in blocklist.find("table").find_all("tr")[1:]:
1291         # DEBUG: print(f"DEBUG: line='{line}'")
1292         blocks.append({
1293             "domain": tidyup_domain(line.find_all("td")[0].text),
1294             "reason": tidyup_domain(line.find_all("td")[1].text)
1295         })
1296
1297     # DEBUG: print("DEBUG: Returning blocks() for domain:", domain, len(blocks))
1298     return {
1299         "reject": blocks
1300     }
1301
1302 def get_misskey_blocks(domain: str) -> dict:
1303     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1304     if type(domain) != str:
1305         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1306     elif domain == "":
1307         raise ValueError(f"Parameter 'domain' cannot be empty")
1308
1309     # DEBUG: print("DEBUG: Fetching misskey blocks from domain:", domain)
1310     blocks = {
1311         "suspended": [],
1312         "blocked"  : []
1313     }
1314
1315     offset = 0
1316     step = config.get("misskey_limit")
1317     while True:
1318         # iterating through all "suspended" (follow-only in its terminology)
1319         # instances page-by-page, since that troonware doesn't support
1320         # sending them all at once
1321         try:
1322             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
1323             if offset == 0:
1324                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1325                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1326                     "sort"     : "+pubAt",
1327                     "host"     : None,
1328                     "suspended": True,
1329                     "limit"    : step
1330                 }), {"Origin": domain})
1331             else:
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                     "offset"   : offset - 1
1339                 }), {"Origin": domain})
1340
1341             # DEBUG: print("DEBUG: fetched():", len(fetched))
1342             if len(fetched) == 0:
1343                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1344                 break
1345             elif len(fetched) != config.get("misskey_limit"):
1346                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config.get('misskey_limit')}'")
1347                 offset = offset + (config.get("misskey_limit") - len(fetched))
1348             else:
1349                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1350                 offset = offset + step
1351
1352             for instance in fetched:
1353                 # just in case
1354                 if instance["isSuspended"]:
1355                     blocks["suspended"].append(
1356                         {
1357                             "domain": tidyup_domain(instance["host"]),
1358                             # no reason field, nothing
1359                             "reason": None
1360                         }
1361                     )
1362
1363         except BaseException as e:
1364             print("WARNING: Caught error, exiting loop:", domain, e)
1365             update_last_error(domain, e)
1366             offset = 0
1367             break
1368
1369     while True:
1370         # same shit, different asshole ("blocked" aka full suspend)
1371         try:
1372             if offset == 0:
1373                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1374                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1375                     "sort"   : "+pubAt",
1376                     "host"   : None,
1377                     "blocked": True,
1378                     "limit"  : step
1379                 }), {"Origin": domain})
1380             else:
1381                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1382                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1383                     "sort"   : "+pubAt",
1384                     "host"   : None,
1385                     "blocked": True,
1386                     "limit"  : step,
1387                     "offset" : offset-1
1388                 }), {"Origin": domain})
1389
1390             # DEBUG: print("DEBUG: fetched():", len(fetched))
1391             if len(fetched) == 0:
1392                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1393                 break
1394             elif len(fetched) != config.get("misskey_limit"):
1395                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config.get('misskey_limit')}'")
1396                 offset = offset + (config.get("misskey_limit") - len(fetched))
1397             else:
1398                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1399                 offset = offset + step
1400
1401             for instance in fetched:
1402                 if instance["isBlocked"]:
1403                     blocks["blocked"].append({
1404                         "domain": tidyup_domain(instance["host"]),
1405                         "reason": None
1406                     })
1407
1408         except BaseException as e:
1409             print("ERROR: Exception during POST:", domain, e)
1410             update_last_error(domain, e)
1411             offset = 0
1412             break
1413
1414     # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
1415     update_last_instance_fetch(domain)
1416
1417     # DEBUG: print("DEBUG: Returning for domain,blocked(),suspended():", domain, len(blocks["blocked"]), len(blocks["suspended"]))
1418     return {
1419         "reject"        : blocks["blocked"],
1420         "followers_only": blocks["suspended"]
1421     }
1422
1423 def tidyup_domain(domain: str) -> str:
1424     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
1425     if type(domain) != str:
1426         raise ValueError(f"Parameter domain[]={type(domain)} is not expected")
1427
1428     # All lower-case and strip spaces out + last dot
1429     domain = domain.lower().strip().rstrip(".")
1430
1431     # No port number
1432     domain = re.sub("\:\d+$", "", domain)
1433
1434     # No protocol, sometimes without the slashes
1435     domain = re.sub("^https?\:(\/*)", "", domain)
1436
1437     # No trailing slash
1438     domain = re.sub("\/$", "", domain)
1439
1440     # No @ sign
1441     domain = re.sub("^\@", "", domain)
1442
1443     # No individual users in block lists
1444     domain = re.sub("(.+)\@", "", domain)
1445
1446     # DEBUG: print(f"DEBUG: domain='{domain}' - EXIT!")
1447     return domain
1448
1449 def json_from_response(response: requests.models.Response) -> list:
1450     # DEBUG: print(f"DEBUG: response[]={type(response)} - CALLED!")
1451     if not isinstance(response, requests.models.Response):
1452         raise ValueError(f"Parameter response[]='{type(response)}' is not type of 'Response'")
1453
1454     data = list()
1455     if response.text.strip() != "":
1456         # DEBUG: print(f"DEBUG: response.text()={len(response.text)} is not empty, invoking response.json() ...")
1457         try:
1458             data = response.json()
1459         except json.JSONDecodeError:
1460             pass
1461
1462     # DEBUG: print(f"DEBUG: data[]={type(data)} - EXIT!")
1463     return data