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