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