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