#!/bin/python3 """ website/admin | Administration script for my personal website written by ngn (https://ngn.tf) (2025) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ from urllib.parse import quote_plus from typing import Dict, List, Any from datetime import datetime, UTC from colorama import Fore, Style from json import dumps, loads from getpass import getpass import requests as req from os import getenv from sys import argv API_URL_ENV = "API_URL" # logger used by the script class Log: def __init__(self) -> None: self.reset = Fore.RESET + Style.RESET_ALL def info(self, m: str) -> None: print(Fore.BLUE + Style.BRIGHT + "[*]" + self.reset + " " + m) def error(self, m: str) -> None: print(Fore.RED + Style.BRIGHT + "[-]" + self.reset + " " + m) def input(self, m: str) -> str: return input(Fore.CYAN + Style.BRIGHT + "[?]" + self.reset + " " + m + ": ") def password(self, m: str) -> str: return getpass(Fore.CYAN + Style.BRIGHT + "[?]" + self.reset + " " + m + ": ") # API interface for the admin endpoints class AdminAPI: def __init__(self, url: str, password: str) -> None: self.languages: List[str] = [ "en", "tr", ] # languages supported by multilang fields self.password = password self.api_url = url def _title_to_id(self, title: str) -> str: return title.lower().replace(" ", "_") def _check_multilang_field(self, ml: Dict[str, str]) -> bool: for l in self.languages: if l in ml and ml[l] != "": return True return False def _api_url_join(self, path: str) -> str: api_has_slash = self.api_url.endswith("/") path_has_slash = path.startswith("/") if api_has_slash or path_has_slash: return self.api_url + path elif api_has_slash and path_has_slash: return self.api_url + path[1:] else: return self.api_url + "/" + path def _to_json(self, res: req.Response) -> dict: if res.status_code == 403: raise Exception("Authentication failed") json = res.json() if json["error"] != "": raise Exception("API error: %s" % json["error"]) return json def PUT(self, url: str, data: dict) -> req.Response: return self._to_json( req.put( self._api_url_join(url), json=data, headers={"Authorization": self.password}, ) ) def DELETE(self, url: str) -> req.Response: return self._to_json( req.delete( self._api_url_join(url), headers={"Authorization": self.password} ) ) def GET(self, url: str) -> req.Response: return self._to_json( req.get(self._api_url_join(url), headers={"Authorization": self.password}) ) def add_service(self, service: Dict[str, str]): if not "name" in service or service["name"] == "": raise Exception('Service structure is missing required "name" field') if not "desc" in service: raise Exception('Service structure is missing required "desc" field') if ( (not "clear" in service or service["clear"] == "") and (not "onion" in service or service["onion"] == "") and (not "i2p" in service or service["i2p"] == "") ): raise Exception( 'Service structure is missing "clear", "onion" and "i2p" field, at least one needed' ) if not self._check_multilang_field(service["desc"]): raise Exception( 'Service structure field "desc" needs at least one supported language entry' ) self.PUT("/v1/admin/service/add", service) def del_service(self, service: str) -> None: if service == "": raise Exception("Service name cannot be empty") self.DELETE("/v1/admin/service/del?name=%s" % quote_plus(service)) def check_services(self) -> None: self.GET("/v1/admin/service/check") def add_news(self, news: Dict[str, str]): if not "id" in news or news["id"] == "": raise Exception('News structure is missing required "id" field') if not "author" in news or news["author"] == "": raise Exception('News structure is missing required "author" field') if not "title" in news: raise Exception('News structure is missing required "title" field') if not "content" in news: raise Exception('News structure is missing required "content" field') if not self._check_multilang_field(news["title"]): raise Exception( 'News structure field "title" needs at least one supported language entry' ) if not self._check_multilang_field(news["content"]): raise Exception( 'News structure field "content" needs at least one supported language entry' ) self.PUT("/v1/admin/news/add", news) def del_news(self, news: str) -> None: if news == "": raise Exception("News ID cannot be empty") self.DELETE("/v1/admin/news/del?id=%s" % quote_plus(news)) def logs(self) -> List[Dict[str, Any]]: return self.GET("/v1/admin/logs") # local helper functions used by the script def __format_time(ts: int) -> str: return datetime.fromtimestamp(ts, UTC).strftime("%H:%M:%S %d/%m/%Y") def __load_json_file(file: str) -> Dict[str, Any]: with open(file, "r") as f: data = loads(f.read()) return data def __dump_json_file(data: Dict[str, Any], file: str) -> None: with open(file, "w") as f: data = dumps(data, indent=2) f.write(data) # command handlers def __handle_command(log: Log, api: AdminAPI, cmd: str) -> None: match cmd: case "add_service": data: Dict[str, str] = {} data["desc"] = {} data["name"] = log.input("Serivce name") for l in api.languages: data["desc"][l] = log.input("Serivce desc (%s)" % l) data["check_url"] = log.input("Serivce status check URL") data["clear"] = log.input("Serivce clearnet URL") data["onion"] = log.input("Serivce onion URL") data["i2p"] = log.input("Serivce I2P URL") api.add_service(data) log.info("Service has been added") case "del_service": api.del_service(self.log.input("Serivce name")) log.info("Service has been deleted") case "check_services": api.check_services() log.info("Requested status check for all the services") case "add_news": news: Dict[str, str] = {} news["title"] = {} news["content"] = {} data["id"] = log.input("News ID") for l in api.languages: data["title"][l] = log.input("News title (%s)" % l) data["author"] = log.input("News author") for l in api.languages: data["content"][l] = log.input("News content (%s)" % l) api.add_news(data) log.info("News has been added") case "del_news": api.del_news(log.input("News ID")) log.info("News has been deleted") case "logs": logs = api.logs() if None == logs["result"] or len(logs["result"]) == 0: return log.info("No available logs") for l in logs["result"]: log.info( "Time: %s | Action: %s" % (__format_time(l["time"]), l["action"]) ) def __handle_command_with_file(log: Log, api: AdminAPI, cmd: str, file: str) -> None: match cmd: case "add_service": data = __load_json_file(file) api.add_service(data) log.info("Service has been added") case "del_service": data = __load_json_file(file) api.del_service(data["name"]) log.info("Service has been deleted") case "check_services": api.check_services() log.info("Requested status check for all the services") case "add_news": data = __load_json_file(file) api.add_news(data) log.info("News has been added") case "del_news": data = __load_json_file(file) api.del_news(data["id"]) log.info("News has been deleted") case "logs": logs = api.logs() if None == logs["result"] or len(logs["result"]) == 0: return log.info("No available logs") __dump_json_file(logs["result"], file) log.info("Logs has been saved") if __name__ == "__main__": log = Log() if len(argv) < 2 or len(argv) > 3: log.error("Usage: %s [command] " % argv[0]) log.info("Here is a list of available commands:") print("\tadd_service") print("\tdel_service") print("\tcheck_services") print("\tadd_news") print("\tdel_news") print("\tlogs") exit(1) url = getenv(API_URL_ENV) if url == None: log.error( "Please specify the API URL using %s environment variable" % API_URL_ENV ) exit(1) try: password = log.password("Please enter the admin password") api = AdminAPI(url, password) if len(argv) == 2: __handle_command(log, api, argv[1]) elif len(argv) == 3: __handle_command_with_file(log, api, argv[1], argv[2]) except KeyboardInterrupt: print() log.error("Command cancelled") exit(1) except Exception as e: log.error("Command failed: %s" % e) exit(1)