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