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