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