]> git.mxchange.org Git - fba.git/blob - fba.py
95d692bc6e17c3570d3ed87bd6d97d9cdfe72cdb
[fba.git] / fba.py
1 import bs4
2 import hashlib
3 import re
4 import reqto
5 import json
6 import sqlite3
7 import sys
8 import time
9 import validators
10
11 with open("config.json") as f:
12     config = json.loads(f.read())
13
14 # Don't check these, known trolls/flooders/testing/developing
15 blacklist = [
16     # Floods network with fake nodes as "research" project
17     "activitypub-troll.cf",
18     # Similar troll
19     "gab.best",
20     # Similar troll
21     "4chan.icu",
22     # Flooder (?)
23     "social.shrimpcam.pw",
24     # Flooder (?)
25     "mastotroll.netz.org",
26     # Testing/developing installations
27     "ngrok.io", "ngrok-free.app",
28 ]
29
30 # Array with pending errors needed to be written to database
31 pending_errors = {
32 }
33
34 # "rel" identifiers (no real URLs)
35 nodeinfo_identifier = [
36     "https://nodeinfo.diaspora.software/ns/schema/2.1",
37     "https://nodeinfo.diaspora.software/ns/schema/2.0",
38     "https://nodeinfo.diaspora.software/ns/schema/1.1",
39     "https://nodeinfo.diaspora.software/ns/schema/1.0",
40     "http://nodeinfo.diaspora.software/ns/schema/2.1",
41     "http://nodeinfo.diaspora.software/ns/schema/2.0",
42     "http://nodeinfo.diaspora.software/ns/schema/1.1",
43     "http://nodeinfo.diaspora.software/ns/schema/1.0",
44 ]
45
46 # HTTP headers for non-API requests
47 headers = {
48     "User-Agent": config["useragent"],
49 }
50 # HTTP headers for API requests
51 api_headers = {
52     "User-Agent": config["useragent"],
53     "Content-Type": "application/json",
54 }
55
56 # Found info from node, such as nodeinfo URL, detection mode that needs to be
57 # written to database. Both arrays must be filled at the same time or else
58 # update_nodeinfos() will fail
59 nodeinfos = {
60     # Detection mode: 'AUTO_DISCOVERY', 'STATIC_CHECKS' or 'GENERATOR'
61     # NULL means all detection methods have failed (maybe still reachable instance)
62     "detection_mode": {},
63     # Found nodeinfo URL
64     "nodeinfo_url": {},
65     # Where to fetch peers (other instances)
66     "get_peers_url": {},
67 }
68
69 language_mapping = {
70     # English -> English
71     "Silenced instances"            : "Silenced servers",
72     "Suspended instances"           : "Suspended servers",
73     "Limited instances"             : "Limited servers",
74     # Mappuing German -> English
75     "Gesperrte Server"              : "Suspended servers",
76     "Gefilterte Medien"             : "Filtered media",
77     "Stummgeschaltete Server"       : "Silenced servers",
78     # Japanese -> English
79     "停止済みのサーバー"            : "Suspended servers",
80     "制限中のサーバー"              : "Limited servers",
81     "メディアを拒否しているサーバー": "Filtered media",
82     "サイレンス済みのサーバー"      : "Silenced servers",
83     # ??? -> English
84     "שרתים מושעים"                  : "Suspended servers",
85     "מדיה מסוננת"                   : "Filtered media",
86     "שרתים מוגבלים"                 : "Silenced servers",
87     # French -> English
88     "Serveurs suspendus"            : "Suspended servers",
89     "Médias filtrés"                : "Filtered media",
90     "Serveurs limités"              : "Limited servers",
91     "Serveurs modérés"              : "Limited servers",
92 }
93
94 # URL for fetching peers
95 get_peers_url = "/api/v1/instance/peers"
96
97 # Connect to database
98 connection = sqlite3.connect("blocks.db")
99 cursor = connection.cursor()
100
101 # Pattern instance for version numbers
102 patterns = [
103     # semantic version number (with v|V) prefix)
104     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-]+)*))?)?$"),
105     # non-sematic, e.g. 1.2.3.4
106     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*))?)$"),
107     # non-sematic, e.g. 2023-05[-dev]
108     re.compile("^(?P<year>[1-9]{1}[0-9]{3})\.(?P<month>[0-9]{2})(-dev){0,1}$"),
109     # non-semantic, e.g. abcdef0
110     re.compile("^[a-f0-9]{7}$"),
111 ]
112
113 def add_peers(rows: dict) -> dict:
114     # DEBUG: print(f"DEBUG: rows()={len(rows)} - CALLED!")
115     peers = {}
116     for element in ["linked", "allowed", "blocked"]:
117         # DEBUG: print(f"DEBUG: Checking element='{element}'")
118         if element in rows and rows[element] != None:
119             # DEBUG: print(f"DEBUG: Adding {len(rows[element])} peer(s) to peers list ...")
120             peers = {**peers, **rows[element]}
121
122     # DEBUG: print(f"DEBUG: peers()={len(peers)} - CALLED!")
123     return peers
124
125 def remove_version(software: str) -> str:
126     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
127     if not "." in software and " " not in software:
128         print(f"WARNING: software='{software}' does not contain a version number.")
129         return software
130
131     temp = software
132     if ";" in software:
133         temp = software.split(";")[0]
134     elif "," in software:
135         temp = software.split(",")[0]
136     elif " - " in software:
137         temp = software.split(" - ")[0]
138
139     # DEBUG: print(f"DEBUG: software='{software}'")
140     version = None
141     if " " in software:
142         version = temp.split(" ")[-1]
143     elif "/" in software:
144         version = temp.split("/")[-1]
145     elif "-" in software:
146         version = temp.split("-")[-1]
147     else:
148         # DEBUG: print(f"DEBUG: Was not able to find common seperator, returning untouched software='{software}'")
149         return software
150
151     matches = None
152     match = None
153     # DEBUG: print(f"DEBUG: Checking {len(patterns)} patterns ...")
154     for pattern in patterns:
155         # Run match()
156         match = pattern.match(version)
157
158         # DEBUG: print(f"DEBUG: match[]={type(match)}")
159         if type(match) is re.Match:
160             break
161
162     # DEBUG: print(f"DEBUG: version[{type(version)}]='{version}',match='{match}'")
163     if type(match) is not re.Match:
164         print(f"WARNING: version='{version}' does not match regex, leaving software='{software}' untouched.")
165         return software
166
167     # DEBUG: print(f"DEBUG: Found valid version number: '{version}', removing it ...")
168     end = len(temp) - len(version) - 1
169
170     # DEBUG: print(f"DEBUG: end[{type(end)}]={end}")
171     software = temp[0:end].strip()
172     if " version" in software:
173         # DEBUG: print(f"DEBUG: software='{software}' contains word ' version'")
174         software = strip_until(software, " version")
175
176     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
177     return software
178
179 def strip_powered_by(software: str) -> str:
180     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
181     if software == "":
182         print(f"ERROR: Bad method call, 'software' is empty")
183         raise Exception("Parameter 'software' is empty")
184     elif not "powered by" in software:
185         print(f"WARNING: Cannot find 'powered by' in '{software}'!")
186         return software
187
188     start = software.find("powered by ")
189     # DEBUG: print(f"DEBUG: start[{type(start)}]='{start}'")
190
191     software = software[start + 11:].strip()
192     # DEBUG: print(f"DEBUG: software='{software}'")
193
194     software = strip_until(software, " - ")
195
196     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
197     return software
198
199 def strip_until(software: str, until: str) -> str:
200     # DEBUG: print(f"DEBUG: software='{software}',until='{until}' - CALLED!")
201     if software == "":
202         print(f"ERROR: Bad method call, 'software' is empty")
203         raise Exception("Parameter 'software' is empty")
204     elif until == "":
205         print(f"ERROR: Bad method call, 'until' is empty")
206         raise Exception("Parameter 'until' is empty")
207     elif not until in software:
208         print(f"WARNING: Cannot find 'powered by' in '{software}'!")
209         return software
210
211     # Next, strip until part
212     end = software.find(until)
213
214     # DEBUG: print(f"DEBUG: end[{type(end)}]='{end}'")
215     if end > 0:
216         software = software[0:end].strip()
217
218     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
219     return software
220
221 def is_blacklisted(domain: str) -> bool:
222     blacklisted = False
223     for peer in blacklist:
224         if peer in domain:
225             blacklisted = True
226
227     return blacklisted
228
229 def remove_pending_error(domain: str):
230     try:
231         # Prevent updating any pending errors, nodeinfo was found
232         del pending_errors[domain]
233
234     except:
235         pass
236
237 def get_hash(domain: str) -> str:
238     return hashlib.sha256(domain.encode("utf-8")).hexdigest()
239
240 def update_last_blocked(domain: str):
241     # DEBUG: print("DEBUG: Updating last_blocked for domain", domain)
242     try:
243         cursor.execute("UPDATE instances SET last_blocked = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
244             time.time(),
245             time.time(),
246             domain
247         ])
248
249         if cursor.rowcount == 0:
250             print("WARNING: Did not update any rows:", domain)
251
252     except BaseException as e:
253         print(f"ERROR: failed SQL query: domain='{domain}',exception:'{str(e)}'")
254         sys.exit(255)
255
256     # DEBUG: print("DEBUG: EXIT!")
257
258 def update_nodeinfos(domain: str):
259     # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
260     sql_string = ''
261     fields = list()
262     for key in nodeinfos:
263         # DEBUG: print("DEBUG: key:", key)
264         if domain in nodeinfos[key]:
265            # DEBUG: print(f"DEBUG: Adding '{nodeinfos[key][domain]}' for key='{key}' ...")
266            fields.append(nodeinfos[key][domain])
267            sql_string += f" {key} = ?,"
268
269     fields.append(domain)
270     # DEBUG: print(f"DEBUG: sql_string='{sql_string}',fields()={len(fields)}")
271
272     sql = "UPDATE instances SET" + sql_string + " last_status_code = NULL, last_error_details = NULL WHERE domain = ? LIMIT 1"
273     # DEBUG: print("DEBUG: sql:", sql)
274
275     try:
276         # DEBUG: print("DEBUG: Executing SQL:", sql)
277         cursor.execute(sql, fields)
278         # DEBUG: print(f"DEBUG: Success! (rowcount={cursor.rowcount })")
279
280         if cursor.rowcount == 0:
281             print("WARNING: Did not update any rows:", domain)
282
283     except BaseException as e:
284         print(f"ERROR: failed SQL query: domain='{domain}',sql='{sql}',exception:'{str(e)}'")
285         sys.exit(255)
286
287     # DEBUG: print("DEBUG: Deleting nodeinfos for domain:", domain)
288     for key in nodeinfos:
289         try:
290             # DEBUG: print("DEBUG: Deleting key:", key)
291             del nodeinfos[key][domain]
292         except:
293             pass
294
295     # DEBUG: print("DEBUG: EXIT!")
296
297 def update_last_error(domain: str, res: any):
298     # DEBUG: print("DEBUG: domain,res[]:", domain, type(res))
299     try:
300         # DEBUG: print("DEBUG: BEFORE res[]:", type(res))
301         if isinstance(res, BaseException) or isinstance(res, json.JSONDecodeError):
302             res = str(res)
303
304         # DEBUG: print("DEBUG: AFTER res[]:", type(res))
305         if type(res) is str:
306             # DEBUG: print(f"DEBUG: Setting last_error_details='{res}'");
307             cursor.execute("UPDATE instances SET last_status_code = 999, last_error_details = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
308                 res,
309                 time.time(),
310                 domain
311             ])
312         else:
313             # DEBUG: print(f"DEBUG: Setting last_error_details='{res.reason}'");
314             cursor.execute("UPDATE instances SET last_status_code = ?, last_error_details = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
315                 res.status_code,
316                 res.reason,
317                 time.time(),
318                 domain
319             ])
320
321         if cursor.rowcount == 0:
322             # DEBUG: print("DEBUG: Did not update any rows:", domain)
323             pending_errors[domain] = res
324
325     except BaseException as e:
326         print(f"ERROR: failed SQL query: domain='{domain}',exception:'{str(e)}'")
327         sys.exit(255)
328
329     # DEBUG: print("DEBUG: EXIT!")
330
331 def update_last_nodeinfo(domain: str):
332     # DEBUG: print("DEBUG: Updating last_nodeinfo for domain:", domain)
333     try:
334         cursor.execute("UPDATE instances SET last_nodeinfo = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
335             time.time(),
336             time.time(),
337             domain
338         ])
339
340         if cursor.rowcount == 0:
341             print("WARNING: Did not update any rows:", domain)
342
343     except BaseException as e:
344         print(f"ERROR: failed SQL query: domain='{domain}',exception:'{str(e)}'")
345         sys.exit(255)
346
347     connection.commit()
348     # DEBUG: print("DEBUG: EXIT!")
349
350 def get_peers(domain: str, software: str) -> list:
351     # DEBUG: print("DEBUG: Getting peers for domain:", domain, software)
352     peers = list()
353
354     if software == "misskey":
355         # DEBUG: print(f"DEBUG: domain='{domain}' is misskey, sending API POST request ...")
356
357         offset = 0
358         step = config["misskey_offset"]
359         # iterating through all "suspended" (follow-only in its terminology)
360         # instances page-by-page, since that troonware doesn't support
361         # sending them all at once
362         while True:
363             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
364             if offset == 0:
365                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
366                     "sort" : "+pubAt",
367                     "host" : None,
368                     "limit": step
369                 }), {"Origin": domain})
370             else:
371                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
372                     "sort"  : "+pubAt",
373                     "host"  : None,
374                     "limit" : step,
375                     "offset": offset - 1
376                 }), {"Origin": domain})
377
378             # DEBUG: print("DEBUG: fetched():", len(fetched))
379             if len(fetched) == 0:
380                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
381                 break
382             elif len(fetched) != config["misskey_offset"]:
383                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
384                 offset = offset + (config["misskey_offset"] - len(fetched))
385             else:
386                 # DEBUG: print("DEBUG: Raising offset by step:", step)
387                 offset = offset + step
388
389             # Check records
390             for row in fetched:
391                 # DEBUG: print(f"DEBUG: row():{len(row)}")
392                 if not "host" in row:
393                     print(f"WARNING: row()={len(row)} does not contain element 'host': {row},domain='{domain}'")
394                     continue
395                 elif "host" in row and is_blacklisted(row["host"]):
396                     # DEBUG: print(f"DEBUG: row[host]='{row['host']}' is blacklisted. domain='{domain}'")
397                     continue
398
399                 # DEBUG: print(f"DEBUG: Adding peer: '{row['host']}'")
400                 peers.append(row["host"])
401
402         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
403         return peers
404     elif software == "lemmy":
405         # DEBUG: print(f"DEBUG: domain='{domain}' is Lemmy, fetching JSON ...")
406         try:
407             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
408
409             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',res.json[]='{type(res.json())}'")
410             if not res.ok or res.status_code >= 400:
411                 print("WARNING: Could not reach any JSON API:", domain)
412                 update_last_error(domain, res)
413             elif res.ok and isinstance(res.json(), list):
414                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{res.json()}'")
415                 sys.exit(255)
416             elif "federated_instances" in res.json():
417                 # DEBUG: print("DEBUG: Found federated_instances", domain)
418                 peers = peers + add_peers(res.json()["federated_instances"])
419             else:
420                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
421                 update_last_error(domain, res)
422
423         except BaseException as e:
424             print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception:'{str(e)}'")
425
426         update_last_nodeinfo(domain)
427
428         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
429         return peers
430     elif software == "peertube":
431         # DEBUG: print(f"DEBUG: domain='{domain}' is a PeerTube, fetching JSON ...")
432
433         start = 0
434         for mode in ["followers", "following"]:
435             # DEBUG: print(f"DEBUG: domain='{domain}',mode='{mode}'")
436             while True:
437                 try:
438                     res = reqto.get(f"https://{domain}/api/v1/server/{mode}?start={start}&count=100", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
439
440                     # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',res.json[]='{type(res.json())}'")
441                     if res.ok and isinstance(res.json(), dict):
442                         # DEBUG: print("DEBUG: Success, res.json():", len(res.json()))
443                         data = res.json()
444
445                         if "data" in data:
446                             # DEBUG: print(f"DEBUG: Found {len(data['data'])} record(s).")
447                             for record in data["data"]:
448                                 # DEBUG: print(f"DEBUG: record()={len(record)}")
449                                 if mode in record and "host" in record[mode]:
450                                     # DEBUG: print(f"DEBUG: Found host={record[mode]['host']}, adding ...")
451                                     peers.append(record[mode]["host"])
452                                 else:
453                                     print(f"WARNING: record from '{domain}' has no '{mode}' or 'host' record: {record}")
454
455                             if len(data["data"]) < 100:
456                                 # DEBUG: print("DEBUG: Reached end of JSON response:", domain)
457                                 break
458
459                         # Continue with next row
460                         start = start + 100
461
462                 except BaseException as e:
463                     print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception:'{str(e)}'")
464
465             update_last_nodeinfo(domain)
466
467             # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
468             return peers
469
470     # DEBUG: print(f"DEBUG: Fetching get_peers_url='{get_peers_url}' from '{domain}' ...")
471     try:
472         res = reqto.get(f"https://{domain}{get_peers_url}", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
473
474         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},res.json[]='{type(res.json())}'")
475         if not res.ok or res.status_code >= 400:
476             # DEBUG: print(f"DEBUG: Was not able to fetch '{get_peers_url}', trying alternative ...")
477             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
478
479             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},res.json[]='{type(res.json())}'")
480             if not res.ok or res.status_code >= 400:
481                 print("WARNING: Could not reach any JSON API:", domain)
482                 update_last_error(domain, res)
483             elif res.ok and isinstance(res.json(), list):
484                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{res.json()}'")
485                 sys.exit(255)
486             elif "federated_instances" in res.json():
487                 # DEBUG: print("DEBUG: Found federated_instances", domain)
488                 peers = peers + add_peers(res.json()["federated_instances"])
489             else:
490                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
491                 update_last_error(domain, res)
492         else:
493             # DEBUG: print("DEBUG: Querying API was successful:", domain, len(res.json()))
494             peers = res.json()
495             nodeinfos["get_peers_url"][domain] = get_peers_url
496
497     except BaseException as e:
498         print("WARNING: Some error during get():", domain, e)
499         update_last_error(domain, e)
500
501     update_last_nodeinfo(domain)
502
503     # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
504     return peers
505
506 def post_json_api(domain: str, path: str, parameter: str, extra_headers: dict = {}) -> list:
507     # DEBUG: print("DEBUG: Sending POST to domain,path,parameter:", domain, path, parameter, extra_headers)
508     data = list()
509     try:
510         res = reqto.post(f"https://{domain}{path}", data=parameter, headers={**api_headers, **extra_headers}, timeout=(config["connection_timeout"], config["read_timeout"]))
511
512         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},res.json[]='{type(res.json())}'")
513         if not res.ok or res.status_code >= 400:
514             print(f"WARNING: Cannot query JSON API: domain='{domain}',path='{path}',parameter()={len(parameter)},res.status_code='{res.status_code}',res.json[]='{type(res.json())}'")
515             update_last_error(domain, res)
516         else:
517             update_last_nodeinfo(domain)
518             data = res.json()
519
520     except BaseException as e:
521         print(f"WARNING: Some error during post(): domain='{domain}',path='{path}',parameter()={len(parameter)},exception:'{str(e)}'")
522
523     # DEBUG: print("DEBUG: Returning data():", len(data))
524     return data
525
526 def fetch_nodeinfo(domain: str) -> list:
527     # DEBUG: print("DEBUG: Fetching nodeinfo from domain:", domain)
528
529     nodeinfo = fetch_wellknown_nodeinfo(domain)
530     # DEBUG: print("DEBUG: nodeinfo:", len(nodeinfo))
531
532     if len(nodeinfo) > 0:
533         # DEBUG: print("DEBUG: Returning auto-discovered nodeinfo:", len(nodeinfo))
534         return nodeinfo
535
536     requests = [
537        f"https://{domain}/nodeinfo/2.1.json",
538        f"https://{domain}/nodeinfo/2.1",
539        f"https://{domain}/nodeinfo/2.0.json",
540        f"https://{domain}/nodeinfo/2.0",
541        f"https://{domain}/nodeinfo/1.0",
542        f"https://{domain}/api/v1/instance"
543     ]
544
545     data = {}
546     for request in requests:
547         try:
548             # DEBUG: print("DEBUG: Fetching request:", request)
549             res = reqto.get(request, headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
550
551             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},res.json[]='{type(res.json())}'")
552             if res.ok and isinstance(res.json(), dict):
553                 # DEBUG: print("DEBUG: Success:", request)
554                 data = res.json()
555                 nodeinfos["detection_mode"][domain] = "STATIC_CHECK"
556                 nodeinfos["nodeinfo_url"][domain] = request
557                 break
558             elif res.ok and isinstance(res.json(), list):
559                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{res.json()}'")
560                 sys.exit(255)
561             elif not res.ok or res.status_code >= 400:
562                 print("WARNING: Failed fetching nodeinfo from domain:", domain)
563                 update_last_error(domain, res)
564                 continue
565
566         except BaseException as e:
567             # DEBUG: print("DEBUG: Cannot fetch API request:", request)
568             update_last_error(domain, e)
569             pass
570
571     # DEBUG: print("DEBUG: Returning data[]:", type(data))
572     return data
573
574 def fetch_wellknown_nodeinfo(domain: str) -> list:
575     # DEBUG: print("DEBUG: Fetching .well-known info for domain:", domain)
576     data = {}
577
578     try:
579         res = reqto.get(f"https://{domain}/.well-known/nodeinfo", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
580         # DEBUG: print("DEBUG: domain,res.ok,res.json[]:", domain, res.ok, type(res.json()))
581         if res.ok and isinstance(res.json(), dict):
582             nodeinfo = res.json()
583             # DEBUG: print("DEBUG: Found entries:", len(nodeinfo), domain)
584             if "links" in nodeinfo:
585                 # DEBUG: print("DEBUG: Found links in nodeinfo():", len(nodeinfo["links"]))
586                 for link in nodeinfo["links"]:
587                     # DEBUG: print("DEBUG: rel,href:", link["rel"], link["href"])
588                     if link["rel"] in nodeinfo_identifier:
589                         # DEBUG: print("DEBUG: Fetching nodeinfo from:", link["href"])
590                         res = reqto.get(link["href"])
591
592                         # DEBUG: print("DEBUG: href,res.ok,res.status_code:", link["href"], res.ok, res.status_code)
593                         if res.ok and isinstance(res.json(), dict):
594                             # DEBUG: print("DEBUG: Found JSON nodeinfo():", len(res.json()))
595                             data = res.json()
596                             nodeinfos["detection_mode"][domain] = "AUTO_DISCOVERY"
597                             nodeinfos["nodeinfo_url"][domain] = link["href"]
598                             break
599                     else:
600                         print("WARNING: Unknown 'rel' value:", domain, link["rel"])
601             else:
602                 print("WARNING: nodeinfo does not contain 'links':", domain)
603
604     except BaseException as e:
605         print("WARNING: Failed fetching .well-known info:", domain)
606         update_last_error(domain, e)
607         pass
608
609     # DEBUG: print("DEBUG: Returning data[]:", type(data))
610     return data
611
612 def fetch_generator_from_path(domain: str, path: str = "/") -> str:
613     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}' - CALLED!")
614     software = None
615
616     try:
617         # DEBUG: print(f"DEBUG: Fetching path='{path}' from '{domain}' ...")
618         res = reqto.get(f"https://{domain}{path}", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"]))
619
620         # DEBUG: print("DEBUG: domain,res.ok,res.status_code,res.text[]:", domain, res.ok, res.status_code, type(res.text))
621         if res.ok and res.status_code < 300 and len(res.text) > 0:
622             # DEBUG: print("DEBUG: Search for <meta name='generator'>:", domain)
623             doc = bs4.BeautifulSoup(res.text, "html.parser")
624
625             # DEBUG: print("DEBUG: doc[]:", type(doc))
626             tag = doc.find("meta", {"name": "generator"})
627
628             # DEBUG: print(f"DEBUG: tag[{type(tag)}: {tag}")
629             if isinstance(tag, bs4.element.Tag):
630                 # DEBUG: print("DEBUG: Found generator meta tag: ", domain)
631                 software = tidyup(tag.get("content"))
632                 print(f"INFO: domain='{domain}' is generated by '{software}'")
633                 nodeinfos["detection_mode"][domain] = "GENERATOR"
634                 remove_pending_error(domain)
635
636     except BaseException as e:
637         # DEBUG: print(f"DEBUG: Cannot fetch / from '{domain}':", e)
638         update_last_error(domain, e)
639         pass
640
641     # DEBUG: print(f"DEBUG: software[]={type(software)}")
642     if type(software) is str and software == "":
643         # DEBUG: print(f"DEBUG: Corrected empty string to None for software of domain='{domain}'")
644         software = None
645     elif type(software) is str and ("." in software or " " in software):
646         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
647         software = remove_version(software)
648
649     # DEBUG: print(f"DEBUG: software[]={type(software)}")
650     if type(software) is str and "powered by" in software:
651         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
652         software = remove_version(strip_powered_by(software))
653     elif type(software) is str and " by " in software:
654         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
655         software = strip_until(software, " by ")
656     elif type(software) is str and " see " in software:
657         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
658         software = strip_until(software, " see ")
659
660     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
661     return software
662
663 def determine_software(domain: str) -> str:
664     # DEBUG: print("DEBUG: Determining software for domain:", domain)
665     software = None
666
667     # DEBUG: print(f"DEBUG: Fetching nodeinfo from '{domain}' ...")
668     data = fetch_nodeinfo(domain)
669
670     # DEBUG: print("DEBUG: data[]:", type(data))
671     if not isinstance(data, dict) or len(data) == 0:
672         # DEBUG: print("DEBUG: Could not determine software type:", domain)
673         return fetch_generator_from_path(domain)
674
675     # DEBUG: print("DEBUG: data():", len(data), data)
676     if "status" in data and data["status"] == "error" and "message" in data:
677         print("WARNING: JSON response is an error:", data["message"])
678         update_last_error(domain, data["message"])
679         return fetch_generator_from_path(domain)
680     elif "software" not in data or "name" not in data["software"]:
681         # DEBUG: print(f"DEBUG: JSON response from {domain} does not include [software][name], fetching / ...")
682         software = fetch_generator_from_path(domain)
683
684         # DEBUG: print(f"DEBUG: Generator for domain='{domain}' is: {software}, EXIT!")
685         return software
686
687     software = tidyup(data["software"]["name"])
688
689     # DEBUG: print("DEBUG: sofware after tidyup():", software)
690     if software in ["akkoma", "rebased"]:
691         # DEBUG: print("DEBUG: Setting pleroma:", domain, software)
692         software = "pleroma"
693     elif software in ["hometown", "ecko"]:
694         # DEBUG: print("DEBUG: Setting mastodon:", domain, software)
695         software = "mastodon"
696     elif software in ["calckey", "groundpolis", "foundkey", "cherrypick", "meisskey"]:
697         # DEBUG: print("DEBUG: Setting misskey:", domain, software)
698         software = "misskey"
699     elif software.find("/") > 0:
700         print("WARNING: Spliting of slash:", software)
701         software = software.split("/")[-1];
702     elif software.find("|") > 0:
703         print("WARNING: Spliting of pipe:", software)
704         software = tidyup(software.split("|")[0]);
705     elif "powered by" in software:
706         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
707         software = strip_powered_by(software)
708     elif type(software) is str and " by " in software:
709         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
710         software = strip_until(software, " by ")
711     elif type(software) is str and " see " in software:
712         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
713         software = strip_until(software, " see ")
714
715     # DEBUG: print(f"DEBUG: software[]={type(software)}")
716     if software == "":
717         print("WARNING: tidyup() left no software name behind:", domain)
718         software = None
719
720     # DEBUG: print(f"DEBUG: software[]={type(software)}")
721     if str(software) == "":
722         # DEBUG: print(f"DEBUG: software for '{domain}' was not detected, trying generator ...")
723         software = fetch_generator_from_path(domain)
724     elif len(str(software)) > 0 and ("." in software or " " in software):
725         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
726         software = remove_version(software)
727
728     # DEBUG: print(f"DEBUG: software[]={type(software)}")
729     if type(software) is str and "powered by" in software:
730         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
731         software = remove_version(strip_powered_by(software))
732
733     # DEBUG: print("DEBUG: Returning domain,software:", domain, software)
734     return software
735
736 def update_block_reason(reason: str, blocker: str, blocked: str, block_level: str):
737     # DEBUG: print("DEBUG: Updating block reason:", reason, blocker, blocked, block_level)
738     try:
739         cursor.execute(
740             "UPDATE blocks SET reason = ?, last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? AND reason = ''",
741             (
742                 reason,
743                 time.time(),
744                 blocker,
745                 blocked,
746                 block_level
747             ),
748         )
749
750         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
751         if cursor.rowcount == 0:
752             print("WARNING: Did not update any rows:", domain)
753
754     except BaseException as e:
755         print(f"ERROR: failed SQL query: reason='{reason}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',sql='{sql}',exception:'{str(e)}'")
756         sys.exit(255)
757
758     # DEBUG: print("DEBUG: EXIT!")
759
760 def update_last_seen(blocker: str, blocked: str, block_level: str):
761     # DEBUG: print("DEBUG: Updating last_seen for:", blocker, blocked, block_level)
762     try:
763         cursor.execute(
764             "UPDATE blocks SET last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ?",
765             (
766                 time.time(),
767                 blocker,
768                 blocked,
769                 block_level
770             )
771         )
772
773         if cursor.rowcount == 0:
774             print("WARNING: Did not update any rows:", domain)
775
776     except BaseException as e:
777         print(f"ERROR: failed SQL query: last_seen='{last_seen}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception:'{str(e)}'")
778         sys.exit(255)
779
780     # DEBUG: print("DEBUG: EXIT!")
781
782 def block_instance(blocker: str, blocked: str, reason: str, block_level: str):
783     # DEBUG: print("DEBUG: blocker,blocked,reason,block_level:", blocker, blocked, reason, block_level)
784     if not validators.domain(blocker.split("/")[0]):
785         print("WARNING: Bad blocker:", blocker)
786         raise
787     elif not validators.domain(blocked.split("/")[0]):
788         print("WARNING: Bad blocked:", blocked)
789         raise
790
791     print("INFO: New block:", blocker, blocked, reason, block_level, first_seen, last_seen)
792     try:
793         cursor.execute(
794             "INSERT INTO blocks (blocker, blocked, reason, block_level, first_seen, last_seen) VALUES(?, ?, ?, ?, ?, ?)",
795              (
796                  blocker,
797                  blocked,
798                  reason,
799                  block_level,
800                  time.time(),
801                  time.time()
802              ),
803         )
804
805     except BaseException as e:
806         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:'{str(e)}'")
807         sys.exit(255)
808
809     # DEBUG: print("DEBUG: EXIT!")
810
811 def is_instance_registered(domain: str) -> bool:
812     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
813     # Default is not registered
814     registered = False
815
816     try:
817         cursor.execute(
818             "SELECT rowid FROM instances WHERE domain = ? LIMIT 1", [domain]
819         )
820
821         # Check condition
822         registered = cursor.fetchone() != None
823     except BaseException as e:
824         print(f"ERROR: failed SQL query: last_seen='{last_seen}'blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',first_seen='{first_seen}',last_seen='{last_seen}',exception:'{str(e)}'")
825         sys.exit(255)
826
827     # DEBUG: print(f"DEBUG: registered='{registered}' - EXIT!")
828     return registered
829
830 def add_instance(domain: str, origin: str, originator: str):
831     # DEBUG: print("DEBUG: domain,origin:", domain, origin, originator)
832     if not validators.domain(domain.split("/")[0]):
833         print("WARNING: Bad domain name:", domain)
834         raise
835     elif origin is not None and not validators.domain(origin.split("/")[0]):
836         print("WARNING: Bad origin name:", origin)
837         raise
838
839     software = determine_software(domain)
840     # DEBUG: print("DEBUG: Determined software:", software)
841
842     print(f"INFO: Adding instance {domain} (origin: {origin})")
843     try:
844         cursor.execute(
845             "INSERT INTO instances (domain, origin, originator, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
846             (
847                domain,
848                origin,
849                originator,
850                get_hash(domain),
851                software,
852                time.time()
853             ),
854         )
855
856         for key in nodeinfos:
857             # DEBUG: print(f"DEBUG: key='{key}',domain='{domain}',nodeinfos[key]={nodeinfos[key]}")
858             if domain in nodeinfos[key]:
859                 # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo being updated ...")
860                 update_nodeinfos(domain)
861                 remove_pending_error(domain)
862                 break
863
864         if domain in pending_errors:
865             # DEBUG: print("DEBUG: domain has pending error being updated:", domain)
866             update_last_error(domain, pending_errors[domain])
867             remove_pending_error(domain)
868
869     except BaseException as e:
870         print(f"ERROR: failed SQL query: domain='{domain}',exception:'{str(e)}'")
871         sys.exit(255)
872     else:
873         # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
874         update_last_nodeinfo(domain)
875
876     # DEBUG: print("DEBUG: EXIT!")
877
878 def send_bot_post(instance: str, blocks: dict):
879     message = instance + " has blocked the following instances:\n\n"
880     truncated = False
881
882     if len(blocks) > 20:
883         truncated = True
884         blocks = blocks[0 : 19]
885
886     for block in blocks:
887         if block["reason"] == None or block["reason"] == '':
888             message = message + block["blocked"] + " with unspecified reason\n"
889         else:
890             if len(block["reason"]) > 420:
891                 block["reason"] = block["reason"][0:419] + "[…]"
892
893             message = message + block["blocked"] + ' for "' + block["reason"].replace("@", "@\u200b") + '"\n'
894
895     if truncated:
896         message = message + "(the list has been truncated to the first 20 entries)"
897
898     botheaders = {**api_headers, **{"Authorization": "Bearer " + config["bot_token"]}}
899
900     req = reqto.post(
901         f"{config['bot_instance']}/api/v1/statuses",
902         data={
903             "status"      : message,
904             "visibility"  : config['bot_visibility'],
905             "content_type": "text/plain"
906         },
907         headers=botheaders,
908         timeout=10
909     ).json()
910
911     return True
912
913 def get_mastodon_blocks(domain: str) -> dict:
914     # DEBUG: print("DEBUG: Fetching mastodon blocks from domain:", domain)
915     blocks = {
916         "Suspended servers": [],
917         "Filtered media"   : [],
918         "Limited servers"  : [],
919         "Silenced servers" : [],
920     }
921
922     try:
923         doc = bs4.BeautifulSoup(
924             reqto.get(f"https://{domain}/about/more", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
925             "html.parser",
926         )
927     except BaseException as e:
928         print("ERROR: Cannot fetch from domain:", domain, e)
929         update_last_error(domain, e)
930         return {}
931
932     for header in doc.find_all("h3"):
933         header_text = tidyup(header.text)
934
935         if header_text in language_mapping:
936             # DEBUG: print(f"DEBUG: header_text='{header_text}'")
937             header_text = language_mapping[header_text]
938
939         if header_text in blocks or header_text.lower() in blocks:
940             # replaced find_next_siblings with find_all_next to account for instances that e.g. hide lists in dropdown menu
941             for line in header.find_all_next("table")[0].find_all("tr")[1:]:
942                 blocks[header_text].append(
943                     {
944                         "domain": tidyup(line.find("span").text),
945                         "hash"  : tidyup(line.find("span")["title"][9:]),
946                         "reason": tidyup(line.find_all("td")[1].text),
947                     }
948                 )
949
950     # DEBUG: print("DEBUG: Returning blocks for domain:", domain)
951     return {
952         "reject"        : blocks["Suspended servers"],
953         "media_removal" : blocks["Filtered media"],
954         "followers_only": blocks["Limited servers"] + blocks["Silenced servers"],
955     }
956
957 def get_friendica_blocks(domain: str) -> dict:
958     # DEBUG: print("DEBUG: Fetching friendica blocks from domain:", domain)
959     blocks = []
960
961     try:
962         doc = bs4.BeautifulSoup(
963             reqto.get(f"https://{domain}/friendica", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
964             "html.parser",
965         )
966     except BaseException as e:
967         print("WARNING: Failed to fetch /friendica from domain:", domain, e)
968         update_last_error(domain, e)
969         return {}
970
971     blocklist = doc.find(id="about_blocklist")
972
973     # Prevents exceptions:
974     if blocklist is None:
975         # DEBUG: print("DEBUG:Instance has no block list:", domain)
976         return {}
977
978     for line in blocklist.find("table").find_all("tr")[1:]:
979         # DEBUG: print(f"DEBUG: line='{line}'")
980         blocks.append({
981             "domain": tidyup(line.find_all("td")[0].text),
982             "reason": tidyup(line.find_all("td")[1].text)
983         })
984
985     # DEBUG: print("DEBUG: Returning blocks() for domain:", domain, len(blocks))
986     return {
987         "reject": blocks
988     }
989
990 def get_misskey_blocks(domain: str) -> dict:
991     # DEBUG: print("DEBUG: Fetching misskey blocks from domain:", domain)
992     blocks = {
993         "suspended": [],
994         "blocked"  : []
995     }
996
997     offset = 0
998     step = config["misskey_offset"]
999     while True:
1000         # iterating through all "suspended" (follow-only in its terminology)
1001         # instances page-by-page, since that troonware doesn't support
1002         # sending them all at once
1003         try:
1004             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
1005             if offset == 0:
1006                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1007                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1008                     "sort"     : "+pubAt",
1009                     "host"     : None,
1010                     "suspended": True,
1011                     "limit"    : step
1012                 }), {"Origin": domain})
1013             else:
1014                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1015                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1016                     "sort"     : "+pubAt",
1017                     "host"     : None,
1018                     "suspended": True,
1019                     "limit"    : step,
1020                     "offset"   : offset - 1
1021                 }), {"Origin": domain})
1022
1023             # DEBUG: print("DEBUG: fetched():", len(fetched))
1024             if len(fetched) == 0:
1025                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1026                 break
1027             elif len(fetched) != config["misskey_offset"]:
1028                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1029                 offset = offset + (config["misskey_offset"] - len(fetched))
1030             else:
1031                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1032                 offset = offset + step
1033
1034             for instance in fetched:
1035                 # just in case
1036                 if instance["isSuspended"]:
1037                     blocks["suspended"].append(
1038                         {
1039                             "domain": tidyup(instance["host"]),
1040                             # no reason field, nothing
1041                             "reason": None
1042                         }
1043                     )
1044
1045         except BaseException as e:
1046             print("WARNING: Caught error, exiting loop:", domain, e)
1047             update_last_error(domain, e)
1048             offset = 0
1049             break
1050
1051     while True:
1052         # same shit, different asshole ("blocked" aka full suspend)
1053         try:
1054             if offset == 0:
1055                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1056                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1057                     "sort"   : "+pubAt",
1058                     "host"   : None,
1059                     "blocked": True,
1060                     "limit"  : step
1061                 }), {"Origin": domain})
1062             else:
1063                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1064                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1065                     "sort"   : "+pubAt",
1066                     "host"   : None,
1067                     "blocked": True,
1068                     "limit"  : step,
1069                     "offset" : offset-1
1070                 }), {"Origin": domain})
1071
1072             # DEBUG: print("DEBUG: fetched():", len(fetched))
1073             if len(fetched) == 0:
1074                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1075                 break
1076             elif len(fetched) != config["misskey_offset"]:
1077                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1078                 offset = offset + (config["misskey_offset"] - len(fetched))
1079             else:
1080                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1081                 offset = offset + step
1082
1083             for instance in fetched:
1084                 if instance["isBlocked"]:
1085                     blocks["blocked"].append({
1086                         "domain": tidyup(instance["host"]),
1087                         "reason": None
1088                     })
1089
1090         except BaseException as e:
1091             print("ERROR: Exception during POST:", domain, e)
1092             update_last_error(domain, e)
1093             offset = 0
1094             break
1095
1096     # DEBUG: print("DEBUG: Returning for domain,blocked(),suspended():", domain, len(blocks["blocked"]), len(blocks["suspended"]))
1097     return {
1098         "reject"        : blocks["blocked"],
1099         "followers_only": blocks["suspended"]
1100     }
1101
1102 def tidyup(string: str) -> str:
1103     # some retards put their blocks in variable case
1104     string = string.lower().strip()
1105
1106     # other retards put the port
1107     string = re.sub("\:\d+$", "", string)
1108
1109     # bigger retards put the schema in their blocklist, sometimes even without slashes
1110     string = re.sub("^https?\:(\/*)", "", string)
1111
1112     # and trailing slash
1113     string = re.sub("\/$", "", string)
1114
1115     # and the @
1116     string = re.sub("^\@", "", string)
1117
1118     # the biggest retards of them all try to block individual users
1119     string = re.sub("(.+)\@", "", string)
1120
1121     return string