]> git.mxchange.org Git - fba.git/blob - fba/fba.py
14d500b1a55429350c535bcea153382b0d14ebce
[fba.git] / fba / fba.py
1 # Fedi API Block - An aggregator for fetching blocking data from fediverse nodes
2 # Copyright (C) 2023 Free Software Foundation
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published
6 # by the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17 import bs4
18 import hashlib
19 import re
20 import reqto
21 import json
22 import sqlite3
23 import sys
24 import time
25 import validators
26
27 from fba import cache
28 from fba import instances
29
30 with open("config.json") as f:
31     config = json.loads(f.read())
32
33 # Don't check these, known trolls/flooders/testing/developing
34 blacklist = [
35     # Floods network with fake nodes as "research" project
36     "activitypub-troll.cf",
37     # Similar troll
38     "gab.best",
39     # Similar troll
40     "4chan.icu",
41     # Flooder (?)
42     "social.shrimpcam.pw",
43     # Flooder (?)
44     "mastotroll.netz.org",
45     # Testing/developing installations
46     "ngrok.io",
47     "ngrok-free.app",
48     "misskeytest.chn.moe",
49 ]
50
51 # Array with pending errors needed to be written to database
52 pending_errors = {
53 }
54
55 # "rel" identifiers (no real URLs)
56 nodeinfo_identifier = [
57     "https://nodeinfo.diaspora.software/ns/schema/2.1",
58     "https://nodeinfo.diaspora.software/ns/schema/2.0",
59     "https://nodeinfo.diaspora.software/ns/schema/1.1",
60     "https://nodeinfo.diaspora.software/ns/schema/1.0",
61     "http://nodeinfo.diaspora.software/ns/schema/2.1",
62     "http://nodeinfo.diaspora.software/ns/schema/2.0",
63     "http://nodeinfo.diaspora.software/ns/schema/1.1",
64     "http://nodeinfo.diaspora.software/ns/schema/1.0",
65 ]
66
67 # HTTP headers for non-API requests
68 headers = {
69     "User-Agent": config["useragent"],
70 }
71
72 # HTTP headers for API requests
73 api_headers = {
74     "User-Agent": config["useragent"],
75     "Content-Type": "application/json",
76 }
77
78 language_mapping = {
79     # English -> English
80     "Silenced instances"            : "Silenced servers",
81     "Suspended instances"           : "Suspended servers",
82     "Limited instances"             : "Limited servers",
83     # Mappuing German -> English
84     "Gesperrte Server"              : "Suspended servers",
85     "Gefilterte Medien"             : "Filtered media",
86     "Stummgeschaltete Server"       : "Silenced servers",
87     # Japanese -> English
88     "停止済みのサーバー"            : "Suspended servers",
89     "制限中のサーバー"              : "Limited servers",
90     "メディアを拒否しているサーバー": "Filtered media",
91     "サイレンス済みのサーバー"      : "Silenced servers",
92     # ??? -> English
93     "שרתים מושעים"                  : "Suspended servers",
94     "מדיה מסוננת"                   : "Filtered media",
95     "שרתים מוגבלים"                 : "Silenced servers",
96     # French -> English
97     "Serveurs suspendus"            : "Suspended servers",
98     "Médias filtrés"                : "Filtered media",
99     "Serveurs limités"              : "Limited servers",
100     "Serveurs modérés"              : "Limited servers",
101 }
102
103 # URL for fetching peers
104 get_peers_url = "/api/v1/instance/peers"
105
106 # Connect to database
107 connection = sqlite3.connect("blocks.db")
108 cursor = connection.cursor()
109
110 # Pattern instance for version numbers
111 patterns = [
112     # semantic version number (with v|V) prefix)
113     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-]+)*))?)?$"),
114     # non-sematic, e.g. 1.2.3.4
115     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*))?)$"),
116     # non-sematic, e.g. 2023-05[-dev]
117     re.compile("^(?P<year>[1-9]{1}[0-9]{3})\.(?P<month>[0-9]{2})(-dev){0,1}$"),
118     # non-semantic, e.g. abcdef0
119     re.compile("^[a-f0-9]{7}$"),
120 ]
121
122 ##### Other functions #####
123
124 def is_primitive(var: any) -> bool:
125     # NOISY-DEBUG: print(f"DEBUG: var[]='{type(var)}' - CALLED!")
126     return type(var) in {int, str, float, bool} or var == None
127
128 def fetch_instances(domain: str, origin: str, software: str, script: str, path: str = None):
129     if type(domain) != str:
130         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
131     elif domain == "":
132         raise ValueError(f"Parameter 'domain' cannot be empty")
133     elif type(origin) != str and origin != None:
134         raise ValueError(f"Parameter origin[]={type(origin)} is not 'str'")
135     elif type(script) != str:
136         raise ValueError(f"Parameter script[]={type(script)} is not 'str'")
137     elif domain == "":
138         raise ValueError(f"Parameter 'domain' cannot be empty")
139
140     # DEBUG: print("DEBUG: domain,origin,software,path:", domain, origin, software, path)
141     if not is_instance_registered(domain):
142         # DEBUG: print("DEBUG: Adding new domain:", domain, origin)
143         add_instance(domain, origin, script, path)
144
145     # DEBUG: print("DEBUG: Fetching instances for domain:", domain, software)
146     peerlist = get_peers(domain, software)
147
148     if (peerlist is None):
149         print("ERROR: Cannot fetch peers:", domain)
150         return
151     elif instances.has_pending_instance_data(domain):
152         # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo data, flushing ...")
153         instances.update_instance_data(domain)
154
155     print(f"INFO: Checking {len(peerlist)} instances from {domain} ...")
156     for instance in peerlist:
157         if instance == None:
158             # Skip "None" types as tidup() cannot parse them
159             continue
160
161         # DEBUG: print(f"DEBUG: instance[{type(instance}]={instance} - BEFORE")
162         instance = tidyup_domain(instance)
163         # DEBUG: print(f"DEBUG: instance[{type(instance}]={instance} - AFTER")
164
165         if instance == "":
166             print("WARNING: Empty instance after tidyup_domain(), domain:", domain)
167             continue
168         elif not validators.domain(instance.split("/")[0]):
169             print(f"WARNING: Bad instance='{instance}' from domain='{domain}',origin='{origin}',software='{software}'")
170             continue
171         elif is_blacklisted(instance):
172             # DEBUG: print("DEBUG: instance is blacklisted:", instance)
173             continue
174
175         # DEBUG: print("DEBUG: Handling instance:", instance)
176         try:
177             if not is_instance_registered(instance):
178                 # DEBUG: print("DEBUG: Adding new instance:", instance, domain)
179                 add_instance(instance, domain, sys.argv[0])
180         except BaseException as e:
181             print(f"ERROR: instance='{instance}',exception[{type(e)}]:'{str(e)}'")
182             continue
183
184     # DEBUG: print("DEBUG: EXIT!")
185
186 def add_peers(rows: dict) -> list:
187     # DEBUG: print(f"DEBUG: rows()={len(rows)} - CALLED!")
188     peers = list()
189     for element in ["linked", "allowed", "blocked"]:
190         # DEBUG: print(f"DEBUG: Checking element='{element}'")
191         if element in rows and rows[element] != None:
192             # DEBUG: print(f"DEBUG: Adding {len(rows[element])} peer(s) to peers list ...")
193             for peer in rows[element]:
194                 # DEBUG: print(f"DEBUG: peer='{peer}' - BEFORE!")
195                 peer = tidyup_domain(peer)
196
197                 # DEBUG: print(f"DEBUG: peer='{peer}' - AFTER!")
198                 if is_blacklisted(peer):
199                     # DEBUG: print(f"DEBUG: peer='{peer}' is blacklisted, skipped!")
200                     continue
201
202                 # DEBUG: print(f"DEBUG: Adding peer='{peer}' ...")
203                 peers.append(peer)
204
205     # DEBUG: print(f"DEBUG: peers()={len(peers)} - EXIT!")
206     return peers
207
208 def remove_version(software: str) -> str:
209     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
210     if not "." in software and " " not in software:
211         print(f"WARNING: software='{software}' does not contain a version number.")
212         return software
213
214     temp = software
215     if ";" in software:
216         temp = software.split(";")[0]
217     elif "," in software:
218         temp = software.split(",")[0]
219     elif " - " in software:
220         temp = software.split(" - ")[0]
221
222     # DEBUG: print(f"DEBUG: software='{software}'")
223     version = None
224     if " " in software:
225         version = temp.split(" ")[-1]
226     elif "/" in software:
227         version = temp.split("/")[-1]
228     elif "-" in software:
229         version = temp.split("-")[-1]
230     else:
231         # DEBUG: print(f"DEBUG: Was not able to find common seperator, returning untouched software='{software}'")
232         return software
233
234     matches = None
235     match = None
236     # DEBUG: print(f"DEBUG: Checking {len(patterns)} patterns ...")
237     for pattern in patterns:
238         # Run match()
239         match = pattern.match(version)
240
241         # DEBUG: print(f"DEBUG: match[]={type(match)}")
242         if type(match) is re.Match:
243             break
244
245     # DEBUG: print(f"DEBUG: version[{type(version)}]='{version}',match='{match}'")
246     if type(match) is not re.Match:
247         print(f"WARNING: version='{version}' does not match regex, leaving software='{software}' untouched.")
248         return software
249
250     # DEBUG: print(f"DEBUG: Found valid version number: '{version}', removing it ...")
251     end = len(temp) - len(version) - 1
252
253     # DEBUG: print(f"DEBUG: end[{type(end)}]={end}")
254     software = temp[0:end].strip()
255     if " version" in software:
256         # DEBUG: print(f"DEBUG: software='{software}' contains word ' version'")
257         software = strip_until(software, " version")
258
259     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
260     return software
261
262 def strip_powered_by(software: str) -> str:
263     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
264     if software == "":
265         print(f"ERROR: Bad method call, 'software' is empty")
266         raise Exception("Parameter 'software' is empty")
267     elif not "powered by" in software:
268         print(f"WARNING: Cannot find 'powered by' in '{software}'!")
269         return software
270
271     start = software.find("powered by ")
272     # DEBUG: print(f"DEBUG: start[{type(start)}]='{start}'")
273
274     software = software[start + 11:].strip()
275     # DEBUG: print(f"DEBUG: software='{software}'")
276
277     software = strip_until(software, " - ")
278
279     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
280     return software
281
282 def strip_hosted_on(software: str) -> str:
283     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
284     if software == "":
285         print(f"ERROR: Bad method call, 'software' is empty")
286         raise Exception("Parameter 'software' is empty")
287     elif not "hosted on" in software:
288         print(f"WARNING: Cannot find 'hosted on' in '{software}'!")
289         return software
290
291     end = software.find("hosted on ")
292     # DEBUG: print(f"DEBUG: end[{type(end)}]='{end}'")
293
294     software = software[0, start].strip()
295     # DEBUG: print(f"DEBUG: software='{software}'")
296
297     software = strip_until(software, " - ")
298
299     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
300     return software
301
302 def strip_until(software: str, until: str) -> str:
303     # DEBUG: print(f"DEBUG: software='{software}',until='{until}' - CALLED!")
304     if software == "":
305         print(f"ERROR: Bad method call, 'software' is empty")
306         raise Exception("Parameter 'software' is empty")
307     elif until == "":
308         print(f"ERROR: Bad method call, 'until' is empty")
309         raise Exception("Parameter 'until' is empty")
310     elif not until in software:
311         print(f"WARNING: Cannot find '{until}' in '{software}'!")
312         return software
313
314     # Next, strip until part
315     end = software.find(until)
316
317     # DEBUG: print(f"DEBUG: end[{type(end)}]='{end}'")
318     if end > 0:
319         software = software[0:end].strip()
320
321     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
322     return software
323
324 def is_blacklisted(domain: str) -> bool:
325     if type(domain) != str:
326         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
327     elif domain == "":
328         raise ValueError(f"Parameter 'domain' cannot be empty")
329
330     blacklisted = False
331     for peer in blacklist:
332         if peer in domain:
333             blacklisted = True
334
335     return blacklisted
336
337 def remove_pending_error(domain: str):
338     if type(domain) != str:
339         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
340     elif domain == "":
341         raise ValueError(f"Parameter 'domain' cannot be empty")
342
343     try:
344         # Prevent updating any pending errors, nodeinfo was found
345         del pending_errors[domain]
346
347     except:
348         pass
349
350     # DEBUG: print("DEBUG: EXIT!")
351
352 def get_hash(domain: str) -> str:
353     if type(domain) != str:
354         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
355     elif domain == "":
356         raise ValueError(f"Parameter 'domain' cannot be empty")
357
358     return hashlib.sha256(domain.encode("utf-8")).hexdigest()
359
360 def update_last_blocked(domain: str):
361     if type(domain) != str:
362         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
363     elif domain == "":
364         raise ValueError(f"Parameter 'domain' cannot be empty")
365
366     # DEBUG: print("DEBUG: Updating last_blocked for domain", domain)
367     instances.set_instance_data("last_blocked", domain, time.time())
368
369     # Running pending updated
370     # DEBUG: print(f"DEBUG: Invoking instances.update_instance_data({domain}) ...")
371     instances.update_instance_data(domain)
372
373     # DEBUG: print("DEBUG: EXIT!")
374
375 def log_error(domain: str, res: any):
376     # DEBUG: print("DEBUG: domain,res[]:", domain, type(res))
377     if type(domain) != str:
378         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
379     elif domain == "":
380         raise ValueError(f"Parameter 'domain' cannot be empty")
381
382     try:
383         # DEBUG: print("DEBUG: BEFORE res[]:", type(res))
384         if isinstance(res, BaseException) or isinstance(res, json.JSONDecodeError):
385             res = str(res)
386
387         # DEBUG: print("DEBUG: AFTER res[]:", type(res))
388         if type(res) is str:
389             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, 999, ?, ?)",[
390                 domain,
391                 res,
392                 time.time()
393             ])
394         else:
395             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, ?, ?, ?)",[
396                 domain,
397                 res.status_code,
398                 res.reason,
399                 time.time()
400             ])
401
402         # Cleanup old entries
403         # DEBUG: print(f"DEBUG: Purging old records (distance: {config['error_log_cleanup']})")
404         cursor.execute("DELETE FROM error_log WHERE created < ?", [time.time() - config["error_log_cleanup"]])
405     except BaseException as e:
406         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
407         sys.exit(255)
408
409     # DEBUG: print("DEBUG: EXIT!")
410
411 def update_last_error(domain: str, res: any):
412     # DEBUG: print("DEBUG: domain,res[]:", domain, type(res))
413     if type(domain) != str:
414         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
415     elif domain == "":
416         raise ValueError(f"Parameter 'domain' cannot be empty")
417
418     # DEBUG: print("DEBUG: BEFORE res[]:", type(res))
419     if isinstance(res, BaseException) or isinstance(res, json.JSONDecodeError):
420         res = str(res)
421
422     # DEBUG: print("DEBUG: AFTER res[]:", type(res))
423     if type(res) is str:
424         # DEBUG: print(f"DEBUG: Setting last_error_details='{res}'");
425         instances.set_instance_data("last_status_code"  , domain, 999)
426         instances.set_instance_data("last_error_details", domain, res)
427     else:
428         # DEBUG: print(f"DEBUG: Setting last_error_details='{res.reason}'");
429         instances.set_instance_data("last_status_code"  , domain, res.status_code)
430         instances.set_instance_data("last_error_details", domain, res.reason)
431
432     # Running pending updated
433     # DEBUG: print(f"DEBUG: Invoking instances.update_instance_data({domain}) ...")
434     instances.update_instance_data(domain)
435
436     log_error(domain, res)
437
438     # DEBUG: print("DEBUG: EXIT!")
439
440 def update_last_instance_fetch(domain: str):
441     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
442     if type(domain) != str:
443         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
444     elif domain == "":
445         raise ValueError(f"Parameter 'domain' cannot be empty")
446
447     # DEBUG: print("DEBUG: Updating last_instance_fetch for domain:", domain)
448     instances.set_instance_data("last_instance_fetch", domain, time.time())
449
450     # Running pending updated
451     # DEBUG: print(f"DEBUG: Invoking instances.update_instance_data({domain}) ...")
452     instances.update_instance_data(domain)
453
454     # DEBUG: print("DEBUG: EXIT!")
455
456 def update_last_nodeinfo(domain: str):
457     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
458     if type(domain) != str:
459         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
460     elif domain == "":
461         raise ValueError(f"Parameter 'domain' cannot be empty")
462
463     # DEBUG: print("DEBUG: Updating last_nodeinfo for domain:", domain)
464     instances.set_instance_data("last_nodeinfo", domain, time.time())
465     instances.set_instance_data("last_updated" , domain, time.time())
466
467     # Running pending updated
468     # DEBUG: print(f"DEBUG: Invoking instances.update_instance_data({domain}) ...")
469     instances.update_instance_data(domain)
470
471     # DEBUG: print("DEBUG: EXIT!")
472
473 def get_peers(domain: str, software: str) -> list:
474     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},software={software} - CALLED!")
475     if type(domain) != str:
476         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
477     elif domain == "":
478         raise ValueError(f"Parameter 'domain' cannot be empty")
479     elif type(software) != str and software != None:
480         raise ValueError(f"software[]={type(software)} is not 'str'")
481
482     # DEBUG: print(f"DEBUG: domain='{domain}',software='{software}' - CALLED!")
483     peers = list()
484
485     if software == "misskey":
486         # DEBUG: print(f"DEBUG: domain='{domain}' is misskey, sending API POST request ...")
487         offset = 0
488         step = config["misskey_offset"]
489
490         # iterating through all "suspended" (follow-only in its terminology)
491         # instances page-by-page, since that troonware doesn't support
492         # sending them all at once
493         while True:
494             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
495             if offset == 0:
496                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
497                     "sort" : "+pubAt",
498                     "host" : None,
499                     "limit": step
500                 }), {"Origin": domain})
501             else:
502                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
503                     "sort"  : "+pubAt",
504                     "host"  : None,
505                     "limit" : step,
506                     "offset": offset - 1
507                 }), {"Origin": domain})
508
509             # DEBUG: print("DEBUG: fetched():", len(fetched))
510             if len(fetched) == 0:
511                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
512                 break
513             elif len(fetched) != config["misskey_offset"]:
514                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
515                 offset = offset + (config["misskey_offset"] - len(fetched))
516             else:
517                 # DEBUG: print("DEBUG: Raising offset by step:", step)
518                 offset = offset + step
519
520             # Check records
521             # DEBUG: print(f"DEBUG: fetched({len(fetched)})[]={type(fetched)}")
522             if isinstance(fetched, dict) and "error" in fetched and "message" in fetched["error"]:
523                 print(f"WARNING: post_json_api() returned error: {fetched['error']['message']}")
524                 update_last_error(domain, fetched["error"]["message"])
525                 break
526
527             for row in fetched:
528                 # DEBUG: print(f"DEBUG: row():{len(row)}")
529                 if not "host" in row:
530                     print(f"WARNING: row()={len(row)} does not contain element 'host': {row},domain='{domain}'")
531                     continue
532                 elif type(row["host"]) != str:
533                     print(f"WARNING: row[host][]={type(row['host'])} is not 'str'")
534                     continue
535                 elif is_blacklisted(row["host"]):
536                     # DEBUG: print(f"DEBUG: row[host]='{row['host']}' is blacklisted. domain='{domain}'")
537                     continue
538
539                 # DEBUG: print(f"DEBUG: Adding peer: '{row['host']}'")
540                 peers.append(row["host"])
541
542         # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
543         instances.set_instance_data("total_peers", domain, len(peers))
544
545         # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
546         update_last_instance_fetch(domain)
547
548         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
549         return peers
550     elif software == "lemmy":
551         # DEBUG: print(f"DEBUG: domain='{domain}' is Lemmy, fetching JSON ...")
552         try:
553             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
554
555             data = res.json()
556             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',data[]='{type(data)}'")
557             if not res.ok or res.status_code >= 400:
558                 print("WARNING: Could not reach any JSON API:", domain)
559                 update_last_error(domain, res)
560             elif res.ok and isinstance(data, list):
561                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
562                 sys.exit(255)
563             elif "federated_instances" in data:
564                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
565                 peers = peers + add_peers(data["federated_instances"])
566                 # DEBUG: print("DEBUG: Added instance(s) to peers")
567             else:
568                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
569                 update_last_error(domain, res)
570
571         except BaseException as e:
572             print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
573
574         # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
575         instances.set_instance_data("total_peers", domain, len(peers))
576
577         # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
578         update_last_instance_fetch(domain)
579
580         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
581         return peers
582     elif software == "peertube":
583         # DEBUG: print(f"DEBUG: domain='{domain}' is a PeerTube, fetching JSON ...")
584
585         start = 0
586         for mode in ["followers", "following"]:
587             # DEBUG: print(f"DEBUG: domain='{domain}',mode='{mode}'")
588             while True:
589                 try:
590                     res = reqto.get(f"https://{domain}/api/v1/server/{mode}?start={start}&count=100", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"]))
591
592                     data = res.json()
593                     # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',data[]='{type(data)}'")
594                     if res.ok and isinstance(data, dict):
595                         # DEBUG: print("DEBUG: Success, data:", len(data))
596                         if "data" in data:
597                             # DEBUG: print(f"DEBUG: Found {len(data['data'])} record(s).")
598                             for record in data["data"]:
599                                 # DEBUG: print(f"DEBUG: record()={len(record)}")
600                                 if mode in record and "host" in record[mode]:
601                                     # DEBUG: print(f"DEBUG: Found host={record[mode]['host']}, adding ...")
602                                     peers.append(record[mode]["host"])
603                                 else:
604                                     print(f"WARNING: record from '{domain}' has no '{mode}' or 'host' record: {record}")
605
606                             if len(data["data"]) < 100:
607                                 # DEBUG: print("DEBUG: Reached end of JSON response:", domain)
608                                 break
609
610                         # Continue with next row
611                         start = start + 100
612
613                 except BaseException as e:
614                     print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
615
616         # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
617         instances.set_instance_data("total_peers", domain, len(peers))
618
619         # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
620         update_last_instance_fetch(domain)
621
622         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
623         return peers
624
625     # DEBUG: print(f"DEBUG: Fetching get_peers_url='{get_peers_url}' from '{domain}' ...")
626     try:
627         res = reqto.get(f"https://{domain}{get_peers_url}", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
628
629         data = res.json()
630         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
631         if not res.ok or res.status_code >= 400:
632             # DEBUG: print(f"DEBUG: Was not able to fetch '{get_peers_url}', trying alternative ...")
633             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
634
635             data = res.json()
636             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
637             if not res.ok or res.status_code >= 400:
638                 print("WARNING: Could not reach any JSON API:", domain)
639                 update_last_error(domain, res)
640             elif res.ok and isinstance(data, list):
641                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
642                 sys.exit(255)
643             elif "federated_instances" in data:
644                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
645                 peers = peers + add_peers(data["federated_instances"])
646                 # DEBUG: print("DEBUG: Added instance(s) to peers")
647             else:
648                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
649                 update_last_error(domain, res)
650         else:
651             # DEBUG: print("DEBUG: Querying API was successful:", domain, len(data))
652             peers = data
653
654     except BaseException as e:
655         print("WARNING: Some error during get():", domain, e)
656         update_last_error(domain, e)
657
658     # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
659     instances.set_instance_data("total_peers", domain, len(peers))
660
661     # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
662     update_last_instance_fetch(domain)
663
664     # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
665     return peers
666
667 def post_json_api(domain: str, path: str, parameter: str, extra_headers: dict = {}) -> dict:
668     if type(domain) != str:
669         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
670     elif domain == "":
671         raise ValueError(f"Parameter 'domain' cannot be empty")
672     elif type(path) != str:
673         raise ValueError(f"path[]={type(path)} is not 'str'")
674     elif path == "":
675         raise ValueError(f"path cannot be empty")
676     elif type(parameter) != str:
677         raise ValueError(f"parameter[]={type(parameter)} is not 'str'")
678
679     # DEBUG: print("DEBUG: Sending POST to domain,path,parameter:", domain, path, parameter, extra_headers)
680     data = {}
681     try:
682         res = reqto.post(f"https://{domain}{path}", data=parameter, headers={**api_headers, **extra_headers}, timeout=(config["connection_timeout"], config["read_timeout"]))
683
684         data = res.json()
685         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
686         if not res.ok or res.status_code >= 400:
687             print(f"WARNING: Cannot query JSON API: domain='{domain}',path='{path}',parameter()={len(parameter)},res.status_code='{res.status_code}',data[]='{type(data)}'")
688             update_last_error(domain, res)
689
690     except BaseException as e:
691         print(f"WARNING: Some error during post(): domain='{domain}',path='{path}',parameter()={len(parameter)},exception[{type(e)}]:'{str(e)}'")
692
693     # DEBUG: print(f"DEBUG: Returning data({len(data)})=[]:{type(data)}")
694     return data
695
696 def fetch_nodeinfo(domain: str, path: str = None) -> list:
697     # DEBUG: print(f"DEBUG: domain='{domain}',path={path} - CALLED!")
698     if type(domain) != str:
699         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
700     elif domain == "":
701         raise ValueError(f"Parameter 'domain' cannot be empty")
702     elif type(path) != str and path != None:
703         raise ValueError(f"Parameter path[]={type(path)} is not 'str'")
704
705     # DEBUG: print("DEBUG: Fetching nodeinfo from domain,path:", domain, path)
706     nodeinfo = fetch_wellknown_nodeinfo(domain)
707
708     # DEBUG: print(f"DEBUG: nodeinfo({len(nodeinfo)})={nodeinfo}")
709     if len(nodeinfo) > 0:
710         # DEBUG: print("DEBUG: nodeinfo()={len(nodeinfo))} - EXIT!")
711         return nodeinfo
712
713     requests = [
714        f"https://{domain}/nodeinfo/2.1.json",
715        f"https://{domain}/nodeinfo/2.1",
716        f"https://{domain}/nodeinfo/2.0.json",
717        f"https://{domain}/nodeinfo/2.0",
718        f"https://{domain}/nodeinfo/1.0",
719        f"https://{domain}/api/v1/instance"
720     ]
721
722     data = {}
723     for request in requests:
724         if path != None and path != "" and request != path:
725             # DEBUG: print(f"DEBUG: path='{path}' does not match request='{request}' - SKIPPED!")
726             continue
727
728         try:
729             # DEBUG: print("DEBUG: Fetching request:", request)
730             res = reqto.get(request, headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
731
732             data = res.json()
733             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
734             if res.ok and isinstance(data, dict):
735                 # DEBUG: print("DEBUG: Success:", request)
736                 instances.set_instance_data("detection_mode", domain, "STATIC_CHECK")
737                 instances.set_instance_data("nodeinfo_url"  , domain, request)
738                 break
739             elif res.ok and isinstance(data, list):
740                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
741                 sys.exit(255)
742             elif not res.ok or res.status_code >= 400:
743                 print("WARNING: Failed fetching nodeinfo from domain:", domain)
744                 update_last_error(domain, res)
745                 continue
746
747         except BaseException as e:
748             # DEBUG: print("DEBUG: Cannot fetch API request:", request)
749             update_last_error(domain, e)
750             pass
751
752     # DEBUG: print(f"DEBUG: data()={len(data)} - EXIT!")
753     return data
754
755 def fetch_wellknown_nodeinfo(domain: str) -> list:
756     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
757     if type(domain) != str:
758         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
759     elif domain == "":
760         raise ValueError(f"Parameter 'domain' cannot be empty")
761
762     # DEBUG: print("DEBUG: Fetching .well-known info for domain:", domain)
763     data = {}
764
765     try:
766         res = reqto.get(f"https://{domain}/.well-known/nodeinfo", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
767
768         data = res.json()
769         # DEBUG: print("DEBUG: domain,res.ok,data[]:", domain, res.ok, type(data))
770         if res.ok and isinstance(data, dict):
771             nodeinfo = data
772             # DEBUG: print("DEBUG: Found entries:", len(nodeinfo), domain)
773             if "links" in nodeinfo:
774                 # DEBUG: print("DEBUG: Found links in nodeinfo():", len(nodeinfo["links"]))
775                 for link in nodeinfo["links"]:
776                     # DEBUG: print("DEBUG: rel,href:", link["rel"], link["href"])
777                     if link["rel"] in nodeinfo_identifier:
778                         # DEBUG: print("DEBUG: Fetching nodeinfo from:", link["href"])
779                         res = reqto.get(link["href"])
780
781                         data = res.json()
782                         # DEBUG: print("DEBUG: href,res.ok,res.status_code:", link["href"], res.ok, res.status_code)
783                         if res.ok and isinstance(data, dict):
784                             # DEBUG: print("DEBUG: Found JSON nodeinfo():", len(data))
785                             instances.set_instance_data("detection_mode", domain, "AUTO_DISCOVERY")
786                             instances.set_instance_data("nodeinfo_url"  , domain, link["href"])
787                             break
788                     else:
789                         print("WARNING: Unknown 'rel' value:", domain, link["rel"])
790             else:
791                 print("WARNING: nodeinfo does not contain 'links':", domain)
792
793     except BaseException as e:
794         print("WARNING: Failed fetching .well-known info:", domain)
795         update_last_error(domain, e)
796         pass
797
798     # DEBUG: print("DEBUG: Returning data[]:", type(data))
799     return data
800
801 def fetch_generator_from_path(domain: str, path: str = "/") -> str:
802     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},path={path} - CALLED!")
803     if type(domain) != str:
804         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
805     elif domain == "":
806         raise ValueError(f"Parameter 'domain' cannot be empty")
807     elif type(path) != str:
808         raise ValueError(f"path[]={type(path)} is not 'str'")
809     elif path == "":
810         raise ValueError(f"Parameter 'domain' cannot be empty")
811
812     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}' - CALLED!")
813     software = None
814
815     try:
816         # DEBUG: print(f"DEBUG: Fetching path='{path}' from '{domain}' ...")
817         res = reqto.get(f"https://{domain}{path}", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"]))
818
819         # DEBUG: print("DEBUG: domain,res.ok,res.status_code,res.text[]:", domain, res.ok, res.status_code, type(res.text))
820         if res.ok and res.status_code < 300 and len(res.text) > 0:
821             # DEBUG: print("DEBUG: Search for <meta name='generator'>:", domain)
822             doc = bs4.BeautifulSoup(res.text, "html.parser")
823
824             # DEBUG: print("DEBUG: doc[]:", type(doc))
825             generator = doc.find("meta", {"name": "generator"})
826             site_name = doc.find("meta", {"property": "og:site_name"})
827
828             # DEBUG: print(f"DEBUG: generator='{generator}',site_name='{site_name}'")
829             if isinstance(generator, bs4.element.Tag):
830                 # DEBUG: print("DEBUG: Found generator meta tag:", domain)
831                 software = tidyup_domain(generator.get("content"))
832                 print(f"INFO: domain='{domain}' is generated by '{software}'")
833                 instances.set_instance_data("detection_mode", domain, "GENERATOR")
834                 remove_pending_error(domain)
835             elif isinstance(site_name, bs4.element.Tag):
836                 # DEBUG: print("DEBUG: Found property=og:site_name:", domain)
837                 sofware = tidyup_domain(site_name.get("content"))
838                 print(f"INFO: domain='{domain}' has og:site_name='{software}'")
839                 instances.set_instance_data("detection_mode", domain, "SITE_NAME")
840                 remove_pending_error(domain)
841
842     except BaseException as e:
843         # DEBUG: print(f"DEBUG: Cannot fetch / from '{domain}':", e)
844         update_last_error(domain, e)
845         pass
846
847     # DEBUG: print(f"DEBUG: software[]={type(software)}")
848     if type(software) is str and software == "":
849         # DEBUG: print(f"DEBUG: Corrected empty string to None for software of domain='{domain}'")
850         software = None
851     elif type(software) is str and ("." in software or " " in software):
852         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
853         software = remove_version(software)
854
855     # DEBUG: print(f"DEBUG: software[]={type(software)}")
856     if type(software) is str and " powered by " in software:
857         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
858         software = remove_version(strip_powered_by(software))
859     elif type(software) is str and " hosted on " in software:
860         # DEBUG: print(f"DEBUG: software='{software}' has 'hosted on' in it")
861         software = remove_version(strip_hosted_on(software))
862     elif type(software) is str and " by " in software:
863         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
864         software = strip_until(software, " by ")
865     elif type(software) is str and " see " in software:
866         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
867         software = strip_until(software, " see ")
868
869     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
870     return software
871
872 def determine_software(domain: str, path: str = None) -> str:
873     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},path={path} - CALLED!")
874     if type(domain) != str:
875         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
876     elif domain == "":
877         raise ValueError(f"Parameter 'domain' cannot be empty")
878     elif type(path) != str and path != None:
879         raise ValueError(f"Parameter path[]={type(path)} is not 'str'")
880
881     # DEBUG: print("DEBUG: Determining software for domain,path:", domain, path)
882     software = None
883
884     # DEBUG: print(f"DEBUG: Fetching nodeinfo from '{domain}' ...")
885     data = fetch_nodeinfo(domain, path)
886
887     # DEBUG: print("DEBUG: data[]:", type(data))
888     if not isinstance(data, dict) or len(data) == 0:
889         # DEBUG: print("DEBUG: Could not determine software type:", domain)
890         return fetch_generator_from_path(domain)
891
892     # DEBUG: print("DEBUG: data():", len(data), data)
893     if "status" in data and data["status"] == "error" and "message" in data:
894         print("WARNING: JSON response is an error:", data["message"])
895         update_last_error(domain, data["message"])
896         return fetch_generator_from_path(domain)
897     elif "message" in data:
898         print("WARNING: JSON response contains only a message:", data["message"])
899         update_last_error(domain, data["message"])
900         return fetch_generator_from_path(domain)
901     elif "software" not in data or "name" not in data["software"]:
902         # DEBUG: print(f"DEBUG: JSON response from domain='{domain}' does not include [software][name], fetching / ...")
903         software = fetch_generator_from_path(domain)
904
905         # DEBUG: print(f"DEBUG: Generator for domain='{domain}' is: {software}, EXIT!")
906         return software
907
908     software = tidyup_domain(data["software"]["name"])
909
910     # DEBUG: print("DEBUG: sofware after tidyup_domain():", software)
911     if software in ["akkoma", "rebased"]:
912         # DEBUG: print("DEBUG: Setting pleroma:", domain, software)
913         software = "pleroma"
914     elif software in ["hometown", "ecko"]:
915         # DEBUG: print("DEBUG: Setting mastodon:", domain, software)
916         software = "mastodon"
917     elif software in ["calckey", "groundpolis", "foundkey", "cherrypick", "meisskey"]:
918         # DEBUG: print("DEBUG: Setting misskey:", domain, software)
919         software = "misskey"
920     elif software.find("/") > 0:
921         print("WARNING: Spliting of slash:", software)
922         software = software.split("/")[-1];
923     elif software.find("|") > 0:
924         print("WARNING: Spliting of pipe:", software)
925         software = tidyup_domain(software.split("|")[0]);
926     elif "powered by" in software:
927         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
928         software = strip_powered_by(software)
929     elif type(software) is str and " by " in software:
930         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
931         software = strip_until(software, " by ")
932     elif type(software) is str and " see " in software:
933         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
934         software = strip_until(software, " see ")
935
936     # DEBUG: print(f"DEBUG: software[]={type(software)}")
937     if software == "":
938         print("WARNING: tidyup_domain() left no software name behind:", domain)
939         software = None
940
941     # DEBUG: print(f"DEBUG: software[]={type(software)}")
942     if str(software) == "":
943         # DEBUG: print(f"DEBUG: software for '{domain}' was not detected, trying generator ...")
944         software = fetch_generator_from_path(domain)
945     elif len(str(software)) > 0 and ("." in software or " " in software):
946         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
947         software = remove_version(software)
948
949     # DEBUG: print(f"DEBUG: software[]={type(software)}")
950     if type(software) is str and "powered by" in software:
951         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
952         software = remove_version(strip_powered_by(software))
953
954     # DEBUG: print("DEBUG: Returning domain,software:", domain, software)
955     return software
956
957 def update_block_reason(reason: str, blocker: str, blocked: str, block_level: str):
958     # DEBUG: print(f"DEBUG: reason='{reason}',blocker={blocker},blocked={blocked},block_level={block_level} - CALLED!")
959     if type(reason) != str and reason != None:
960         raise ValueError(f"Parameter reason[]='{type(reason)}' is not 'str'")
961     elif type(blocker) != str:
962         raise ValueError(f"Parameter blocker[]='{type(blocker)}' is not 'str'")
963     elif type(blocked) != str:
964         raise ValueError(f"Parameter blocked[]='{type(blocked)}' is not 'str'")
965     elif type(block_level) != str:
966         raise ValueError(f"Parameter block_level[]='{type(block_level)}' is not 'str'")
967
968     # DEBUG: print("DEBUG: Updating block reason:", reason, blocker, blocked, block_level)
969     try:
970         cursor.execute(
971             "UPDATE blocks SET reason = ?, last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? AND reason IN ('','unknown') LIMIT 1",
972             (
973                 reason,
974                 time.time(),
975                 blocker,
976                 blocked,
977                 block_level
978             ),
979         )
980
981         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
982         if cursor.rowcount == 0:
983             # DEBUG: print(f"DEBUG: Did not update any rows: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',reason='{reason}' - EXIT!")
984             return
985
986     except BaseException as e:
987         print(f"ERROR: failed SQL query: reason='{reason}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
988         sys.exit(255)
989
990     # DEBUG: print("DEBUG: EXIT!")
991
992 def update_last_seen(blocker: str, blocked: str, block_level: str):
993     # DEBUG: print("DEBUG: Updating last_seen for:", blocker, blocked, block_level)
994     try:
995         cursor.execute(
996             "UPDATE blocks SET last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? LIMIT 1",
997             (
998                 time.time(),
999                 blocker,
1000                 blocked,
1001                 block_level
1002             )
1003         )
1004
1005         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
1006         if cursor.rowcount == 0:
1007             # DEBUG: print(f"DEBUG: Did not update any rows: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}' - EXIT!")
1008             return
1009
1010     except BaseException as e:
1011         print(f"ERROR: failed SQL query: blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
1012         sys.exit(255)
1013
1014     # DEBUG: print("DEBUG: EXIT!")
1015
1016 def is_instance_blocked(blocker: str, blocked: str, block_level: str) -> bool:
1017     # DEBUG: print(f"DEBUG: blocker={blocker},blocked={blocked},block_level={block_level} - CALLED!")
1018     if type(blocker) != str:
1019         raise ValueError(f"Parameter blocker[]={type(blocker)} is not of type 'str'")
1020     elif blocker == "":
1021         raise ValueError("Parameter 'blocker' cannot be empty")
1022     elif type(blocked) != str:
1023         raise ValueError(f"Parameter blocked[]={type(blocked)} is not of type 'str'")
1024     elif blocked == "":
1025         raise ValueError("Parameter 'blocked' cannot be empty")
1026     elif type(block_level) != str:
1027         raise ValueError(f"Parameter block_level[]={type(block_level)} is not of type 'str'")
1028     elif block_level == "":
1029         raise ValueError("Parameter 'block_level' cannot be empty")
1030
1031     cursor.execute(
1032         "SELECT * FROM blocks WHERE blocker = ? AND blocked = ? AND block_level = ? LIMIT 1",
1033         (
1034             blocker,
1035             blocked,
1036             block_level
1037         ),
1038     )
1039
1040     is_blocked = cursor.fetchone() != None
1041
1042     # DEBUG: print(f"DEBUG: is_blocked='{is_blocked}' - EXIT!")
1043     return is_blocked
1044
1045 def block_instance(blocker: str, blocked: str, reason: str, block_level: str):
1046     # DEBUG: print("DEBUG: blocker,blocked,reason,block_level:", blocker, blocked, reason, block_level)
1047     if type(blocker) != str:
1048         raise ValueError(f"Parameter blocker[]={type(blocker)} is not 'str'")
1049     elif blocker == "":
1050         raise ValueError(f"Parameter 'blocker' cannot be empty")
1051     elif not validators.domain(blocker.split("/")[0]):
1052         raise ValueError(f"Bad blocker='{blocker}'")
1053     elif type(blocked) != str:
1054         raise ValueError(f"Parameter blocked[]={type(blocked)} is not 'str'")
1055     elif blocked == "":
1056         raise ValueError(f"Parameter 'blocked' cannot be empty")
1057     elif not validators.domain(blocked.split("/")[0]):
1058         raise ValueError(f"Bad blocked='{blocked}'")
1059     elif is_blacklisted(blocker):
1060         raise Exception(f"blocker='{blocker}' is blacklisted but function invoked")
1061     elif is_blacklisted(blocked):
1062         raise Exception(f"blocked='{blocked}' is blacklisted but function invoked")
1063
1064     print(f"INFO: New block: blocker='{blocker}',blocked='{blocked}', reason='{reason}', block_level='{block_level}'")
1065     try:
1066         cursor.execute(
1067             "INSERT INTO blocks (blocker, blocked, reason, block_level, first_seen, last_seen) VALUES(?, ?, ?, ?, ?, ?)",
1068              (
1069                  blocker,
1070                  blocked,
1071                  reason,
1072                  block_level,
1073                  time.time(),
1074                  time.time()
1075              ),
1076         )
1077     except BaseException as e:
1078         print(f"ERROR: failed SQL query: blocker='{blocker}',blocked='{blocked}',reason='{reason}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
1079         sys.exit(255)
1080
1081     # DEBUG: print("DEBUG: EXIT!")
1082
1083 def is_instance_registered(domain: str) -> bool:
1084     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1085     if type(domain) != str:
1086         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1087     elif domain == "":
1088         raise ValueError(f"Parameter 'domain' cannot be empty")
1089
1090     # NOISY-DEBUG: # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
1091     if not cache.is_cache_initialized("is_registered"):
1092         # NOISY-DEBUG: # DEBUG: print(f"DEBUG: Cache for 'is_registered' not initialized, fetching all rows ...")
1093         try:
1094             cursor.execute("SELECT domain FROM instances")
1095
1096             # Check Set all
1097             cache.set_all_cache_key("is_registered", cursor.fetchall(), True)
1098         except BaseException as e:
1099             print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
1100             sys.exit(255)
1101
1102     # Is cache found?
1103     registered = cache.is_cache_key_set("is_registered", domain)
1104
1105     # NOISY-DEBUG: # DEBUG: print(f"DEBUG: registered='{registered}' - EXIT!")
1106     return registered
1107
1108 def add_instance(domain: str, origin: str, originator: str, path: str = None):
1109     # DEBUG: print(f"DEBUG: domain={domain},origin={origin},originator={originator},path={path} - CALLED!")
1110     if type(domain) != str:
1111         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1112     elif domain == "":
1113         raise ValueError(f"Parameter 'domain' cannot be empty")
1114     elif type(origin) != str and origin != None:
1115         raise ValueError(f"origin[]={type(origin)} is not 'str'")
1116     elif type(originator) != str:
1117         raise ValueError(f"originator[]={type(originator)} is not 'str'")
1118     elif originator == "":
1119         raise ValueError(f"originator cannot be empty")
1120     elif not validators.domain(domain.split("/")[0]):
1121         raise ValueError(f"Bad domain name='{domain}'")
1122     elif origin is not None and not validators.domain(origin.split("/")[0]):
1123         raise ValueError(f"Bad origin name='{origin}'")
1124     elif is_blacklisted(domain):
1125         raise Exception(f"domain='{domain}' is blacklisted, but method invoked")
1126
1127     # DEBUG: print("DEBUG: domain,origin,originator,path:", domain, origin, originator, path)
1128     software = determine_software(domain, path)
1129     # DEBUG: print("DEBUG: Determined software:", software)
1130
1131     print(f"INFO: Adding instance domain='{domain}' (origin='{origin}',software='{software}')")
1132     try:
1133         cursor.execute(
1134             "INSERT INTO instances (domain, origin, originator, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
1135             (
1136                domain,
1137                origin,
1138                originator,
1139                get_hash(domain),
1140                software,
1141                time.time()
1142             ),
1143         )
1144
1145         cache.set_cache_key("is_registered", domain, True)
1146
1147         if instances.has_pending_instance_data(domain):
1148             # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo being updated ...")
1149             instances.set_instance_data("last_status_code"  , domain, None)
1150             instances.set_instance_data("last_error_details", domain, None)
1151             instances.update_instance_data(domain)
1152             remove_pending_error(domain)
1153
1154         if domain in pending_errors:
1155             # DEBUG: print("DEBUG: domain has pending error being updated:", domain)
1156             update_last_error(domain, pending_errors[domain])
1157             remove_pending_error(domain)
1158
1159     except BaseException as e:
1160         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
1161         sys.exit(255)
1162     else:
1163         # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
1164         update_last_nodeinfo(domain)
1165
1166     # DEBUG: print("DEBUG: EXIT!")
1167
1168 def send_bot_post(instance: str, blocks: dict):
1169     # DEBUG: print(f"DEBUG: instance={instance},blocks()={len(blocks)} - CALLED!")
1170     message = instance + " has blocked the following instances:\n\n"
1171     truncated = False
1172
1173     if len(blocks) > 20:
1174         truncated = True
1175         blocks = blocks[0 : 19]
1176
1177     for block in blocks:
1178         if block["reason"] == None or block["reason"] == '':
1179             message = message + block["blocked"] + " with unspecified reason\n"
1180         else:
1181             if len(block["reason"]) > 420:
1182                 block["reason"] = block["reason"][0:419] + "[…]"
1183
1184             message = message + block["blocked"] + ' for "' + block["reason"].replace("@", "@\u200b") + '"\n'
1185
1186     if truncated:
1187         message = message + "(the list has been truncated to the first 20 entries)"
1188
1189     botheaders = {**api_headers, **{"Authorization": "Bearer " + config["bot_token"]}}
1190
1191     req = reqto.post(
1192         f"{config['bot_instance']}/api/v1/statuses",
1193         data={
1194             "status"      : message,
1195             "visibility"  : config['bot_visibility'],
1196             "content_type": "text/plain"
1197         },
1198         headers=botheaders,
1199         timeout=10
1200     ).json()
1201
1202     return True
1203
1204 def get_mastodon_blocks(domain: str) -> dict:
1205     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1206     if type(domain) != str:
1207         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1208     elif domain == "":
1209         raise ValueError(f"Parameter 'domain' cannot be empty")
1210
1211     # DEBUG: print("DEBUG: Fetching mastodon blocks from domain:", domain)
1212     blocks = {
1213         "Suspended servers": [],
1214         "Filtered media"   : [],
1215         "Limited servers"  : [],
1216         "Silenced servers" : [],
1217     }
1218
1219     try:
1220         doc = bs4.BeautifulSoup(
1221             reqto.get(f"https://{domain}/about", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
1222             "html.parser",
1223         )
1224     except BaseException as e:
1225         print("ERROR: Cannot fetch from domain:", domain, e)
1226         update_last_error(domain, e)
1227         return {}
1228
1229     for header in doc.find_all("h3"):
1230         header_text = tidyup_domain(header.text)
1231
1232         if header_text in language_mapping:
1233             # DEBUG: print(f"DEBUG: header_text='{header_text}'")
1234             header_text = language_mapping[header_text]
1235
1236         if header_text in blocks or header_text.lower() in blocks:
1237             # replaced find_next_siblings with find_all_next to account for instances that e.g. hide lists in dropdown menu
1238             for line in header.find_all_next("table")[0].find_all("tr")[1:]:
1239                 blocks[header_text].append(
1240                     {
1241                         "domain": tidyup_domain(line.find("span").text),
1242                         "hash"  : tidyup_domain(line.find("span")["title"][9:]),
1243                         "reason": tidyup_domain(line.find_all("td")[1].text),
1244                     }
1245                 )
1246
1247     # DEBUG: print("DEBUG: Returning blocks for domain:", domain)
1248     return {
1249         "reject"        : blocks["Suspended servers"],
1250         "media_removal" : blocks["Filtered media"],
1251         "followers_only": blocks["Limited servers"] + blocks["Silenced servers"],
1252     }
1253
1254 def get_friendica_blocks(domain: str) -> dict:
1255     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1256     if type(domain) != str:
1257         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1258     elif domain == "":
1259         raise ValueError(f"Parameter 'domain' cannot be empty")
1260
1261     # DEBUG: print("DEBUG: Fetching friendica blocks from domain:", domain)
1262     blocks = []
1263
1264     try:
1265         doc = bs4.BeautifulSoup(
1266             reqto.get(f"https://{domain}/friendica", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
1267             "html.parser",
1268         )
1269     except BaseException as e:
1270         print("WARNING: Failed to fetch /friendica from domain:", domain, e)
1271         update_last_error(domain, e)
1272         return {}
1273
1274     blocklist = doc.find(id="about_blocklist")
1275
1276     # Prevents exceptions:
1277     if blocklist is None:
1278         # DEBUG: print("DEBUG: Instance has no block list:", domain)
1279         return {}
1280
1281     for line in blocklist.find("table").find_all("tr")[1:]:
1282         # DEBUG: print(f"DEBUG: line='{line}'")
1283         blocks.append({
1284             "domain": tidyup_domain(line.find_all("td")[0].text),
1285             "reason": tidyup_domain(line.find_all("td")[1].text)
1286         })
1287
1288     # DEBUG: print("DEBUG: Returning blocks() for domain:", domain, len(blocks))
1289     return {
1290         "reject": blocks
1291     }
1292
1293 def get_misskey_blocks(domain: str) -> dict:
1294     # DEBUG: print(f"DEBUG: domain={domain} - CALLED!")
1295     if type(domain) != str:
1296         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
1297     elif domain == "":
1298         raise ValueError(f"Parameter 'domain' cannot be empty")
1299
1300     # DEBUG: print("DEBUG: Fetching misskey blocks from domain:", domain)
1301     blocks = {
1302         "suspended": [],
1303         "blocked"  : []
1304     }
1305
1306     offset = 0
1307     step = config["misskey_offset"]
1308     while True:
1309         # iterating through all "suspended" (follow-only in its terminology)
1310         # instances page-by-page, since that troonware doesn't support
1311         # sending them all at once
1312         try:
1313             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
1314             if offset == 0:
1315                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1316                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1317                     "sort"     : "+pubAt",
1318                     "host"     : None,
1319                     "suspended": True,
1320                     "limit"    : step
1321                 }), {"Origin": domain})
1322             else:
1323                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1324                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1325                     "sort"     : "+pubAt",
1326                     "host"     : None,
1327                     "suspended": True,
1328                     "limit"    : step,
1329                     "offset"   : offset - 1
1330                 }), {"Origin": domain})
1331
1332             # DEBUG: print("DEBUG: fetched():", len(fetched))
1333             if len(fetched) == 0:
1334                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1335                 break
1336             elif len(fetched) != config["misskey_offset"]:
1337                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1338                 offset = offset + (config["misskey_offset"] - len(fetched))
1339             else:
1340                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1341                 offset = offset + step
1342
1343             for instance in fetched:
1344                 # just in case
1345                 if instance["isSuspended"]:
1346                     blocks["suspended"].append(
1347                         {
1348                             "domain": tidyup_domain(instance["host"]),
1349                             # no reason field, nothing
1350                             "reason": None
1351                         }
1352                     )
1353
1354         except BaseException as e:
1355             print("WARNING: Caught error, exiting loop:", domain, e)
1356             update_last_error(domain, e)
1357             offset = 0
1358             break
1359
1360     while True:
1361         # same shit, different asshole ("blocked" aka full suspend)
1362         try:
1363             if offset == 0:
1364                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1365                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1366                     "sort"   : "+pubAt",
1367                     "host"   : None,
1368                     "blocked": True,
1369                     "limit"  : step
1370                 }), {"Origin": domain})
1371             else:
1372                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1373                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1374                     "sort"   : "+pubAt",
1375                     "host"   : None,
1376                     "blocked": True,
1377                     "limit"  : step,
1378                     "offset" : offset-1
1379                 }), {"Origin": domain})
1380
1381             # DEBUG: print("DEBUG: fetched():", len(fetched))
1382             if len(fetched) == 0:
1383                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1384                 break
1385             elif len(fetched) != config["misskey_offset"]:
1386                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1387                 offset = offset + (config["misskey_offset"] - len(fetched))
1388             else:
1389                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1390                 offset = offset + step
1391
1392             for instance in fetched:
1393                 if instance["isBlocked"]:
1394                     blocks["blocked"].append({
1395                         "domain": tidyup_domain(instance["host"]),
1396                         "reason": None
1397                     })
1398
1399         except BaseException as e:
1400             print("ERROR: Exception during POST:", domain, e)
1401             update_last_error(domain, e)
1402             offset = 0
1403             break
1404
1405     # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
1406     update_last_instance_fetch(domain)
1407
1408     # DEBUG: print("DEBUG: Returning for domain,blocked(),suspended():", domain, len(blocks["blocked"]), len(blocks["suspended"]))
1409     return {
1410         "reject"        : blocks["blocked"],
1411         "followers_only": blocks["suspended"]
1412     }
1413
1414 def tidyup_domain(domain: str) -> str:
1415     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
1416     if type(domain) != str:
1417         raise ValueError(f"Parameter domain[]={type(domain)} is not expected")
1418
1419     # All lower-case and strip spaces out
1420     domain = domain.lower().strip()
1421
1422     # No port number
1423     domain = re.sub("\:\d+$", "", domain)
1424
1425     # No protocol, sometimes with the slashes
1426     domain = re.sub("^https?\:(\/*)", "", domain)
1427
1428     # No trailing slash
1429     domain = re.sub("\/$", "", domain)
1430
1431     # No @ sign
1432     domain = re.sub("^\@", "", domain)
1433
1434     # No individual users in block lists
1435     domain = re.sub("(.+)\@", "", domain)
1436
1437     # DEBUG: print(f"DEBUG: domain='{domain}' - EXIT!")
1438     return domain