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