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