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