]> git.mxchange.org Git - fba.git/blob - fba.py
Continued:
[fba.git] / fba.py
1 # Fedi API Block - An aggregator for fetching blocking data from fediverse nodes
2 # Copyright (C) 2023 Free Software Foundation
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published
6 # by the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17 import bs4
18 import hashlib
19 import re
20 import reqto
21 import json
22 import sqlite3
23 import sys
24 import time
25 import validators
26
27 with open("config.json") as f:
28     config = json.loads(f.read())
29
30 # Don't check these, known trolls/flooders/testing/developing
31 blacklist = [
32     # Floods network with fake nodes as "research" project
33     "activitypub-troll.cf",
34     # Similar troll
35     "gab.best",
36     # Similar troll
37     "4chan.icu",
38     # Flooder (?)
39     "social.shrimpcam.pw",
40     # Flooder (?)
41     "mastotroll.netz.org",
42     # Testing/developing installations
43     "ngrok.io",
44     "ngrok-free.app",
45     "misskeytest.chn.moe",
46 ]
47
48 # Array with pending errors needed to be written to database
49 pending_errors = {
50 }
51
52 # "rel" identifiers (no real URLs)
53 nodeinfo_identifier = [
54     "https://nodeinfo.diaspora.software/ns/schema/2.1",
55     "https://nodeinfo.diaspora.software/ns/schema/2.0",
56     "https://nodeinfo.diaspora.software/ns/schema/1.1",
57     "https://nodeinfo.diaspora.software/ns/schema/1.0",
58     "http://nodeinfo.diaspora.software/ns/schema/2.1",
59     "http://nodeinfo.diaspora.software/ns/schema/2.0",
60     "http://nodeinfo.diaspora.software/ns/schema/1.1",
61     "http://nodeinfo.diaspora.software/ns/schema/1.0",
62 ]
63
64 # HTTP headers for non-API requests
65 headers = {
66     "User-Agent": config["useragent"],
67 }
68 # HTTP headers for API requests
69 api_headers = {
70     "User-Agent": config["useragent"],
71     "Content-Type": "application/json",
72 }
73
74 # Found info from node, such as nodeinfo URL, detection mode that needs to be
75 # written to database. Both arrays must be filled at the same time or else
76 # update_instance_data() will fail
77 instance_data = {
78     # Detection mode: 'AUTO_DISCOVERY', 'STATIC_CHECKS' or 'GENERATOR'
79     # NULL means all detection methods have failed (maybe still reachable instance)
80     "detection_mode"     : {},
81     # Found nodeinfo URL
82     "nodeinfo_url"       : {},
83     # Found total peers
84     "total_peers"        : {},
85     # Last fetched instances
86     "last_instance_fetch": {},
87     # Last updated
88     "last_updated"       : {},
89     # Last blocked
90     "last_blocked"       : {},
91     # Last nodeinfo (fetched)
92     "last_nodeinfo"      : {},
93     # Last status code
94     "last_status_code"   : {},
95     # Last error details
96     "last_error_details" : {},
97 }
98
99 language_mapping = {
100     # English -> English
101     "Silenced instances"            : "Silenced servers",
102     "Suspended instances"           : "Suspended servers",
103     "Limited instances"             : "Limited servers",
104     # Mappuing German -> English
105     "Gesperrte Server"              : "Suspended servers",
106     "Gefilterte Medien"             : "Filtered media",
107     "Stummgeschaltete Server"       : "Silenced servers",
108     # Japanese -> English
109     "停止済みのサーバー"            : "Suspended servers",
110     "制限中のサーバー"              : "Limited servers",
111     "メディアを拒否しているサーバー": "Filtered media",
112     "サイレンス済みのサーバー"      : "Silenced servers",
113     # ??? -> English
114     "שרתים מושעים"                  : "Suspended servers",
115     "מדיה מסוננת"                   : "Filtered media",
116     "שרתים מוגבלים"                 : "Silenced servers",
117     # French -> English
118     "Serveurs suspendus"            : "Suspended servers",
119     "Médias filtrés"                : "Filtered media",
120     "Serveurs limités"              : "Limited servers",
121     "Serveurs modérés"              : "Limited servers",
122 }
123
124 # URL for fetching peers
125 get_peers_url = "/api/v1/instance/peers"
126
127 # Cache for redundant SQL queries
128 cache = {}
129
130 # Connect to database
131 connection = sqlite3.connect("blocks.db")
132 cursor = connection.cursor()
133
134 # Pattern instance for version numbers
135 patterns = [
136     # semantic version number (with v|V) prefix)
137     re.compile("^(?P<version>v|V{0,1})(\.{0,1})(?P<major>0|[1-9]\d*)\.(?P<minor>0+|[1-9]\d*)(\.(?P<patch>0+|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)?$"),
138     # non-sematic, e.g. 1.2.3.4
139     re.compile("^(?P<version>v|V{0,1})(\.{0,1})(?P<major>0|[1-9]\d*)\.(?P<minor>0+|[1-9]\d*)(\.(?P<patch>0+|[1-9]\d*)(\.(?P<subpatch>0|[1-9]\d*))?)$"),
140     # non-sematic, e.g. 2023-05[-dev]
141     re.compile("^(?P<year>[1-9]{1}[0-9]{3})\.(?P<month>[0-9]{2})(-dev){0,1}$"),
142     # non-semantic, e.g. abcdef0
143     re.compile("^[a-f0-9]{7}$"),
144 ]
145
146 ##### Cache #####
147
148 def is_cache_initialized(key: str) -> bool:
149     return key in cache
150
151 def set_all_cache_key(key: str, rows: list, value: any):
152     # NOISY-DEBUG: print(f"DEBUG: key='{key}',rows()={len(rows)},value[]={type(value)} - CALLED!")
153     if type(key) != str:
154         raise ValueError("Parameter key[]='{type(key)}' is not 'str'")
155     elif not is_cache_initialized(key):
156         # NOISY-DEBUG: print(f"DEBUG: Cache for key='{key}' not initialized.")
157         cache[key] = {}
158
159     for sub in rows:
160         # NOISY-DEBUG: print(f"DEBUG: Setting key='{key}',sub[{type(sub)}]='{sub}'")
161
162         if isinstance(sub, tuple):
163             cache[key][sub[0]] = value
164         else:
165             print(f"WARNING: Unsupported type row[]='{type(row)}'")
166
167     # NOISY-DEBUG: print("DEBUG: EXIT!")
168
169 def set_cache_key(key: str, sub: str, value: any):
170     if type(key) != str:
171         raise ValueError("Parameter key[]='{type(key)}' is not 'str'")
172     elif type(sub) != str:
173         raise ValueError("Parameter sub[]='{type(sub)}' is not 'str'")
174     elif not is_cache_initialized(key):
175         print(f"WARNING: Bad method call, key='{key}' is not initialized yet.")
176         raise Exception(f"Cache for key='{key}' is not initialized, but function called")
177
178     cache[key][sub] = value
179
180 def is_cache_key_set(key: str, sub: str) -> bool:
181     if type(key) != str:
182         raise ValueError("Parameter key[]='{type(key)}' is not 'str'")
183     elif type(sub) != str:
184         raise ValueError("Parameter sub[]='{type(sub)}' is not 'str'")
185     elif not is_cache_initialized(key):
186         print(f"WARNING: Bad method call, key='{key}' is not initialized yet.")
187         raise Exception(f"Cache for key='{key}' is not initialized, but function called")
188
189     return sub in cache[key]
190
191 ##### Other functions #####
192
193 def is_primitive(var: any) -> bool:
194     # NOISY-DEBUG: print(f"DEBUG: var[]='{type(var)}' - CALLED!")
195     return type(var) in {int, str, float, bool} or var == None
196
197 def set_instance_data(key: str, domain: str, value: any):
198     # NOISY-DEBUG: 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_string = "UPDATE instances SET" + sql_string + " last_updated = TIME() WHERE domain = ? LIMIT 1"
442     # DEBUG: print("DEBUG: sql_string:", sql_string)
443
444     try:
445         # DEBUG: print("DEBUG: Executing SQL:", sql_string)
446         cursor.execute(sql_string, fields)
447
448         # DEBUG: print(f"DEBUG: Success! (rowcount={cursor.rowcount })")
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_string='{sql_string}',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     if type(reason) != str and reason != None:
1043         raise ValueError(f"Parameter reason[]='{type(reason)}' is not 'str'")
1044     elif type(blocker) != str:
1045         raise ValueError(f"Parameter blocker[]='{type(blocker)}' is not 'str'")
1046     elif type(blocked) != str:
1047         raise ValueError(f"Parameter blocked[]='{type(blocked)}' is not 'str'")
1048     elif type(block_level) != str:
1049         raise ValueError(f"Parameter block_level[]='{type(block_level)}' is not 'str'")
1050
1051     # DEBUG: print("DEBUG: Updating block reason:", reason, blocker, blocked, block_level)
1052     try:
1053         cursor.execute(
1054             "UPDATE blocks SET reason = ?, last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? AND reason IN ('','unknown') LIMIT 1",
1055             (
1056                 reason,
1057                 time.time(),
1058                 blocker,
1059                 blocked,
1060                 block_level
1061             ),
1062         )
1063
1064         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
1065         if cursor.rowcount == 0:
1066             # DEBUG: print(f"DEBUG: Did not update any rows: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',reason='{reason}' - EXIT!")
1067             return
1068
1069     except BaseException as e:
1070         print(f"ERROR: failed SQL query: reason='{reason}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
1071         sys.exit(255)
1072
1073     # DEBUG: print("DEBUG: EXIT!")
1074
1075 def update_last_seen(blocker: str, blocked: str, block_level: str):
1076     # DEBUG: print("DEBUG: Updating last_seen for:", blocker, blocked, block_level)
1077     try:
1078         cursor.execute(
1079             "UPDATE blocks SET last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? LIMIT 1",
1080             (
1081                 time.time(),
1082                 blocker,
1083                 blocked,
1084                 block_level
1085             )
1086         )
1087
1088         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
1089         if cursor.rowcount == 0:
1090             # DEBUG: print(f"DEBUG: Did not update any rows: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}' - EXIT!")
1091             return
1092
1093     except BaseException as e:
1094         print(f"ERROR: failed SQL query: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
1095         sys.exit(255)
1096
1097     # DEBUG: print("DEBUG: EXIT!")
1098
1099 def block_instance(blocker: str, blocked: str, reason: str, block_level: str):
1100     # DEBUG: print("DEBUG: blocker,blocked,reason,block_level:", blocker, blocked, reason, block_level)
1101     if type(blocker) != str:
1102         raise ValueError(f"Parameter blocker[]={type(blocker)} is not 'str'")
1103     elif blocker == "":
1104         raise ValueError(f"Parameter 'blocker' cannot be empty")
1105     elif not validators.domain(blocker.split("/")[0]):
1106         raise ValueError(f"Bad blocker='{blocker}'")
1107     elif type(blocked) != str:
1108         raise ValueError(f"Parameter blocked[]={type(blocked)} is not 'str'")
1109     elif blocked == "":
1110         raise ValueError(f"Parameter 'blocked' cannot be empty")
1111     elif not validators.domain(blocked.split("/")[0]):
1112         raise ValueError(f"Bad blocked='{blocked}'")
1113     elif is_blacklisted(blocker):
1114         raise Exception(f"blocker='{blocker}' is blacklisted but function invoked")
1115     elif is_blacklisted(blocked):
1116         raise Exception(f"blocked='{blocked}' is blacklisted but function invoked")
1117
1118     print(f"INFO: New block: blocker='{blocker}',blocked='{blocked}', reason='{reason}', block_level='{block_level}'")
1119     try:
1120         cursor.execute(
1121             "INSERT INTO blocks (blocker, blocked, reason, block_level, first_seen, last_seen) VALUES(?, ?, ?, ?, ?, ?)",
1122              (
1123                  blocker,
1124                  blocked,
1125                  reason,
1126                  block_level,
1127                  time.time(),
1128                  time.time()
1129              ),
1130         )
1131     except BaseException as e:
1132         print(f"ERROR: failed SQL query: blocker='{blocker}',blocked='{blocked}',reason='{reason}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
1133         sys.exit(255)
1134
1135     # DEBUG: print("DEBUG: EXIT!")
1136
1137 def is_instance_registered(domain: str) -> bool:
1138     if type(domain) != str:
1139         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1140     elif domain == "":
1141         raise ValueError(f"Parameter 'domain' cannot be empty")
1142
1143     # NOISY-DEBUG: # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
1144     if not is_cache_initialized("is_registered"):
1145         # NOISY-DEBUG: # DEBUG: print(f"DEBUG: Cache for 'is_registered' not initialized, fetching all rows ...")
1146         try:
1147             cursor.execute("SELECT domain FROM instances")
1148
1149             # Check Set all
1150             set_all_cache_key("is_registered", cursor.fetchall(), True)
1151         except BaseException as e:
1152             print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
1153             sys.exit(255)
1154
1155     # Is cache found?
1156     registered = is_cache_key_set("is_registered", domain)
1157
1158     # NOISY-DEBUG: # DEBUG: print(f"DEBUG: registered='{registered}' - EXIT!")
1159     return registered
1160
1161 def add_instance(domain: str, origin: str, originator: str, path: str = None):
1162     if type(domain) != str:
1163         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1164     elif domain == "":
1165         raise ValueError(f"Parameter 'domain' cannot be empty")
1166     elif type(origin) != str and origin != None:
1167         raise ValueError(f"origin[]={type(origin)} is not 'str'")
1168     elif type(originator) != str:
1169         raise ValueError(f"originator[]={type(originator)} is not 'str'")
1170     elif originator == "":
1171         raise ValueError(f"originator cannot be empty")
1172     elif not validators.domain(domain.split("/")[0]):
1173         raise ValueError(f"Bad domain name='{domain}'")
1174     elif origin is not None and not validators.domain(origin.split("/")[0]):
1175         raise ValueError(f"Bad origin name='{origin}'")
1176     elif is_blacklisted(domain):
1177         raise Exception(f"domain='{domain}' is blacklisted, but method invoked")
1178
1179     # DEBUG: print("DEBUG: domain,origin,originator,path:", domain, origin, originator, path)
1180     software = determine_software(domain, path)
1181     # DEBUG: print("DEBUG: Determined software:", software)
1182
1183     print(f"INFO: Adding instance domain='{domain}' (origin='{origin}',software='{software}')")
1184     try:
1185         cursor.execute(
1186             "INSERT INTO instances (domain, origin, originator, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
1187             (
1188                domain,
1189                origin,
1190                originator,
1191                get_hash(domain),
1192                software,
1193                time.time()
1194             ),
1195         )
1196
1197         set_cache_key("is_registered", domain, True)
1198
1199         if has_pending_instance_data(domain):
1200             # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo being updated ...")
1201             set_instance_data("last_status_code"  , domain, None)
1202             set_instance_data("last_error_details", domain, None)
1203             update_instance_data(domain)
1204             remove_pending_error(domain)
1205
1206         if domain in pending_errors:
1207             # DEBUG: print("DEBUG: domain has pending error being updated:", domain)
1208             update_last_error(domain, pending_errors[domain])
1209             remove_pending_error(domain)
1210
1211     except BaseException as e:
1212         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
1213         sys.exit(255)
1214     else:
1215         # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
1216         update_last_nodeinfo(domain)
1217
1218     # DEBUG: print("DEBUG: EXIT!")
1219
1220 def send_bot_post(instance: str, blocks: dict):
1221     message = instance + " has blocked the following instances:\n\n"
1222     truncated = False
1223
1224     if len(blocks) > 20:
1225         truncated = True
1226         blocks = blocks[0 : 19]
1227
1228     for block in blocks:
1229         if block["reason"] == None or block["reason"] == '':
1230             message = message + block["blocked"] + " with unspecified reason\n"
1231         else:
1232             if len(block["reason"]) > 420:
1233                 block["reason"] = block["reason"][0:419] + "[…]"
1234
1235             message = message + block["blocked"] + ' for "' + block["reason"].replace("@", "@\u200b") + '"\n'
1236
1237     if truncated:
1238         message = message + "(the list has been truncated to the first 20 entries)"
1239
1240     botheaders = {**api_headers, **{"Authorization": "Bearer " + config["bot_token"]}}
1241
1242     req = reqto.post(
1243         f"{config['bot_instance']}/api/v1/statuses",
1244         data={
1245             "status"      : message,
1246             "visibility"  : config['bot_visibility'],
1247             "content_type": "text/plain"
1248         },
1249         headers=botheaders,
1250         timeout=10
1251     ).json()
1252
1253     return True
1254
1255 def get_mastodon_blocks(domain: str) -> dict:
1256     if type(domain) != str:
1257         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1258     elif domain == "":
1259         raise ValueError(f"Parameter 'domain' cannot be empty")
1260
1261     # DEBUG: print("DEBUG: Fetching mastodon blocks from domain:", domain)
1262     blocks = {
1263         "Suspended servers": [],
1264         "Filtered media"   : [],
1265         "Limited servers"  : [],
1266         "Silenced servers" : [],
1267     }
1268
1269     try:
1270         doc = bs4.BeautifulSoup(
1271             reqto.get(f"https://{domain}/about", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
1272             "html.parser",
1273         )
1274     except BaseException as e:
1275         print("ERROR: Cannot fetch from domain:", domain, e)
1276         update_last_error(domain, e)
1277         return {}
1278
1279     for header in doc.find_all("h3"):
1280         header_text = tidyup(header.text)
1281
1282         if header_text in language_mapping:
1283             # DEBUG: print(f"DEBUG: header_text='{header_text}'")
1284             header_text = language_mapping[header_text]
1285
1286         if header_text in blocks or header_text.lower() in blocks:
1287             # replaced find_next_siblings with find_all_next to account for instances that e.g. hide lists in dropdown menu
1288             for line in header.find_all_next("table")[0].find_all("tr")[1:]:
1289                 blocks[header_text].append(
1290                     {
1291                         "domain": tidyup(line.find("span").text),
1292                         "hash"  : tidyup(line.find("span")["title"][9:]),
1293                         "reason": tidyup(line.find_all("td")[1].text),
1294                     }
1295                 )
1296
1297     # DEBUG: print("DEBUG: Returning blocks for domain:", domain)
1298     return {
1299         "reject"        : blocks["Suspended servers"],
1300         "media_removal" : blocks["Filtered media"],
1301         "followers_only": blocks["Limited servers"] + blocks["Silenced servers"],
1302     }
1303
1304 def get_friendica_blocks(domain: str) -> dict:
1305     if type(domain) != str:
1306         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1307     elif domain == "":
1308         raise ValueError(f"Parameter 'domain' cannot be empty")
1309
1310     # DEBUG: print("DEBUG: Fetching friendica blocks from domain:", domain)
1311     blocks = []
1312
1313     try:
1314         doc = bs4.BeautifulSoup(
1315             reqto.get(f"https://{domain}/friendica", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
1316             "html.parser",
1317         )
1318     except BaseException as e:
1319         print("WARNING: Failed to fetch /friendica from domain:", domain, e)
1320         update_last_error(domain, e)
1321         return {}
1322
1323     blocklist = doc.find(id="about_blocklist")
1324
1325     # Prevents exceptions:
1326     if blocklist is None:
1327         # DEBUG: print("DEBUG: Instance has no block list:", domain)
1328         return {}
1329
1330     for line in blocklist.find("table").find_all("tr")[1:]:
1331         # DEBUG: print(f"DEBUG: line='{line}'")
1332         blocks.append({
1333             "domain": tidyup(line.find_all("td")[0].text),
1334             "reason": tidyup(line.find_all("td")[1].text)
1335         })
1336
1337     # DEBUG: print("DEBUG: Returning blocks() for domain:", domain, len(blocks))
1338     return {
1339         "reject": blocks
1340     }
1341
1342 def get_misskey_blocks(domain: str) -> dict:
1343     if type(domain) != str:
1344         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1345     elif domain == "":
1346         raise ValueError(f"Parameter 'domain' cannot be empty")
1347
1348     # DEBUG: print("DEBUG: Fetching misskey blocks from domain:", domain)
1349     blocks = {
1350         "suspended": [],
1351         "blocked"  : []
1352     }
1353
1354     offset = 0
1355     step = config["misskey_offset"]
1356     while True:
1357         # iterating through all "suspended" (follow-only in its terminology)
1358         # instances page-by-page, since that troonware doesn't support
1359         # sending them all at once
1360         try:
1361             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
1362             if offset == 0:
1363                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1364                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1365                     "sort"     : "+pubAt",
1366                     "host"     : None,
1367                     "suspended": True,
1368                     "limit"    : step
1369                 }), {"Origin": domain})
1370             else:
1371                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1372                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1373                     "sort"     : "+pubAt",
1374                     "host"     : None,
1375                     "suspended": True,
1376                     "limit"    : step,
1377                     "offset"   : offset - 1
1378                 }), {"Origin": domain})
1379
1380             # DEBUG: print("DEBUG: fetched():", len(fetched))
1381             if len(fetched) == 0:
1382                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1383                 break
1384             elif len(fetched) != config["misskey_offset"]:
1385                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1386                 offset = offset + (config["misskey_offset"] - len(fetched))
1387             else:
1388                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1389                 offset = offset + step
1390
1391             for instance in fetched:
1392                 # just in case
1393                 if instance["isSuspended"]:
1394                     blocks["suspended"].append(
1395                         {
1396                             "domain": tidyup(instance["host"]),
1397                             # no reason field, nothing
1398                             "reason": None
1399                         }
1400                     )
1401
1402         except BaseException as e:
1403             print("WARNING: Caught error, exiting loop:", domain, e)
1404             update_last_error(domain, e)
1405             offset = 0
1406             break
1407
1408     while True:
1409         # same shit, different asshole ("blocked" aka full suspend)
1410         try:
1411             if offset == 0:
1412                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1413                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1414                     "sort"   : "+pubAt",
1415                     "host"   : None,
1416                     "blocked": True,
1417                     "limit"  : step
1418                 }), {"Origin": domain})
1419             else:
1420                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1421                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1422                     "sort"   : "+pubAt",
1423                     "host"   : None,
1424                     "blocked": True,
1425                     "limit"  : step,
1426                     "offset" : offset-1
1427                 }), {"Origin": domain})
1428
1429             # DEBUG: print("DEBUG: fetched():", len(fetched))
1430             if len(fetched) == 0:
1431                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1432                 break
1433             elif len(fetched) != config["misskey_offset"]:
1434                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1435                 offset = offset + (config["misskey_offset"] - len(fetched))
1436             else:
1437                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1438                 offset = offset + step
1439
1440             for instance in fetched:
1441                 if instance["isBlocked"]:
1442                     blocks["blocked"].append({
1443                         "domain": tidyup(instance["host"]),
1444                         "reason": None
1445                     })
1446
1447         except BaseException as e:
1448             print("ERROR: Exception during POST:", domain, e)
1449             update_last_error(domain, e)
1450             offset = 0
1451             break
1452
1453     # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
1454     update_last_instance_fetch(domain)
1455
1456     # DEBUG: print("DEBUG: Returning for domain,blocked(),suspended():", domain, len(blocks["blocked"]), len(blocks["suspended"]))
1457     return {
1458         "reject"        : blocks["blocked"],
1459         "followers_only": blocks["suspended"]
1460     }
1461
1462 def tidyup(string: str) -> str:
1463     if type(string) != str:
1464         raise ValueError(f"Parameter string[]={type(string)} is not expected")
1465
1466     # some retards put their blocks in variable case
1467     string = string.lower().strip()
1468
1469     # other retards put the port
1470     string = re.sub("\:\d+$", "", string)
1471
1472     # bigger retards put the schema in their blocklist, sometimes even without slashes
1473     string = re.sub("^https?\:(\/*)", "", string)
1474
1475     # and trailing slash
1476     string = re.sub("\/$", "", string)
1477
1478     # and the @
1479     string = re.sub("^\@", "", string)
1480
1481     # the biggest retards of them all try to block individual users
1482     string = re.sub("(.+)\@", "", string)
1483
1484     return string