]> 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 from urllib.parse import urlparse
26
27 import fastapi
28 from fastapi import Request, HTTPException, Query
29 from fastapi.responses import JSONResponse
30 from fastapi.responses import PlainTextResponse
31 from fastapi.staticfiles import StaticFiles
32 from fastapi.templating import Jinja2Templates
33
34 import requests
35 import uvicorn
36
37 from fba import database
38 from fba import utils
39
40 from fba.helpers import blacklist
41 from fba.helpers import config
42 from fba.helpers import json as json_helper
43 from fba.helpers import tidyup
44
45 from fba.models import blocks
46 from fba.models import instances
47
48 router = fastapi.FastAPI(docs_url=config.get("base_url") + "/docs", redoc_url=config.get("base_url") + "/redoc")
49 router.mount(
50     "/static",
51     StaticFiles(directory=Path(__file__).parent.absolute() / "static"),
52     name="static",
53 )
54
55 templates = Jinja2Templates(directory="templates")
56
57 @router.get(config.get("base_url") + "/api/info.json", response_class=JSONResponse)
58 def api_info():
59     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)")
60     row = database.cursor.fetchone()
61
62     return JSONResponse(status_code=200, content={
63         "known_instances"    : row[0],
64         "supported_instances": row[1],
65         "blocks_recorded"    : row[2],
66         "erroneous_instances": row[3],
67     })
68
69 @router.get(config.get("base_url") + "/api/scoreboard.json", response_class=JSONResponse)
70 def api_scoreboard(mode: str, amount: int):
71     if amount > config.get("api_limit"):
72         raise HTTPException(status_code=400, detail="Too many results")
73
74     if mode == "blocked":
75         database.cursor.execute("SELECT blocked, COUNT(blocked) AS score FROM blocks GROUP BY blocked ORDER BY score DESC LIMIT ?", [amount])
76     elif mode == "blocker":
77         database.cursor.execute("SELECT blocker, COUNT(blocker) AS score FROM blocks GROUP BY blocker ORDER BY score DESC LIMIT ?", [amount])
78     elif mode == "reference":
79         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])
80     elif mode == "software":
81         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])
82     elif mode == "command":
83         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])
84     elif mode == "error_code":
85         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])
86     elif mode == "detection_mode":
87         database.cursor.execute("SELECT detection_mode, COUNT(domain) AS cnt FROM instances GROUP BY detection_mode ORDER BY cnt DESC LIMIT ?", [amount])
88     elif mode == "avg_peers":
89         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])
90     elif mode == "obfuscator":
91         database.cursor.execute("SELECT software, COUNT(domain) AS cnt FROM instances WHERE has_obfuscation = 1 GROUP BY software ORDER BY cnt DESC LIMIT ?", [amount])
92     elif mode == "obfuscation":
93         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])
94     elif mode == "block_level":
95         database.cursor.execute("SELECT block_level, COUNT(rowid) AS cnt FROM blocks GROUP BY block_level ORDER BY cnt DESC LIMIT ?", [amount])
96     else:
97         raise HTTPException(status_code=400, detail="No filter specified")
98
99     scores = list()
100
101     for domain, score in database.cursor.fetchall():
102         scores.append({
103             "domain": domain,
104             "score" : round(score)
105         })
106
107     return JSONResponse(status_code=200, content=scores)
108
109 @router.get(config.get("base_url") + "/api/list.json", response_class=JSONResponse)
110 def api_list(request: Request, mode: str, value: str, amount: int):
111     if mode is None or value is None or amount is None:
112         raise HTTPException(status_code=500, detail="No filter specified")
113     elif amount > config.get("api_limit"):
114         raise HTTPException(status_code=500, detail=f"amount={amount} is to big")
115
116     if mode in ("detection_mode", "software", "command"):
117         database.cursor.execute(
118             f"SELECT domain, origin, software, detection_mode, command, total_peers, total_blocks, first_seen, last_updated \
119 FROM instances \
120 WHERE {mode} = ? \
121 ORDER BY domain \
122 LIMIT ?", [value, amount]
123         )
124
125     domainlist = database.cursor.fetchall()
126
127     return JSONResponse(status_code=200, content=dict(domainlist))
128
129 @router.get(config.get("base_url") + "/api/top.json", response_class=JSONResponse)
130 def api_index(request: Request, mode: str, value: str, amount: int):
131     if mode is None or value is None or amount is None:
132         raise HTTPException(status_code=500, detail="No filter specified")
133     elif amount > config.get("api_limit"):
134         raise HTTPException(status_code=500, detail=f"amount={amount} is to big")
135
136     domain = wildchar = punycode = reason = None
137
138     if mode == "block_level":
139         database.cursor.execute(
140             "SELECT blocker, blocked, block_level, reason, first_seen, last_seen FROM blocks WHERE block_level = ? LIMIT ?", [value, amount]
141         )
142     elif mode in ["domain", "reverse"]:
143         domain = tidyup.domain(value)
144         if not utils.is_domain_wanted(domain):
145             raise HTTPException(status_code=500, detail=f"domain='{domain}' is not wanted")
146
147         wildchar = "*." + ".".join(domain.split(".")[-domain.count("."):])
148         punycode = domain.encode("idna").decode("utf-8")
149     elif mode == "reason":
150         reason = re.sub("(%|_)", "", tidyup.reason(value))
151         if len(reason) < 3:
152             raise HTTPException(status_code=400, detail="Keyword is shorter than three characters")
153
154     if mode == "domain":
155         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen \
156 FROM blocks \
157 WHERE blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? OR blocked = ? \
158 ORDER BY block_level ASC, first_seen ASC \
159 LIMIT ?",
160             [
161                 domain,
162                 "*." + domain,
163                 wildchar,
164                 utils.get_hash(domain),
165                 punycode,
166                 "*." + punycode,
167                 amount
168             ]
169         )
170     elif mode == "reverse":
171         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen \
172 FROM blocks \
173 WHERE blocker = ? OR blocker = ? OR blocker = ? OR blocker = ? OR blocker = ? OR blocker = ? \
174 ORDER BY first_seen ASC \
175 LIMIT ?", [
176             domain,
177             "*." + domain,
178             wildchar,
179             utils.get_hash(domain),
180             punycode,
181             "*." + punycode,
182             amount
183         ])
184     elif mode == "reason":
185         database.cursor.execute("SELECT blocker, blocked, block_level, reason, first_seen, last_seen \
186 FROM blocks \
187 WHERE reason LIKE ? AND reason != '' \
188 ORDER BY first_seen ASC \
189 LIMIT ?", [
190             "%" + reason + "%",
191             amount
192         ])
193
194     blocklist = database.cursor.fetchall()
195
196     result = {}
197     for blocker, blocked, block_level, reason, first_seen, last_seen in blocklist:
198         if reason is not None and reason != "":
199             reason = reason.replace(",", " ").replace("  ", " ")
200
201         entry = {
202             "blocker"   : blocker,
203             "blocked"   : blocked,
204             "reason"    : reason,
205             "first_seen": first_seen,
206             "last_seen" : last_seen
207         }
208
209         if block_level in result:
210             result[block_level].append(entry)
211         else:
212             result[block_level] = [entry]
213
214     return result
215
216 @router.get(config.get("base_url") + "/api/domain.json", response_class=JSONResponse)
217 def api_domain(domain: str):
218     if domain is None:
219         raise HTTPException(status_code=400, detail="Invalid request, parameter 'domain' missing")
220
221     # Tidy up domain name
222     domain = tidyup.domain(domain).encode("idna").decode("utf-8")
223
224     if not utils.is_domain_wanted(domain):
225         raise HTTPException(status_code=500, detail=f"domain='{domain}' is not wanted")
226
227     # Fetch domain data
228     database.cursor.execute("SELECT * FROM instances WHERE domain = ? LIMIT 1", [domain])
229     domain_data = database.cursor.fetchone()
230
231     if domain_data is None:
232         raise HTTPException(status_code=404, detail=f"domain='{domain}' not found")
233
234     return JSONResponse(status_code=200, content=dict(domain_data))
235
236 @router.get(config.get("base_url") + "/api/mutual.json", response_class=JSONResponse)
237 def api_mutual(domains: list[str] = Query()):
238     """Return 200 if federation is open between the two, 4xx otherwise"""
239     database.cursor.execute(
240         "SELECT block_level FROM blocks " \
241         "WHERE ((blocker = :a OR blocker = :b) AND (blocked = :b OR blocked = :a OR blocked = :aw OR blocked = :bw)) " \
242         "AND block_level = 'reject' " \
243         "LIMIT 1",
244         {
245             "a" : domains[0],
246             "b" : domains[1],
247             "aw": "*." + domains[0],
248             "bw": "*." + domains[1],
249         },
250     )
251
252     if database.cursor.fetchone() is not None:
253         # Blocks found
254         return JSONResponse(status_code=418, content={})
255
256     # No known blocks
257     return JSONResponse(status_code=200, content={})
258
259 @router.get(config.get("base_url") + "/.well-known/nodeinfo", response_class=JSONResponse)
260 def wellknown_nodeinfo(request: Request):
261     components = urlparse(str(request.url))
262
263     return JSONResponse(status_code=200, content={
264         "links": ({
265             "rel" : "http://nodeinfo.diaspora.software/ns/schema/1.0",
266             "href": f"{components.scheme}://{config.get('hostname')}{config.get('base_url')}/nodeinfo/1.0"
267         })
268     })
269
270 @router.get(config.get("base_url") + "/nodeinfo/1.0", response_class=JSONResponse)
271 def nodeinfo_1_0(request: Request):
272     return JSONResponse(status_code=200, content={
273         "version": "1.0",
274         "software": {
275             "name": "FBA",
276             "version": "0.1",
277         },
278         "protocols": {
279             "inbound": (),
280             "outbound": (
281                 "rss",
282             ),
283         },
284         "services": {
285             "inbound": (),
286             "outbound": (
287                 "rss",
288             ),
289         },
290         "usage": {
291             "users": (),
292         },
293         "openRegistrations": False,
294         "metadata": {
295             "nodeName": "Fedi Block API",
296             "protocols": {
297                 "inbound": (),
298                 "outbound": (
299                     "rss",
300                 ),
301             },
302             "services": {
303                 "inbound": (),
304                 "outbound": (
305                     "rss",
306                 ),
307             },
308             "explicitContent": False,
309         },
310     })
311
312 @router.get(config.get("base_url") + "/api/v1/instance/domain_blocks", response_class=JSONResponse)
313 def api_domain_blocks(request: Request):
314     blocked = blacklist.get_all()
315     blocking = list()
316
317     for block in blocked:
318         blocking.append({
319             "domain"  : block,
320             "digest"  : utils.get_hash(block),
321             "severity": "suspend",
322             "comment" : blocked[block],
323         })
324
325     return JSONResponse(status_code=200, content=blocking)
326
327 @router.get(config.get("base_url") + "/api/v1/instance/peers", response_class=JSONResponse)
328 def api_peers(request: Request):
329     database.cursor.execute("SELECT domain FROM instances WHERE nodeinfo_url IS NOT NULL")
330
331     peers = list()
332     for row in database.cursor.fetchall():
333         peers.append(row["domain"])
334
335     return JSONResponse(status_code=200, content=peers)
336
337 @router.get(config.get("base_url") + "/scoreboard")
338 def scoreboard(request: Request, mode: str, amount: int):
339     response = None
340
341     if mode == "blocker" and amount > 0:
342         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=blocker&amount={amount}")
343     elif mode == "blocked" and amount > 0:
344         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=blocked&amount={amount}")
345     elif mode == "reference" and amount > 0:
346         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=reference&amount={amount}")
347     elif mode == "software" and amount > 0:
348         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=software&amount={amount}")
349     elif mode == "command" and amount > 0:
350         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=command&amount={amount}")
351     elif mode == "error_code" and amount > 0:
352         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=error_code&amount={amount}")
353     elif mode == "detection_mode" and amount > 0:
354         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=detection_mode&amount={amount}")
355     elif mode == "avg_peers" and amount > 0:
356         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=avg_peers&amount={amount}")
357     elif mode == "obfuscator" and amount > 0:
358         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=obfuscator&amount={amount}")
359     elif mode == "obfuscation" and amount > 0:
360         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=obfuscation&amount={amount}")
361     elif mode == "block_level" and amount > 0:
362         response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/scoreboard.json?mode=block_level&amount={amount}")
363     else:
364         raise HTTPException(status_code=400, detail="No filter specified")
365
366     if response is None:
367         raise HTTPException(status_code=500, detail="Could not determine scores")
368     elif not response.ok:
369         raise HTTPException(status_code=response.status_code, detail=response.text)
370
371     return templates.TemplateResponse("views/scoreboard.html", {
372         "base_url"  : config.get("base_url"),
373         "slogan"    : config.get("slogan"),
374         "theme"     : config.get("theme"),
375         "request"   : request,
376         "scoreboard": True,
377         "mode"      : mode,
378         "amount"    : amount,
379         "scores"    : json_helper.from_response(response)
380     })
381
382 @router.get(config.get("base_url") + "/list")
383 def list_domains(request: Request, mode: str, value: str, amount: int = config.get("api_limit")):
384     if mode == "detection_mode" and not instances.valid(value, "detection_mode"):
385         raise HTTPException(status_code=500, detail="Invalid detection mode provided")
386
387     response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/list.json?mode={mode}&value={value}&amount={amount}")
388
389     domainlist = list()
390     if response is not None and response.ok:
391         domainlist = response.json()
392         tformat = config.get("timestamp_format")
393         for row in domainlist:
394             row["first_seen"]   = datetime.utcfromtimestamp(row["first_seen"]).strftime(tformat)
395             row["last_updated"] = datetime.utcfromtimestamp(row["last_updated"]).strftime(tformat) if isinstance(row["last_updated"], float) else None
396
397     return templates.TemplateResponse("views/list.html", {
398         "request"   : request,
399         "mode"      : mode if response is not None else None,
400         "value"     : value if response is not None else None,
401         "amount"    : amount if response is not None else None,
402         "found"     : len(domainlist),
403         "domainlist": domainlist,
404         "slogan"    : config.get("slogan"),
405         "theme"     : config.get("theme"),
406     })
407
408 @router.get(config.get("base_url") + "/top")
409 def top(request: Request, mode: str, value: str, amount: int = config.get("api_limit")):
410     if mode == "block_level" and not blocks.valid(value, "block_level"):
411         raise HTTPException(status_code=500, detail="Invalid block level provided")
412     elif mode in ["domain", "reverse"] and not utils.is_domain_wanted(value):
413         raise HTTPException(status_code=500, detail="Invalid or blocked domain specified")
414
415     response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/top.json?mode={mode}&value={value}&amount={amount}")
416
417     found = 0
418     blocklist = list()
419     if response.ok and response.status_code == 200 and len(response.text) > 0:
420         blocklist = response.json()
421
422         tformat = config.get("timestamp_format")
423         for block_level in blocklist:
424             for row in blocklist[block_level]:
425                 row["first_seen"] = datetime.utcfromtimestamp(row["first_seen"]).strftime(tformat)
426                 row["last_seen"]  = datetime.utcfromtimestamp(row["last_seen"]).strftime(tformat) if isinstance(row["last_seen"], float) else None
427                 found = found + 1
428
429     return templates.TemplateResponse("views/top.html", {
430         "request"  : request,
431         "mode"     : mode if response is not None else None,
432         "value"    : value if response is not None else None,
433         "amount"   : amount if response is not None else None,
434         "found"    : found,
435         "blocklist": blocklist,
436         "slogan"   : config.get("slogan"),
437         "theme"    : config.get("theme"),
438     })
439
440 @router.get(config.get("base_url") + "/infos")
441 def infos(request: Request, domain: str):
442     if domain is None:
443         raise HTTPException(status_code=400, detail="Invalid request, parameter 'domain' missing")
444
445     # Tidy up domain name
446     domain = tidyup.domain(domain).encode("idna").decode("utf-8")
447
448     if not utils.is_domain_wanted(domain):
449         raise HTTPException(status_code=500, detail=f"domain='{domain}' is not wanted")
450
451     response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/domain.json?domain={domain}")
452
453     if not response.ok or response.status_code >= 300 or response.text.strip() == "":
454         raise HTTPException(status_code=response.status_code, detail=response.reason)
455
456     domain_data = response.json()
457
458     # Format timestamps
459     tformat = config.get("timestamp_format")
460     instance = dict()
461     for key in domain_data.keys():
462         if key in ["last_nodeinfo", "last_blocked", "first_seen", "last_updated", "last_instance_fetch"] and isinstance(domain_data[key], float):
463             # Timestamps
464             instance[key] = datetime.utcfromtimestamp(domain_data[key]).strftime(tformat)
465         else:
466             # Generic
467             instance[key] = domain_data[key]
468
469     return templates.TemplateResponse("views/infos.html", {
470         "request" : request,
471         "domain"  : domain,
472         "instance": instance,
473         "theme"   : config.get("theme"),
474         "slogan"  : config.get("slogan"),
475     })
476
477 @router.get(config.get("base_url") + "/rss")
478 def rss(request: Request, domain: str = None):
479     if domain is not None:
480         domain = tidyup.domain(domain).encode("idna").decode("utf-8")
481
482         wildchar = "*." + ".".join(domain.split(".")[-domain.count("."):])
483         punycode = domain.encode("idna").decode("utf-8")
484
485         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 ?", [
486             domain,
487             "*." + domain, wildchar,
488             utils.get_hash(domain),
489             punycode,
490             "*." + punycode,
491             config.get("rss_limit")
492         ])
493     else:
494         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")])
495
496     result = database.cursor.fetchall()
497     blocklist = []
498
499     for row in result:
500         blocklist.append({
501             "blocker"    : row[0],
502             "blocked"    : row[1],
503             "block_level": row[2],
504             "reason"     : "Provided reason: '" + row[3] + "'" if row[3] is not None and row[3] != "" else "No reason provided.",
505             "first_seen" : format_datetime(datetime.fromtimestamp(row[4])),
506             "last_seen"  : format_datetime(datetime.fromtimestamp(row[5])),
507         })
508
509     return templates.TemplateResponse("views/rss.xml", {
510         "request"  : request,
511         "timestamp": format_datetime(datetime.now()),
512         "domain"   : domain,
513         "hostname" : config.get("hostname"),
514         "blocks"   : blocklist
515     }, headers={
516         "Content-Type": "routerlication/rss+xml"
517     })
518
519 @router.get(config.get("base_url") + "/robots.txt", response_class=PlainTextResponse)
520 def robots(request: Request):
521     return templates.TemplateResponse("views/robots.txt", {
522         "request" : request,
523         "base_url": config.get("base_url")
524     })
525
526 @router.get(config.get("base_url") + "/")
527 def index(request: Request):
528     # Get info
529     response = requests.get(f"http://{config.get('host')}:{config.get('port')}{config.get('base_url')}/api/info.json")
530
531     if not response.ok:
532         raise HTTPException(status_code=response.status_code, detail=response.text)
533
534     return templates.TemplateResponse("views/index.html", {
535         "request": request,
536         "theme"  : config.get("theme"),
537         "info"   : response.json(),
538         "slogan" : config.get("slogan"),
539     })
540
541 if __name__ == "__main__":
542     uvicorn.run("daemon:router", host=config.get("host"), port=config.get("port"), log_level=config.get("log_level"))