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