]> git.mxchange.org Git - fba.git/blob - daemon.py
Continued:
[fba.git] / daemon.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     }
63
64 @router.get(config.get("base_url") + "/api/scoreboard.json", response_class=JSONResponse)
65 def api_scoreboard(mode: str, amount: int):
66     if amount > config.get("api_limit"):
67         raise HTTPException(status_code=400, detail="Too many results")
68
69     if mode == "blocked":
70         database.cursor.execute("SELECT blocked, COUNT(blocked) AS score FROM blocks GROUP BY blocked ORDER BY score DESC LIMIT ?", [amount])
71     elif mode == "blocker":
72         database.cursor.execute("SELECT blocker, COUNT(blocker) AS score FROM blocks GROUP BY blocker ORDER BY score DESC LIMIT ?", [amount])
73     elif mode == "reference":
74         database.cursor.execute("SELECT origin, COUNT(domain) AS score FROM instances WHERE origin IS NOT NULL GROUP BY origin ORDER BY score DESC LIMIT ?", [amount])
75     elif mode == "software":
76         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])
77     elif mode == "command":
78         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])
79     elif mode == "error_code":
80         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])
81     elif mode == "detection_mode":
82         database.cursor.execute("SELECT detection_mode, COUNT(domain) AS cnt FROM instances GROUP BY detection_mode ORDER BY cnt DESC LIMIT ?", [amount])
83     elif mode == "avg_peers":
84         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])
85     elif mode == "obfuscator":
86         database.cursor.execute("SELECT software, COUNT(domain) AS cnt FROM instances WHERE has_obfuscation = 1 GROUP BY software ORDER BY cnt DESC LIMIT ?", [amount])
87     elif mode == "obfuscation":
88         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])
89     elif mode == "block_level":
90         database.cursor.execute("SELECT block_level, COUNT(rowid) AS cnt FROM blocks GROUP BY block_level ORDER BY cnt DESC LIMIT ?", [amount])
91     else:
92         raise HTTPException(status_code=400, detail="No filter specified")
93
94     scores = list()
95
96     for domain, score in database.cursor.fetchall():
97         scores.append({
98             "domain": domain,
99             "score" : round(score)
100         })
101
102     return scores
103
104 @router.get(config.get("base_url") + "/api/index.json", response_class=JSONResponse)
105 def api_index(request: Request, mode: str, value: str, amount: int):
106     if mode is None or value is None or amount is None:
107         raise HTTPException(status_code=500, detail="No filter specified")
108     elif amount > config.get("api_limit"):
109         raise HTTPException(status_code=500, detail=f"amount={amount} is to big")
110
111     domain = wildchar = punycode = reason = None
112
113     if mode == "block_level":
114         database.cursor.execute(
115             "SELECT blocker, blocked, block_level, reason, first_seen, last_seen FROM blocks WHERE block_level = ? LIMIT ?", [value, amount]
116         )
117     elif mode in ["domain", "reverse"]:
118         domain = tidyup.domain(value)
119         if not utils.is_domain_wanted(domain):
120             raise HTTPException(status_code=500, detail=f"domain='{domain}' is not wanted")
121
122         wildchar = "*." + ".".join(domain.split(".")[-domain.count("."):])
123         punycode = domain.encode('idna').decode('utf-8')
124     elif mode == "reason":
125         reason = re.sub("(%|_)", "", tidyup.reason(value))
126         if len(reason) < 3:
127             raise HTTPException(status_code=400, detail="Keyword is shorter than three characters")
128
129     if mode == "domain":
130         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen \
131 FROM blocks \
132 WHERE blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? ORDER BY first_seen ASC LIMIT ?",
133             [
134                 domain,
135                 "*." + domain,
136                 wildchar,
137                 utils.get_hash(domain),
138                 punycode,
139                 "*." + punycode,
140                 amount
141             ]
142         )
143     elif mode == "reverse":
144         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen \
145 FROM blocks \
146 WHERE blocker = ? OR blocker = ? OR blocker = ? OR blocker = ? OR blocker = ? OR blocker = ? \
147 ORDER BY first_seen ASC \
148 LIMIT ?", [
149             domain,
150             "*." + domain,
151             wildchar,
152             utils.get_hash(domain),
153             punycode,
154             "*." + punycode,
155             amount
156         ])
157     elif mode == "reason":
158         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen \
159 FROM blocks \
160 WHERE reason LIKE ? AND reason != '' \
161 ORDER BY first_seen ASC \
162 LIMIT ?", [
163             "%" + reason + "%",
164             amount
165         ])
166
167     blocklist = database.cursor.fetchall()
168
169     result = {}
170     for blocker, blocked, block_level, reason, first_seen, last_seen in blocklist:
171         if reason is not None and reason != "":
172             reason = reason.replace(",", " ").replace("  ", " ")
173
174         entry = {
175             "blocker"   : blocker,
176             "blocked"   : blocked,
177             "reason"    : reason,
178             "first_seen": first_seen,
179             "last_seen" : last_seen
180         }
181
182         if block_level in result:
183             result[block_level].append(entry)
184         else:
185             result[block_level] = [entry]
186
187     return result
188
189 @router.get(config.get("base_url") + "/api/mutual.json", response_class=JSONResponse)
190 def api_mutual(domains: list[str] = Query()):
191     """Return 200 if federation is open between the two, 4xx otherwise"""
192     database.cursor.execute(
193         "SELECT block_level FROM blocks " \
194         "WHERE ((blocker = :a OR blocker = :b) AND (blocked = :b OR blocked = :a OR blocked = :aw OR blocked = :bw)) " \
195         "AND block_level = 'reject' " \
196         "LIMIT 1",
197         {
198             "a" : domains[0],
199             "b" : domains[1],
200             "aw": "*." + domains[0],
201             "bw": "*." + domains[1],
202         },
203     )
204     response = database.cursor.fetchone()
205
206     if response is not None:
207         # Blocks found
208         return JSONResponse(status_code=418, content={})
209
210     # No known blocks
211     return JSONResponse(status_code=200, content={})
212
213 @router.get(config.get("base_url") + "/scoreboard")
214 def scoreboard(request: Request, mode: str, amount: int):
215     response = None
216
217     if mode == "blocker" and amount > 0:
218         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=blocker&amount={amount}")
219     elif mode == "blocked" and amount > 0:
220         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=blocked&amount={amount}")
221     elif mode == "reference" and amount > 0:
222         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=reference&amount={amount}")
223     elif mode == "software" and amount > 0:
224         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=software&amount={amount}")
225     elif mode == "command" and amount > 0:
226         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=command&amount={amount}")
227     elif mode == "error_code" and amount > 0:
228         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=error_code&amount={amount}")
229     elif mode == "detection_mode" and amount > 0:
230         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=detection_mode&amount={amount}")
231     elif mode == "avg_peers" and amount > 0:
232         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=avg_peers&amount={amount}")
233     elif mode == "obfuscator" and amount > 0:
234         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=obfuscator&amount={amount}")
235     elif mode == "obfuscation" and amount > 0:
236         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=obfuscation&amount={amount}")
237     elif mode == "block_level" and amount > 0:
238         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=block_level&amount={amount}")
239     else:
240         raise HTTPException(status_code=400, detail="No filter specified")
241
242     if response is None:
243         raise HTTPException(status_code=500, detail="Could not determine scores")
244     elif not response.ok:
245         raise HTTPException(status_code=response.status_code, detail=response.text)
246
247     return templates.TemplateResponse("views/scoreboard.html", {
248         "base_url"  : config.get("base_url"),
249         "slogan"    : config.get("slogan"),
250         "theme"     : config.get("theme"),
251         "request"   : request,
252         "scoreboard": True,
253         "mode"      : mode,
254         "amount"    : amount,
255         "scores"    : network.json_from_response(response)
256     })
257
258 @router.get(config.get("base_url") + "/")
259 def index(request: Request):
260     # Get info
261     response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/info.json")
262
263     if not response.ok:
264         raise HTTPException(status_code=response.status_code, detail=response.text)
265
266     return templates.TemplateResponse("views/index.html", {
267         "request": request,
268         "theme"  : config.get("theme"),
269         "info"   : response.json(),
270         "slogan" : config.get("slogan"),
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"))