]> git.mxchange.org Git - fba.git/blob - daemon.py
Continued:
[fba.git] / daemon.py
1 #!/usr/bin/python3
2 # -*- coding: utf-8 -*-
3
4 # Fedi API Block - An aggregator for fetching blocking data from fediverse nodes
5 # Copyright (C) 2023 Free Software Foundation
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published
9 # by the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
19
20 import re
21
22 from datetime import datetime
23 from email.utils import format_datetime
24 from pathlib import Path
25
26 import fastapi
27 from fastapi import Request, HTTPException, Query
28 from fastapi.responses import JSONResponse
29 from fastapi.responses import PlainTextResponse
30 from fastapi.staticfiles import StaticFiles
31 from fastapi.templating import Jinja2Templates
32
33 import requests
34 import uvicorn
35
36 from fba import database
37 from fba import utils
38
39 from fba.helpers import config
40 from fba.helpers import json as json_helper
41 from fba.helpers import tidyup
42
43 from fba.models import blocks
44 from fba.models import instances
45
46 router = fastapi.FastAPI(docs_url=config.get("base_url") + "/docs", redoc_url=config.get("base_url") + "/redoc")
47 router.mount(
48     "/static",
49     StaticFiles(directory=Path(__file__).parent.absolute() / "static"),
50     name="static",
51 )
52
53 templates = Jinja2Templates(directory="templates")
54
55 @router.get(config.get("base_url") + "/api/info.json", response_class=JSONResponse)
56 def api_info():
57     database.cursor.execute("SELECT (SELECT COUNT(domain) FROM instances), (SELECT COUNT(domain) FROM instances WHERE software IN ('pleroma', 'mastodon', 'lemmy', 'friendica', 'misskey', 'peertube', 'takahe', 'gotosocial', 'brighteon', 'wildebeest', 'bookwyrm')), (SELECT COUNT(blocker) FROM blocks), (SELECT COUNT(domain) FROM instances WHERE last_error_details IS NOT NULL)")
58     row = database.cursor.fetchone()
59
60     return {
61         "known_instances"    : row[0],
62         "supported_instances": row[1],
63         "blocks_recorded"    : row[2],
64         "erroneous_instances": row[3],
65     }
66
67 @router.get(config.get("base_url") + "/api/scoreboard.json", response_class=JSONResponse)
68 def api_scoreboard(mode: str, amount: int):
69     if amount > config.get("api_limit"):
70         raise HTTPException(status_code=400, detail="Too many results")
71
72     if mode == "blocked":
73         database.cursor.execute("SELECT blocked, COUNT(blocked) AS score FROM blocks GROUP BY blocked ORDER BY score DESC LIMIT ?", [amount])
74     elif mode == "blocker":
75         database.cursor.execute("SELECT blocker, COUNT(blocker) AS score FROM blocks GROUP BY blocker ORDER BY score DESC LIMIT ?", [amount])
76     elif mode == "reference":
77         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])
78     elif mode == "software":
79         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])
80     elif mode == "command":
81         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])
82     elif mode == "error_code":
83         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])
84     elif mode == "detection_mode":
85         database.cursor.execute("SELECT detection_mode, COUNT(domain) AS cnt FROM instances GROUP BY detection_mode ORDER BY cnt DESC LIMIT ?", [amount])
86     elif mode == "avg_peers":
87         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])
88     elif mode == "obfuscator":
89         database.cursor.execute("SELECT software, COUNT(domain) AS cnt FROM instances WHERE has_obfuscation = 1 GROUP BY software ORDER BY cnt DESC LIMIT ?", [amount])
90     elif mode == "obfuscation":
91         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])
92     elif mode == "block_level":
93         database.cursor.execute("SELECT block_level, COUNT(rowid) AS cnt FROM blocks GROUP BY block_level ORDER BY cnt DESC LIMIT ?", [amount])
94     else:
95         raise HTTPException(status_code=400, detail="No filter specified")
96
97     scores = list()
98
99     for domain, score in database.cursor.fetchall():
100         scores.append({
101             "domain": domain,
102             "score" : round(score)
103         })
104
105     return scores
106
107 @router.get(config.get("base_url") + "/api/list.json", response_class=JSONResponse)
108 def api_list(request: Request, mode: str, value: str, amount: int):
109     if mode is None or value is None or amount is None:
110         raise HTTPException(status_code=500, detail="No filter specified")
111     elif amount > config.get("api_limit"):
112         raise HTTPException(status_code=500, detail=f"amount={amount} is to big")
113
114     if mode in ("detection_mode", "software", "command"):
115         database.cursor.execute(
116             f"SELECT domain, origin, software, detection_mode, command, total_peers, total_blocks, first_seen, last_updated \
117 FROM instances \
118 WHERE {mode} = ? \
119 ORDER BY domain \
120 LIMIT ?", [value, amount]
121         )
122
123     domainlist = database.cursor.fetchall()
124
125     return domainlist
126
127 @router.get(config.get("base_url") + "/api/top.json", response_class=JSONResponse)
128 def api_index(request: Request, mode: str, value: str, amount: int):
129     if mode is None or value is None or amount is None:
130         raise HTTPException(status_code=500, detail="No filter specified")
131     elif amount > config.get("api_limit"):
132         raise HTTPException(status_code=500, detail=f"amount={amount} is to big")
133
134     domain = wildchar = punycode = reason = None
135
136     if mode == "block_level":
137         database.cursor.execute(
138             "SELECT blocker, blocked, block_level, reason, first_seen, last_seen FROM blocks WHERE block_level = ? LIMIT ?", [value, amount]
139         )
140     elif mode in ["domain", "reverse"]:
141         domain = tidyup.domain(value)
142         if not utils.is_domain_wanted(domain):
143             raise HTTPException(status_code=500, detail=f"domain='{domain}' is not wanted")
144
145         wildchar = "*." + ".".join(domain.split(".")[-domain.count("."):])
146         punycode = domain.encode("idna").decode("utf-8")
147     elif mode == "reason":
148         reason = re.sub("(%|_)", "", tidyup.reason(value))
149         if len(reason) < 3:
150             raise HTTPException(status_code=400, detail="Keyword is shorter than three characters")
151
152     if mode == "domain":
153         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen \
154 FROM blocks \
155 WHERE blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? \
156 ORDER BY block_level ASC, first_seen ASC \
157 LIMIT ?",
158             [
159                 domain,
160                 "*." + domain,
161                 wildchar,
162                 utils.get_hash(domain),
163                 punycode,
164                 "*." + punycode,
165                 amount
166             ]
167         )
168     elif mode == "reverse":
169         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen \
170 FROM blocks \
171 WHERE blocker = ? OR blocker = ? OR blocker = ? OR blocker = ? OR blocker = ? OR blocker = ? \
172 ORDER BY first_seen ASC \
173 LIMIT ?", [
174             domain,
175             "*." + domain,
176             wildchar,
177             utils.get_hash(domain),
178             punycode,
179             "*." + punycode,
180             amount
181         ])
182     elif mode == "reason":
183         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen \
184 FROM blocks \
185 WHERE reason LIKE ? AND reason != '' \
186 ORDER BY first_seen ASC \
187 LIMIT ?", [
188             "%" + reason + "%",
189             amount
190         ])
191
192     blocklist = database.cursor.fetchall()
193
194     result = {}
195     for blocker, blocked, block_level, reason, first_seen, last_seen in blocklist:
196         if reason is not None and reason != "":
197             reason = reason.replace(",", " ").replace("  ", " ")
198
199         entry = {
200             "blocker"   : blocker,
201             "blocked"   : blocked,
202             "reason"    : reason,
203             "first_seen": first_seen,
204             "last_seen" : last_seen
205         }
206
207         if block_level in result:
208             result[block_level].append(entry)
209         else:
210             result[block_level] = [entry]
211
212     return result
213
214 @router.get(config.get("base_url") + "/api/domain.json", response_class=JSONResponse)
215 def api_domain(domain: str):
216     if domain is None:
217         raise HTTPException(status_code=400, detail="Invalid request, parameter 'domain' missing")
218
219     # Tidy up domain name
220     domain = tidyup.domain(domain).encode("idna").decode("utf-8")
221
222     if not utils.is_domain_wanted(domain):
223         raise HTTPException(status_code=500, detail=f"domain='{domain}' is not wanted")
224
225     # Fetch domain data
226     database.cursor.execute("SELECT * FROM instances WHERE domain = ? LIMIT 1", [domain])
227     domain_data = database.cursor.fetchone()
228
229     if domain_data is None:
230         raise HTTPException(status_code=404, detail=f"domain='{domain}' not found")
231
232     return domain_data
233
234 @router.get(config.get("base_url") + "/api/mutual.json", response_class=JSONResponse)
235 def api_mutual(domains: list[str] = Query()):
236     """Return 200 if federation is open between the two, 4xx otherwise"""
237     database.cursor.execute(
238         "SELECT block_level FROM blocks " \
239         "WHERE ((blocker = :a OR blocker = :b) AND (blocked = :b OR blocked = :a OR blocked = :aw OR blocked = :bw)) " \
240         "AND block_level = 'reject' " \
241         "LIMIT 1",
242         {
243             "a" : domains[0],
244             "b" : domains[1],
245             "aw": "*." + domains[0],
246             "bw": "*." + domains[1],
247         },
248     )
249
250     if database.cursor.fetchone() is not None:
251         # Blocks found
252         return JSONResponse(status_code=418, content={})
253
254     # No known blocks
255     return JSONResponse(status_code=200, content={})
256
257 @router.get(config.get("base_url") + "/scoreboard")
258 def scoreboard(request: Request, mode: str, amount: int):
259     response = None
260
261     if mode == "blocker" and amount > 0:
262         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=blocker&amount={amount}")
263     elif mode == "blocked" and amount > 0:
264         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=blocked&amount={amount}")
265     elif mode == "reference" and amount > 0:
266         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=reference&amount={amount}")
267     elif mode == "software" and amount > 0:
268         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=software&amount={amount}")
269     elif mode == "command" and amount > 0:
270         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=command&amount={amount}")
271     elif mode == "error_code" and amount > 0:
272         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=error_code&amount={amount}")
273     elif mode == "detection_mode" and amount > 0:
274         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=detection_mode&amount={amount}")
275     elif mode == "avg_peers" and amount > 0:
276         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=avg_peers&amount={amount}")
277     elif mode == "obfuscator" and amount > 0:
278         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=obfuscator&amount={amount}")
279     elif mode == "obfuscation" and amount > 0:
280         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=obfuscation&amount={amount}")
281     elif mode == "block_level" and amount > 0:
282         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=block_level&amount={amount}")
283     else:
284         raise HTTPException(status_code=400, detail="No filter specified")
285
286     if response is None:
287         raise HTTPException(status_code=500, detail="Could not determine scores")
288     elif not response.ok:
289         raise HTTPException(status_code=response.status_code, detail=response.text)
290
291     return templates.TemplateResponse("views/scoreboard.html", {
292         "base_url"  : config.get("base_url"),
293         "slogan"    : config.get("slogan"),
294         "theme"     : config.get("theme"),
295         "request"   : request,
296         "scoreboard": True,
297         "mode"      : mode,
298         "amount"    : amount,
299         "scores"    : json_helper.from_response(response)
300     })
301
302 @router.get(config.get("base_url") + "/")
303 def index(request: Request):
304     # Get info
305     response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/info.json")
306
307     if not response.ok:
308         raise HTTPException(status_code=response.status_code, detail=response.text)
309
310     return templates.TemplateResponse("views/index.html", {
311         "request": request,
312         "theme"  : config.get("theme"),
313         "info"   : response.json(),
314         "slogan" : config.get("slogan"),
315     })
316
317 @router.get(config.get("base_url") + "/list")
318 def list_domains(request: Request, mode: str, value: str, amount: int = config.get("api_limit")):
319     if mode == "detection_mode" and not instances.valid(value, "detection_mode"):
320         raise HTTPException(status_code=500, detail="Invalid detection mode provided")
321
322     response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/list.json?mode={mode}&value={value}&amount={amount}")
323
324     domainlist = list()
325     if response is not None and response.ok:
326         domainlist = response.json()
327         tformat = config.get("timestamp_format")
328         for row in domainlist:
329             row["first_seen"]   = datetime.utcfromtimestamp(row["first_seen"]).strftime(tformat)
330             row["last_updated"] = datetime.utcfromtimestamp(row["last_updated"]).strftime(tformat) if isinstance(row["last_updated"], float) else None
331
332     return templates.TemplateResponse("views/list.html", {
333         "request"   : request,
334         "mode"      : mode if response is not None else None,
335         "value"     : value if response is not None else None,
336         "amount"    : amount if response is not None else None,
337         "found"     : len(domainlist),
338         "domainlist": domainlist,
339         "slogan"    : config.get("slogan"),
340         "theme"     : config.get("theme"),
341     })
342
343 @router.get(config.get("base_url") + "/top")
344 def top(request: Request, mode: str, value: str, amount: int = config.get("api_limit")):
345     if mode == "block_level" and not blocks.valid(value, "block_level"):
346         raise HTTPException(status_code=500, detail="Invalid block level provided")
347     elif mode in ["domain", "reverse"] and not utils.is_domain_wanted(value):
348         raise HTTPException(status_code=500, detail="Invalid or blocked domain specified")
349
350     response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/top.json?mode={mode}&value={value}&amount={amount}")
351
352     found = 0
353     blocklist = list()
354     if response.ok and response.status_code == 200 and len(response.text) > 0:
355         blocklist = response.json()
356
357         tformat = config.get("timestamp_format")
358         for block_level in blocklist:
359             for row in blocklist[block_level]:
360                 row["first_seen"] = datetime.utcfromtimestamp(row["first_seen"]).strftime(tformat)
361                 row["last_seen"]  = datetime.utcfromtimestamp(row["last_seen"]).strftime(tformat) if isinstance(row["last_seen"], float) else None
362                 found = found + 1
363
364     return templates.TemplateResponse("views/top.html", {
365         "request"  : request,
366         "mode"     : mode if response is not None else None,
367         "value"    : value if response is not None else None,
368         "amount"   : amount if response is not None else None,
369         "found"    : found,
370         "blocklist": blocklist,
371         "slogan"   : config.get("slogan"),
372         "theme"    : config.get("theme"),
373     })
374
375 @router.get(config.get("base_url") + "/infos")
376 def infos(request: Request, domain: str):
377     if domain is None:
378         raise HTTPException(status_code=400, detail="Invalid request, parameter 'domain' missing")
379
380     # Tidy up domain name
381     domain = tidyup.domain(domain).encode("idna").decode("utf-8")
382
383     if not utils.is_domain_wanted(domain):
384         raise HTTPException(status_code=500, detail=f"domain='{domain}' is not wanted")
385
386     response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/domain.json?domain={domain}")
387
388     if not response.ok or response.status_code >= 300 or response.text.strip() == "":
389         raise HTTPException(status_code=response.status_code, detail=response.reason)
390
391     domain_data = response.json()
392
393     # Format timestamps
394     tformat = config.get("timestamp_format")
395     instance = dict()
396     for key in domain_data.keys():
397         if key in ["last_nodeinfo", "last_blocked", "first_seen", "last_updated", "last_instance_fetch"] and isinstance(domain_data[key], float):
398             # Timestamps
399             instance[key] = datetime.utcfromtimestamp(domain_data[key]).strftime(tformat)
400         else:
401             # Generic
402             instance[key] = domain_data[key]
403
404     return templates.TemplateResponse("views/infos.html", {
405         "request" : request,
406         "domain"  : domain,
407         "instance": instance,
408         "theme"   : config.get("theme"),
409         "slogan"  : config.get("slogan"),
410     })
411
412 @router.get(config.get("base_url") + "/rss")
413 def rss(request: Request, domain: str = None):
414     if domain is not None:
415         domain = tidyup.domain(domain).encode("idna").decode("utf-8")
416
417         wildchar = "*." + ".".join(domain.split(".")[-domain.count("."):])
418         punycode = domain.encode("idna").decode("utf-8")
419
420         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 ?", [
421             domain,
422             "*." + domain, wildchar,
423             utils.get_hash(domain),
424             punycode,
425             "*." + punycode,
426             config.get("rss_limit")
427         ])
428     else:
429         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")])
430
431     result = database.cursor.fetchall()
432     blocklist = []
433
434     for row in result:
435         blocklist.append({
436             "blocker"    : row[0],
437             "blocked"    : row[1],
438             "block_level": row[2],
439             "reason"     : "Provided reason: '" + row[3] + "'" if row[3] is not None and row[3] != "" else "No reason provided.",
440             "first_seen" : format_datetime(datetime.fromtimestamp(row[4])),
441             "last_seen"  : format_datetime(datetime.fromtimestamp(row[5])),
442         })
443
444     return templates.TemplateResponse("views/rss.xml", {
445         "request"  : request,
446         "timestamp": format_datetime(datetime.now()),
447         "domain"   : domain,
448         "hostname" : config.get("hostname"),
449         "blocks"   : blocklist
450     }, headers={
451         "Content-Type": "routerlication/rss+xml"
452     })
453
454 @router.get(config.get("base_url") + "/robots.txt", response_class=PlainTextResponse)
455 def robots(request: Request):
456     return templates.TemplateResponse("views/robots.txt", {
457         "request" : request,
458         "base_url": config.get("base_url")
459     })
460
461 if __name__ == "__main__":
462     uvicorn.run("daemon:router", host=config.get("host"), port=config.get("port"), log_level=config.get("log_level"))