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