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