]> git.mxchange.org Git - fba.git/blob - api.py
Continued:
[fba.git] / api.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 re
18
19 from datetime import datetime
20 from email.utils import format_datetime
21 from fastapi import Request, HTTPException, Query
22 from fastapi.responses import JSONResponse
23 from fastapi.responses import PlainTextResponse
24 from fastapi.templating import Jinja2Templates
25
26 import fastapi
27 import uvicorn
28 import requests
29 import validators
30
31 from fba import database
32 from fba import utils
33
34 from fba.helpers import config
35 from fba.helpers import tidyup
36
37 from fba.http import network
38
39 router = fastapi.FastAPI(docs_url=config.get("base_url") + "/docs", redoc_url=config.get("base_url") + "/redoc")
40 templates = Jinja2Templates(directory="templates")
41
42 @router.get(config.get("base_url") + "/api/info.json", response_class=JSONResponse)
43 def api_info():
44     database.cursor.execute("SELECT (SELECT COUNT(domain) FROM instances), (SELECT COUNT(domain) FROM instances WHERE software IN ('pleroma', 'mastodon', 'lemmy', 'friendica', 'misskey', 'peertube')), (SELECT COUNT(blocker) FROM blocks), (SELECT COUNT(domain) FROM instances WHERE last_error_details IS NOT NULL)")
45     row = database.cursor.fetchone()
46
47     return {
48         "known_instances"    : row[0],
49         "supported_instances": row[1],
50         "blocks_recorded"    : row[2],
51         "erroneous_instances": row[3],
52         "slogan"             : config.get("slogan"),
53     }
54
55 @router.get(config.get("base_url") + "/api/scoreboard.json", response_class=JSONResponse)
56 def api_scoreboard(mode: str, amount: int):
57     if amount > 500:
58         raise HTTPException(status_code=400, detail="Too many results")
59
60     if mode == "blocked":
61         database.cursor.execute("SELECT blocked, COUNT(blocked) AS score FROM blocks WHERE block_level = 'reject' GROUP BY blocked ORDER BY score DESC LIMIT ?", [amount])
62     elif mode == "blocker":
63         database.cursor.execute("SELECT blocker, COUNT(blocker) AS score FROM blocks WHERE block_level = 'reject' GROUP BY blocker ORDER BY score DESC LIMIT ?", [amount])
64     elif mode == "reference":
65         database.cursor.execute("SELECT origin, COUNT(domain) AS score FROM instances WHERE software IS NOT NULL GROUP BY origin ORDER BY score DESC LIMIT ?", [amount])
66     elif mode == "software":
67         database.cursor.execute("SELECT software, COUNT(domain) AS score FROM instances WHERE software IS NOT NULL GROUP BY software ORDER BY score DESC, software ASC LIMIT ?", [amount])
68     elif mode == "command":
69         database.cursor.execute("SELECT command, COUNT(domain) AS score FROM instances WHERE command IS NOT NULL GROUP BY command ORDER BY score DESC, command ASC LIMIT ?", [amount])
70     elif mode == "error_code":
71         database.cursor.execute("SELECT last_status_code, COUNT(domain) AS score FROM instances WHERE last_status_code IS NOT NULL AND last_status_code != '200' GROUP BY last_status_code ORDER BY score DESC LIMIT ?", [amount])
72     elif mode == "detection_mode":
73         database.cursor.execute("SELECT detection_mode, COUNT(domain) AS cnt FROM instances GROUP BY detection_mode ORDER BY cnt DESC LIMIT ?", [amount])
74     elif mode == "avg_peers":
75         database.cursor.execute("SELECT software, AVG(total_peers) AS average FROM instances WHERE software IS NOT NULL GROUP BY software HAVING average>0 ORDER BY average DESC LIMIT ?", [amount])
76     elif mode == "obfuscator":
77         database.cursor.execute("SELECT software, COUNT(domain) AS cnt FROM instances WHERE has_obfuscation = 1 GROUP BY software ORDER BY cnt DESC LIMIT ?", [amount])
78     elif mode == "obfuscation":
79         database.cursor.execute("SELECT has_obfuscation, COUNT(domain) AS cnt FROM instances WHERE software IN ('pleroma', 'mastodon', 'friendica') GROUP BY has_obfuscation ORDER BY cnt DESC LIMIT ?", [amount])
80     elif mode == "block_level":
81         database.cursor.execute("SELECT block_level, COUNT(rowid) AS cnt FROM blocks GROUP BY block_level ORDER BY cnt DESC LIMIT ?", [amount])
82     else:
83         raise HTTPException(status_code=400, detail="No filter specified")
84
85     scores = list()
86
87     for domain, score in database.cursor.fetchall():
88         scores.append({
89             "domain": domain,
90             "score" : round(score)
91         })
92
93     return scores
94
95 @router.get(config.get("base_url") + "/api/index.json", response_class=JSONResponse)
96 def api_blocked(domain: str = None, reason: str = None, reverse: str = None):
97     if domain is None and reason is None and reverse is None:
98         raise HTTPException(status_code=400, detail="No filter specified")
99
100     if reason is not None:
101         reason = re.sub("(%|_)", "", tidyup.reason(reason))
102         if len(reason) < 3:
103             raise HTTPException(status_code=400, detail="Keyword is shorter than three characters")
104
105     if domain is not None:
106         domain = tidyup.domain(domain)
107         if not validators.domain(domain.split("/")[0]):
108             raise HTTPException(status_code=500, detail="Invalid domain")
109
110         wildchar = "*." + ".".join(domain.split(".")[-domain.count("."):])
111         punycode = domain.encode('idna').decode('utf-8')
112
113         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen FROM blocks WHERE blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? ORDER BY first_seen ASC",
114                   (domain, "*." + domain, wildchar, utils.get_hash(domain), punycode, "*." + punycode))
115     elif reverse is not None:
116         reverse = tidyup.domain(reverse)
117         if not validators.domain(reverse):
118             raise HTTPException(status_code=500, detail="Invalid domain")
119
120         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen FROM blocks WHERE blocker = ? ORDER BY first_seen ASC", [reverse])
121     else:
122         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen FROM blocks WHERE reason like ? AND reason != '' ORDER BY first_seen ASC", ["%" + reason + "%"])
123
124     blocklist = database.cursor.fetchall()
125
126     result = {}
127     for blocker, blocked, block_level, reason, first_seen, last_seen in blocklist:
128         if reason is not None and reason != "":
129             reason = reason.replace(",", " ").replace("  ", " ")
130
131         entry = {
132             "blocker"   : blocker,
133             "blocked"   : blocked,
134             "reason"    : reason,
135             "first_seen": first_seen,
136             "last_seen" : last_seen
137         }
138
139         if block_level in result:
140             result[block_level].append(entry)
141         else:
142             result[block_level] = [entry]
143
144     return result
145
146 @router.get(config.get("base_url") + "/api/mutual.json", response_class=JSONResponse)
147 def api_mutual(domains: list[str] = Query()):
148     """Return 200 if federation is open between the two, 4xx otherwise"""
149     database.cursor.execute(
150         "SELECT block_level FROM blocks " \
151         "WHERE ((blocker = :a OR blocker = :b) AND (blocked = :b OR blocked = :a OR blocked = :aw OR blocked = :bw)) " \
152         "AND block_level = 'reject' " \
153         "LIMIT 1",
154         {
155             "a" : domains[0],
156             "b" : domains[1],
157             "aw": "*." + domains[0],
158             "bw": "*." + domains[1],
159         },
160     )
161     response = database.cursor.fetchone()
162
163     if response is not None:
164         # Blocks found
165         return JSONResponse(status_code=418, content={})
166
167     # No known blocks
168     return JSONResponse(status_code=200, content={})
169
170 @router.get(config.get("base_url") + "/scoreboard")
171 def scoreboard(request: Request, mode: str, amount: int):
172     response = None
173
174     if mode == "blocker" and amount > 0:
175         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=blocker&amount={amount}")
176     elif mode == "blocked" and amount > 0:
177         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=blocked&amount={amount}")
178     elif mode == "reference" and amount > 0:
179         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=reference&amount={amount}")
180     elif mode == "software" and amount > 0:
181         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=software&amount={amount}")
182     elif mode == "command" and amount > 0:
183         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=command&amount={amount}")
184     elif mode == "error_code" and amount > 0:
185         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=error_code&amount={amount}")
186     elif mode == "detection_mode" and amount > 0:
187         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=detection_mode&amount={amount}")
188     elif mode == "avg_peers" and amount > 0:
189         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=avg_peers&amount={amount}")
190     elif mode == "obfuscator" and amount > 0:
191         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=obfuscator&amount={amount}")
192     elif mode == "obfuscation" and amount > 0:
193         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=obfuscation&amount={amount}")
194     elif mode == "block_level" and amount > 0:
195         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=block_level&amount={amount}")
196     else:
197         raise HTTPException(status_code=400, detail="No filter specified")
198
199     if response is None:
200         raise HTTPException(status_code=500, detail="Could not determine scores")
201     elif not response.ok:
202         raise HTTPException(status_code=response.status_code, detail=response.text)
203
204     return templates.TemplateResponse("views/scoreboard.html", {
205         "base_url"  : config.get("base_url"),
206         "slogan"    : config.get("slogan"),
207         "request"   : request,
208         "scoreboard": True,
209         "mode"      : mode,
210         "amount"    : amount,
211         "scores"    : network.json_from_response(response)
212     })
213
214 @router.get(config.get("base_url") + "/")
215 def index(request: Request):
216     # Get info
217     response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/info.json")
218
219     if not response.ok:
220         raise HTTPException(status_code=response.status_code, detail=response.text)
221
222     return templates.TemplateResponse("views/index.html", {
223         "request": request,
224         "info"   : response.json()
225     })
226
227 @router.get(config.get("base_url") + "/top")
228 def top(request: Request, domain: str = None, reason: str = None, reverse: str = None):
229     if domain == "" or reason == "" or reverse == "":
230         raise HTTPException(status_code=500, detail="Insufficient parameter provided")
231
232     response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/info.json")
233
234     if not response.ok:
235         raise HTTPException(status_code=response.status_code, detail=response.text)
236
237     info = response.json()
238     response = None
239
240     if domain is not None:
241         domain = tidyup.domain(domain)
242         if not validators.domain(domain.split("/")[0]):
243             raise HTTPException(status_code=500, detail="Invalid domain")
244
245         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/index.json?domain={domain}")
246     elif reason is not None:
247         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/index.json?reason={reason}")
248     elif reverse is not None:
249         reverse = tidyup.domain(reverse)
250         if not validators.domain(reverse):
251             raise HTTPException(status_code=500, detail="Invalid domain")
252
253         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/index.json?reverse={reverse}")
254
255     if response is not None:
256         if not response.ok:
257             raise HTTPException(status_code=response.status_code, detail=response.text)
258
259         blocklist = response.json()
260
261         for block_level in blocklist:
262             for block in blocklist[block_level]:
263                 block["first_seen"] = datetime.utcfromtimestamp(block["first_seen"]).strftime(config.get("timestamp_format"))
264                 block["last_seen"]  = datetime.utcfromtimestamp(block["last_seen"]).strftime(config.get("timestamp_format"))
265
266     return templates.TemplateResponse("views/top.html", {
267         "request": request,
268         "domain" : domain,
269         "blocks" : blocklist,
270         "reason" : reason,
271         "reverse": reverse,
272         "info"   : info
273     })
274
275 @router.get(config.get("base_url") + "/rss")
276 def rss(request: Request, domain: str = None):
277     if domain is not None:
278         domain = tidyup.domain(domain)
279
280         wildchar = "*." + ".".join(domain.split(".")[-domain.count("."):])
281         punycode = domain.encode("idna").decode("utf-8")
282
283         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen FROM blocks WHERE blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? ORDER BY first_seen DESC LIMIT ?", [
284             domain,
285             "*." + domain, wildchar,
286             utils.get_hash(domain),
287             punycode,
288             "*." + punycode,
289             config.get("rss_limit")
290         ])
291     else:
292         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen FROM blocks ORDER BY first_seen DESC LIMIT ?", [config.get("rss_limit")])
293
294     result = database.cursor.fetchall()
295     blocklist = []
296
297     for row in result:
298         blocklist.append({
299             "blocker"    : row[0],
300             "blocked"    : row[1],
301             "block_level": row[2],
302             "reason"     : "Provided reason: '" + row[3] + "'" if row[3] is not None and row[3] != "" else "No reason provided.",
303             "first_seen" : format_datetime(datetime.fromtimestamp(row[4])),
304             "last_seen"  : format_datetime(datetime.fromtimestamp(row[5])),
305         })
306
307     return templates.TemplateResponse("rss.xml", {
308         "request"  : request,
309         "timestamp": format_datetime(datetime.now()),
310         "domain"   : domain,
311         "hostname" : config.get("hostname"),
312         "blocks"   : blocklist
313     }, headers={
314         "Content-Type": "routerlication/rss+xml"
315     })
316
317 @router.get(config.get("base_url") + "/robots.txt", response_class=PlainTextResponse)
318 def robots(request: Request):
319     return templates.TemplateResponse("robots.txt", {
320         "request" : request,
321         "base_url": config.get("base_url")
322     })
323
324 if __name__ == "__main__":
325     uvicorn.run("api:router", host=config.get("host"), port=config.get("port"), log_level=config.get("log_level"))