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