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