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