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