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