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