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