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