]> git.mxchange.org Git - fba.git/blob - fba.py
Continued:
[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_instance_fetch(domain: str):
332     #print("DEBUG: Updating last_instance_fetch for domain:", domain)
333     try:
334         cursor.execute("UPDATE instances SET last_instance_fetch = ?, 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     #print("DEBUG: EXIT!")
349
350 def update_last_nodeinfo(domain: str):
351     # DEBUG: print("DEBUG: Updating last_nodeinfo for domain:", domain)
352     try:
353         cursor.execute("UPDATE instances SET last_nodeinfo = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
354             time.time(),
355             time.time(),
356             domain
357         ])
358
359         if cursor.rowcount == 0:
360             print("WARNING: Did not update any rows:", domain)
361
362     except BaseException as e:
363         print(f"ERROR: failed SQL query: domain='{domain}',exception:'{str(e)}'")
364         sys.exit(255)
365
366     connection.commit()
367     # DEBUG: print("DEBUG: EXIT!")
368
369 def get_peers(domain: str, software: str) -> list:
370     # DEBUG: print("DEBUG: Getting peers for domain:", domain, software)
371     peers = list()
372
373     if software == "misskey":
374         # DEBUG: print(f"DEBUG: domain='{domain}' is misskey, sending API POST request ...")
375
376         offset = 0
377         step = config["misskey_offset"]
378         # iterating through all "suspended" (follow-only in its terminology)
379         # instances page-by-page, since that troonware doesn't support
380         # sending them all at once
381         while True:
382             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
383             if offset == 0:
384                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
385                     "sort" : "+pubAt",
386                     "host" : None,
387                     "limit": step
388                 }), {"Origin": domain})
389             else:
390                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
391                     "sort"  : "+pubAt",
392                     "host"  : None,
393                     "limit" : step,
394                     "offset": offset - 1
395                 }), {"Origin": domain})
396
397             # DEBUG: print("DEBUG: fetched():", len(fetched))
398             if len(fetched) == 0:
399                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
400                 break
401             elif len(fetched) != config["misskey_offset"]:
402                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
403                 offset = offset + (config["misskey_offset"] - len(fetched))
404             else:
405                 # DEBUG: print("DEBUG: Raising offset by step:", step)
406                 offset = offset + step
407
408             # Check records
409             for row in fetched:
410                 # DEBUG: print(f"DEBUG: row():{len(row)}")
411                 if not "host" in row:
412                     print(f"WARNING: row()={len(row)} does not contain element 'host': {row},domain='{domain}'")
413                     continue
414                 elif "host" in row and is_blacklisted(row["host"]):
415                     # DEBUG: print(f"DEBUG: row[host]='{row['host']}' is blacklisted. domain='{domain}'")
416                     continue
417
418                 # DEBUG: print(f"DEBUG: Adding peer: '{row['host']}'")
419                 peers.append(row["host"])
420
421         update_last_instance_fetch(domain)
422
423         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
424         return peers
425     elif software == "lemmy":
426         # DEBUG: print(f"DEBUG: domain='{domain}' is Lemmy, fetching JSON ...")
427         try:
428             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
429
430             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',res.json[]='{type(res.json())}'")
431             if not res.ok or res.status_code >= 400:
432                 print("WARNING: Could not reach any JSON API:", domain)
433                 update_last_error(domain, res)
434             elif res.ok and isinstance(res.json(), list):
435                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{res.json()}'")
436                 sys.exit(255)
437             elif "federated_instances" in res.json():
438                 # DEBUG: print("DEBUG: Found federated_instances", domain)
439                 peers = peers + add_peers(res.json()["federated_instances"])
440             else:
441                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
442                 update_last_error(domain, res)
443
444         except BaseException as e:
445             print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception:'{str(e)}'")
446
447         update_last_instance_fetch(domain)
448
449         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
450         return peers
451     elif software == "peertube":
452         # DEBUG: print(f"DEBUG: domain='{domain}' is a PeerTube, fetching JSON ...")
453
454         start = 0
455         for mode in ["followers", "following"]:
456             # DEBUG: print(f"DEBUG: domain='{domain}',mode='{mode}'")
457             while True:
458                 try:
459                     res = reqto.get(f"https://{domain}/api/v1/server/{mode}?start={start}&count=100", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
460
461                     # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',res.json[]='{type(res.json())}'")
462                     if res.ok and isinstance(res.json(), dict):
463                         # DEBUG: print("DEBUG: Success, res.json():", len(res.json()))
464                         data = res.json()
465
466                         if "data" in data:
467                             # DEBUG: print(f"DEBUG: Found {len(data['data'])} record(s).")
468                             for record in data["data"]:
469                                 # DEBUG: print(f"DEBUG: record()={len(record)}")
470                                 if mode in record and "host" in record[mode]:
471                                     # DEBUG: print(f"DEBUG: Found host={record[mode]['host']}, adding ...")
472                                     peers.append(record[mode]["host"])
473                                 else:
474                                     print(f"WARNING: record from '{domain}' has no '{mode}' or 'host' record: {record}")
475
476                             if len(data["data"]) < 100:
477                                 # DEBUG: print("DEBUG: Reached end of JSON response:", domain)
478                                 break
479
480                         # Continue with next row
481                         start = start + 100
482
483                 except BaseException as e:
484                     print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception:'{str(e)}'")
485
486             update_last_instance_fetch(domain)
487
488             # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
489             return peers
490
491     # DEBUG: print(f"DEBUG: Fetching get_peers_url='{get_peers_url}' from '{domain}' ...")
492     try:
493         res = reqto.get(f"https://{domain}{get_peers_url}", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
494
495         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},res.json[]='{type(res.json())}'")
496         if not res.ok or res.status_code >= 400:
497             # DEBUG: print(f"DEBUG: Was not able to fetch '{get_peers_url}', trying alternative ...")
498             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
499
500             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},res.json[]='{type(res.json())}'")
501             if not res.ok or res.status_code >= 400:
502                 print("WARNING: Could not reach any JSON API:", domain)
503                 update_last_error(domain, res)
504             elif res.ok and isinstance(res.json(), list):
505                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{res.json()}'")
506                 sys.exit(255)
507             elif "federated_instances" in res.json():
508                 # DEBUG: print("DEBUG: Found federated_instances", domain)
509                 peers = peers + add_peers(res.json()["federated_instances"])
510             else:
511                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
512                 update_last_error(domain, res)
513         else:
514             # DEBUG: print("DEBUG: Querying API was successful:", domain, len(res.json()))
515             peers = res.json()
516             nodeinfos["get_peers_url"][domain] = get_peers_url
517
518     except BaseException as e:
519         print("WARNING: Some error during get():", domain, e)
520         update_last_error(domain, e)
521
522     update_last_instance_fetch(domain)
523
524     # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
525     return peers
526
527 def post_json_api(domain: str, path: str, parameter: str, extra_headers: dict = {}) -> list:
528     # DEBUG: print("DEBUG: Sending POST to domain,path,parameter:", domain, path, parameter, extra_headers)
529     data = list()
530     try:
531         res = reqto.post(f"https://{domain}{path}", data=parameter, headers={**api_headers, **extra_headers}, timeout=(config["connection_timeout"], config["read_timeout"]))
532
533         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},res.json[]='{type(res.json())}'")
534         if not res.ok or res.status_code >= 400:
535             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())}'")
536             update_last_error(domain, res)
537         else:
538             update_last_nodeinfo(domain)
539             data = res.json()
540
541     except BaseException as e:
542         print(f"WARNING: Some error during post(): domain='{domain}',path='{path}',parameter()={len(parameter)},exception:'{str(e)}'")
543
544     # DEBUG: print("DEBUG: Returning data():", len(data))
545     return data
546
547 def fetch_nodeinfo(domain: str) -> list:
548     # DEBUG: print("DEBUG: Fetching nodeinfo from domain:", domain)
549
550     nodeinfo = fetch_wellknown_nodeinfo(domain)
551     # DEBUG: print("DEBUG: nodeinfo:", len(nodeinfo))
552
553     if len(nodeinfo) > 0:
554         # DEBUG: print("DEBUG: Returning auto-discovered nodeinfo:", len(nodeinfo))
555         return nodeinfo
556
557     requests = [
558        f"https://{domain}/nodeinfo/2.1.json",
559        f"https://{domain}/nodeinfo/2.1",
560        f"https://{domain}/nodeinfo/2.0.json",
561        f"https://{domain}/nodeinfo/2.0",
562        f"https://{domain}/nodeinfo/1.0",
563        f"https://{domain}/api/v1/instance"
564     ]
565
566     data = {}
567     for request in requests:
568         try:
569             # DEBUG: print("DEBUG: Fetching request:", request)
570             res = reqto.get(request, headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
571
572             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},res.json[]='{type(res.json())}'")
573             if res.ok and isinstance(res.json(), dict):
574                 # DEBUG: print("DEBUG: Success:", request)
575                 data = res.json()
576                 nodeinfos["detection_mode"][domain] = "STATIC_CHECK"
577                 nodeinfos["nodeinfo_url"][domain] = request
578                 break
579             elif res.ok and isinstance(res.json(), list):
580                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{res.json()}'")
581                 sys.exit(255)
582             elif not res.ok or res.status_code >= 400:
583                 print("WARNING: Failed fetching nodeinfo from domain:", domain)
584                 update_last_error(domain, res)
585                 continue
586
587         except BaseException as e:
588             # DEBUG: print("DEBUG: Cannot fetch API request:", request)
589             update_last_error(domain, e)
590             pass
591
592     # DEBUG: print("DEBUG: Returning data[]:", type(data))
593     return data
594
595 def fetch_wellknown_nodeinfo(domain: str) -> list:
596     # DEBUG: print("DEBUG: Fetching .well-known info for domain:", domain)
597     data = {}
598
599     try:
600         res = reqto.get(f"https://{domain}/.well-known/nodeinfo", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
601         # DEBUG: print("DEBUG: domain,res.ok,res.json[]:", domain, res.ok, type(res.json()))
602         if res.ok and isinstance(res.json(), dict):
603             nodeinfo = res.json()
604             # DEBUG: print("DEBUG: Found entries:", len(nodeinfo), domain)
605             if "links" in nodeinfo:
606                 # DEBUG: print("DEBUG: Found links in nodeinfo():", len(nodeinfo["links"]))
607                 for link in nodeinfo["links"]:
608                     # DEBUG: print("DEBUG: rel,href:", link["rel"], link["href"])
609                     if link["rel"] in nodeinfo_identifier:
610                         # DEBUG: print("DEBUG: Fetching nodeinfo from:", link["href"])
611                         res = reqto.get(link["href"])
612
613                         # DEBUG: print("DEBUG: href,res.ok,res.status_code:", link["href"], res.ok, res.status_code)
614                         if res.ok and isinstance(res.json(), dict):
615                             # DEBUG: print("DEBUG: Found JSON nodeinfo():", len(res.json()))
616                             data = res.json()
617                             nodeinfos["detection_mode"][domain] = "AUTO_DISCOVERY"
618                             nodeinfos["nodeinfo_url"][domain] = link["href"]
619                             break
620                     else:
621                         print("WARNING: Unknown 'rel' value:", domain, link["rel"])
622             else:
623                 print("WARNING: nodeinfo does not contain 'links':", domain)
624
625     except BaseException as e:
626         print("WARNING: Failed fetching .well-known info:", domain)
627         update_last_error(domain, e)
628         pass
629
630     # DEBUG: print("DEBUG: Returning data[]:", type(data))
631     return data
632
633 def fetch_generator_from_path(domain: str, path: str = "/") -> str:
634     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}' - CALLED!")
635     software = None
636
637     try:
638         # DEBUG: print(f"DEBUG: Fetching path='{path}' from '{domain}' ...")
639         res = reqto.get(f"https://{domain}{path}", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"]))
640
641         # DEBUG: print("DEBUG: domain,res.ok,res.status_code,res.text[]:", domain, res.ok, res.status_code, type(res.text))
642         if res.ok and res.status_code < 300 and len(res.text) > 0:
643             # DEBUG: print("DEBUG: Search for <meta name='generator'>:", domain)
644             doc = bs4.BeautifulSoup(res.text, "html.parser")
645
646             # DEBUG: print("DEBUG: doc[]:", type(doc))
647             tag = doc.find("meta", {"name": "generator"})
648
649             # DEBUG: print(f"DEBUG: tag[{type(tag)}: {tag}")
650             if isinstance(tag, bs4.element.Tag):
651                 # DEBUG: print("DEBUG: Found generator meta tag: ", domain)
652                 software = tidyup(tag.get("content"))
653                 print(f"INFO: domain='{domain}' is generated by '{software}'")
654                 nodeinfos["detection_mode"][domain] = "GENERATOR"
655                 remove_pending_error(domain)
656
657     except BaseException as e:
658         # DEBUG: print(f"DEBUG: Cannot fetch / from '{domain}':", e)
659         update_last_error(domain, e)
660         pass
661
662     # DEBUG: print(f"DEBUG: software[]={type(software)}")
663     if type(software) is str and software == "":
664         # DEBUG: print(f"DEBUG: Corrected empty string to None for software of domain='{domain}'")
665         software = None
666     elif type(software) is str and ("." in software or " " in software):
667         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
668         software = remove_version(software)
669
670     # DEBUG: print(f"DEBUG: software[]={type(software)}")
671     if type(software) is str and "powered by" in software:
672         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
673         software = remove_version(strip_powered_by(software))
674     elif type(software) is str and " by " in software:
675         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
676         software = strip_until(software, " by ")
677     elif type(software) is str and " see " in software:
678         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
679         software = strip_until(software, " see ")
680
681     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
682     return software
683
684 def determine_software(domain: str) -> str:
685     # DEBUG: print("DEBUG: Determining software for domain:", domain)
686     software = None
687
688     # DEBUG: print(f"DEBUG: Fetching nodeinfo from '{domain}' ...")
689     data = fetch_nodeinfo(domain)
690
691     # DEBUG: print("DEBUG: data[]:", type(data))
692     if not isinstance(data, dict) or len(data) == 0:
693         # DEBUG: print("DEBUG: Could not determine software type:", domain)
694         return fetch_generator_from_path(domain)
695
696     # DEBUG: print("DEBUG: data():", len(data), data)
697     if "status" in data and data["status"] == "error" and "message" in data:
698         print("WARNING: JSON response is an error:", data["message"])
699         update_last_error(domain, data["message"])
700         return fetch_generator_from_path(domain)
701     elif "software" not in data or "name" not in data["software"]:
702         # DEBUG: print(f"DEBUG: JSON response from {domain} does not include [software][name], fetching / ...")
703         software = fetch_generator_from_path(domain)
704
705         # DEBUG: print(f"DEBUG: Generator for domain='{domain}' is: {software}, EXIT!")
706         return software
707
708     software = tidyup(data["software"]["name"])
709
710     # DEBUG: print("DEBUG: sofware after tidyup():", software)
711     if software in ["akkoma", "rebased"]:
712         # DEBUG: print("DEBUG: Setting pleroma:", domain, software)
713         software = "pleroma"
714     elif software in ["hometown", "ecko"]:
715         # DEBUG: print("DEBUG: Setting mastodon:", domain, software)
716         software = "mastodon"
717     elif software in ["calckey", "groundpolis", "foundkey", "cherrypick", "meisskey"]:
718         # DEBUG: print("DEBUG: Setting misskey:", domain, software)
719         software = "misskey"
720     elif software.find("/") > 0:
721         print("WARNING: Spliting of slash:", software)
722         software = software.split("/")[-1];
723     elif software.find("|") > 0:
724         print("WARNING: Spliting of pipe:", software)
725         software = tidyup(software.split("|")[0]);
726     elif "powered by" in software:
727         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
728         software = strip_powered_by(software)
729     elif type(software) is str and " by " in software:
730         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
731         software = strip_until(software, " by ")
732     elif type(software) is str and " see " in software:
733         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
734         software = strip_until(software, " see ")
735
736     # DEBUG: print(f"DEBUG: software[]={type(software)}")
737     if software == "":
738         print("WARNING: tidyup() left no software name behind:", domain)
739         software = None
740
741     # DEBUG: print(f"DEBUG: software[]={type(software)}")
742     if str(software) == "":
743         # DEBUG: print(f"DEBUG: software for '{domain}' was not detected, trying generator ...")
744         software = fetch_generator_from_path(domain)
745     elif len(str(software)) > 0 and ("." in software or " " in software):
746         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
747         software = remove_version(software)
748
749     # DEBUG: print(f"DEBUG: software[]={type(software)}")
750     if type(software) is str and "powered by" in software:
751         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
752         software = remove_version(strip_powered_by(software))
753
754     # DEBUG: print("DEBUG: Returning domain,software:", domain, software)
755     return software
756
757 def update_block_reason(reason: str, blocker: str, blocked: str, block_level: str):
758     # DEBUG: print("DEBUG: Updating block reason:", reason, blocker, blocked, block_level)
759     try:
760         cursor.execute(
761             "UPDATE blocks SET reason = ?, last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? AND reason = ''",
762             (
763                 reason,
764                 time.time(),
765                 blocker,
766                 blocked,
767                 block_level
768             ),
769         )
770
771         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
772         if cursor.rowcount == 0:
773             print("WARNING: Did not update any rows:", domain)
774
775     except BaseException as e:
776         print(f"ERROR: failed SQL query: reason='{reason}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',sql='{sql}',exception:'{str(e)}'")
777         sys.exit(255)
778
779     # DEBUG: print("DEBUG: EXIT!")
780
781 def update_last_seen(blocker: str, blocked: str, block_level: str):
782     # DEBUG: print("DEBUG: Updating last_seen for:", blocker, blocked, block_level)
783     try:
784         cursor.execute(
785             "UPDATE blocks SET last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ?",
786             (
787                 time.time(),
788                 blocker,
789                 blocked,
790                 block_level
791             )
792         )
793
794         if cursor.rowcount == 0:
795             print("WARNING: Did not update any rows:", domain)
796
797     except BaseException as e:
798         print(f"ERROR: failed SQL query: last_seen='{last_seen}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception:'{str(e)}'")
799         sys.exit(255)
800
801     # DEBUG: print("DEBUG: EXIT!")
802
803 def block_instance(blocker: str, blocked: str, reason: str, block_level: str):
804     # DEBUG: print("DEBUG: blocker,blocked,reason,block_level:", blocker, blocked, reason, block_level)
805     if not validators.domain(blocker.split("/")[0]):
806         print("WARNING: Bad blocker:", blocker)
807         raise
808     elif not validators.domain(blocked.split("/")[0]):
809         print("WARNING: Bad blocked:", blocked)
810         raise
811
812     print("INFO: New block:", blocker, blocked, reason, block_level, first_seen, last_seen)
813     try:
814         cursor.execute(
815             "INSERT INTO blocks (blocker, blocked, reason, block_level, first_seen, last_seen) VALUES(?, ?, ?, ?, ?, ?)",
816              (
817                  blocker,
818                  blocked,
819                  reason,
820                  block_level,
821                  time.time(),
822                  time.time()
823              ),
824         )
825
826     except BaseException as e:
827         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)}'")
828         sys.exit(255)
829
830     # DEBUG: print("DEBUG: EXIT!")
831
832 def is_instance_registered(domain: str) -> bool:
833     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
834     # Default is not registered
835     registered = False
836
837     try:
838         cursor.execute(
839             "SELECT rowid FROM instances WHERE domain = ? LIMIT 1", [domain]
840         )
841
842         # Check condition
843         registered = cursor.fetchone() != None
844     except BaseException as e:
845         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)}'")
846         sys.exit(255)
847
848     # DEBUG: print(f"DEBUG: registered='{registered}' - EXIT!")
849     return registered
850
851 def add_instance(domain: str, origin: str, originator: str):
852     # DEBUG: print("DEBUG: domain,origin:", domain, origin, originator)
853     if not validators.domain(domain.split("/")[0]):
854         print("WARNING: Bad domain name:", domain)
855         raise
856     elif origin is not None and not validators.domain(origin.split("/")[0]):
857         print("WARNING: Bad origin name:", origin)
858         raise
859
860     software = determine_software(domain)
861     # DEBUG: print("DEBUG: Determined software:", software)
862
863     print(f"INFO: Adding instance {domain} (origin: {origin})")
864     try:
865         cursor.execute(
866             "INSERT INTO instances (domain, origin, originator, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
867             (
868                domain,
869                origin,
870                originator,
871                get_hash(domain),
872                software,
873                time.time()
874             ),
875         )
876
877         for key in nodeinfos:
878             # DEBUG: print(f"DEBUG: key='{key}',domain='{domain}',nodeinfos[key]={nodeinfos[key]}")
879             if domain in nodeinfos[key]:
880                 # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo being updated ...")
881                 update_nodeinfos(domain)
882                 remove_pending_error(domain)
883                 break
884
885         if domain in pending_errors:
886             # DEBUG: print("DEBUG: domain has pending error being updated:", domain)
887             update_last_error(domain, pending_errors[domain])
888             remove_pending_error(domain)
889
890     except BaseException as e:
891         print(f"ERROR: failed SQL query: domain='{domain}',exception:'{str(e)}'")
892         sys.exit(255)
893     else:
894         # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
895         update_last_nodeinfo(domain)
896
897     # DEBUG: print("DEBUG: EXIT!")
898
899 def send_bot_post(instance: str, blocks: dict):
900     message = instance + " has blocked the following instances:\n\n"
901     truncated = False
902
903     if len(blocks) > 20:
904         truncated = True
905         blocks = blocks[0 : 19]
906
907     for block in blocks:
908         if block["reason"] == None or block["reason"] == '':
909             message = message + block["blocked"] + " with unspecified reason\n"
910         else:
911             if len(block["reason"]) > 420:
912                 block["reason"] = block["reason"][0:419] + "[…]"
913
914             message = message + block["blocked"] + ' for "' + block["reason"].replace("@", "@\u200b") + '"\n'
915
916     if truncated:
917         message = message + "(the list has been truncated to the first 20 entries)"
918
919     botheaders = {**api_headers, **{"Authorization": "Bearer " + config["bot_token"]}}
920
921     req = reqto.post(
922         f"{config['bot_instance']}/api/v1/statuses",
923         data={
924             "status"      : message,
925             "visibility"  : config['bot_visibility'],
926             "content_type": "text/plain"
927         },
928         headers=botheaders,
929         timeout=10
930     ).json()
931
932     return True
933
934 def get_mastodon_blocks(domain: str) -> dict:
935     # DEBUG: print("DEBUG: Fetching mastodon blocks from domain:", domain)
936     blocks = {
937         "Suspended servers": [],
938         "Filtered media"   : [],
939         "Limited servers"  : [],
940         "Silenced servers" : [],
941     }
942
943     try:
944         doc = bs4.BeautifulSoup(
945             reqto.get(f"https://{domain}/about/more", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
946             "html.parser",
947         )
948     except BaseException as e:
949         print("ERROR: Cannot fetch from domain:", domain, e)
950         update_last_error(domain, e)
951         return {}
952
953     for header in doc.find_all("h3"):
954         header_text = tidyup(header.text)
955
956         if header_text in language_mapping:
957             # DEBUG: print(f"DEBUG: header_text='{header_text}'")
958             header_text = language_mapping[header_text]
959
960         if header_text in blocks or header_text.lower() in blocks:
961             # replaced find_next_siblings with find_all_next to account for instances that e.g. hide lists in dropdown menu
962             for line in header.find_all_next("table")[0].find_all("tr")[1:]:
963                 blocks[header_text].append(
964                     {
965                         "domain": tidyup(line.find("span").text),
966                         "hash"  : tidyup(line.find("span")["title"][9:]),
967                         "reason": tidyup(line.find_all("td")[1].text),
968                     }
969                 )
970
971     # DEBUG: print("DEBUG: Returning blocks for domain:", domain)
972     return {
973         "reject"        : blocks["Suspended servers"],
974         "media_removal" : blocks["Filtered media"],
975         "followers_only": blocks["Limited servers"] + blocks["Silenced servers"],
976     }
977
978 def get_friendica_blocks(domain: str) -> dict:
979     # DEBUG: print("DEBUG: Fetching friendica blocks from domain:", domain)
980     blocks = []
981
982     try:
983         doc = bs4.BeautifulSoup(
984             reqto.get(f"https://{domain}/friendica", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
985             "html.parser",
986         )
987     except BaseException as e:
988         print("WARNING: Failed to fetch /friendica from domain:", domain, e)
989         update_last_error(domain, e)
990         return {}
991
992     blocklist = doc.find(id="about_blocklist")
993
994     # Prevents exceptions:
995     if blocklist is None:
996         # DEBUG: print("DEBUG:Instance has no block list:", domain)
997         return {}
998
999     for line in blocklist.find("table").find_all("tr")[1:]:
1000         # DEBUG: print(f"DEBUG: line='{line}'")
1001         blocks.append({
1002             "domain": tidyup(line.find_all("td")[0].text),
1003             "reason": tidyup(line.find_all("td")[1].text)
1004         })
1005
1006     # DEBUG: print("DEBUG: Returning blocks() for domain:", domain, len(blocks))
1007     return {
1008         "reject": blocks
1009     }
1010
1011 def get_misskey_blocks(domain: str) -> dict:
1012     # DEBUG: print("DEBUG: Fetching misskey blocks from domain:", domain)
1013     blocks = {
1014         "suspended": [],
1015         "blocked"  : []
1016     }
1017
1018     offset = 0
1019     step = config["misskey_offset"]
1020     while True:
1021         # iterating through all "suspended" (follow-only in its terminology)
1022         # instances page-by-page, since that troonware doesn't support
1023         # sending them all at once
1024         try:
1025             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
1026             if offset == 0:
1027                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1028                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1029                     "sort"     : "+pubAt",
1030                     "host"     : None,
1031                     "suspended": True,
1032                     "limit"    : step
1033                 }), {"Origin": domain})
1034             else:
1035                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1036                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1037                     "sort"     : "+pubAt",
1038                     "host"     : None,
1039                     "suspended": True,
1040                     "limit"    : step,
1041                     "offset"   : offset - 1
1042                 }), {"Origin": domain})
1043
1044             # DEBUG: print("DEBUG: fetched():", len(fetched))
1045             if len(fetched) == 0:
1046                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1047                 break
1048             elif len(fetched) != config["misskey_offset"]:
1049                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1050                 offset = offset + (config["misskey_offset"] - len(fetched))
1051             else:
1052                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1053                 offset = offset + step
1054
1055             for instance in fetched:
1056                 # just in case
1057                 if instance["isSuspended"]:
1058                     blocks["suspended"].append(
1059                         {
1060                             "domain": tidyup(instance["host"]),
1061                             # no reason field, nothing
1062                             "reason": None
1063                         }
1064                     )
1065
1066         except BaseException as e:
1067             print("WARNING: Caught error, exiting loop:", domain, e)
1068             update_last_error(domain, e)
1069             offset = 0
1070             break
1071
1072     while True:
1073         # same shit, different asshole ("blocked" aka full suspend)
1074         try:
1075             if offset == 0:
1076                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1077                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1078                     "sort"   : "+pubAt",
1079                     "host"   : None,
1080                     "blocked": True,
1081                     "limit"  : step
1082                 }), {"Origin": domain})
1083             else:
1084                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1085                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1086                     "sort"   : "+pubAt",
1087                     "host"   : None,
1088                     "blocked": True,
1089                     "limit"  : step,
1090                     "offset" : offset-1
1091                 }), {"Origin": domain})
1092
1093             # DEBUG: print("DEBUG: fetched():", len(fetched))
1094             if len(fetched) == 0:
1095                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1096                 break
1097             elif len(fetched) != config["misskey_offset"]:
1098                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1099                 offset = offset + (config["misskey_offset"] - len(fetched))
1100             else:
1101                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1102                 offset = offset + step
1103
1104             for instance in fetched:
1105                 if instance["isBlocked"]:
1106                     blocks["blocked"].append({
1107                         "domain": tidyup(instance["host"]),
1108                         "reason": None
1109                     })
1110
1111         except BaseException as e:
1112             print("ERROR: Exception during POST:", domain, e)
1113             update_last_error(domain, e)
1114             offset = 0
1115             break
1116
1117     # DEBUG: print("DEBUG: Returning for domain,blocked(),suspended():", domain, len(blocks["blocked"]), len(blocks["suspended"]))
1118     return {
1119         "reject"        : blocks["blocked"],
1120         "followers_only": blocks["suspended"]
1121     }
1122
1123 def tidyup(string: str) -> str:
1124     # some retards put their blocks in variable case
1125     string = string.lower().strip()
1126
1127     # other retards put the port
1128     string = re.sub("\:\d+$", "", string)
1129
1130     # bigger retards put the schema in their blocklist, sometimes even without slashes
1131     string = re.sub("^https?\:(\/*)", "", string)
1132
1133     # and trailing slash
1134     string = re.sub("\/$", "", string)
1135
1136     # and the @
1137     string = re.sub("^\@", "", string)
1138
1139     # the biggest retards of them all try to block individual users
1140     string = re.sub("(.+)\@", "", string)
1141
1142     return string