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