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