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