diff --git a/admin/Makefile b/admin/Makefile new file mode 100644 index 0000000..f284023 --- /dev/null +++ b/admin/Makefile @@ -0,0 +1,12 @@ +SRCS = $(wildcard *.py) +PREFIX = /usr + +all: + +format: + black $(SRCS) + +install: + install -Dm755 admin.py $(PREFIX)/bin/admin_script + +.PHONY: format install diff --git a/admin/admin.py b/admin/admin.py index a7c1dea..87f9ceb 100644 --- a/admin/admin.py +++ b/admin/admin.py @@ -1,176 +1,331 @@ #!/bin/python3 """ -Administration script for my website (ngn.tf) -############################################# -I really enjoy doing stuff from the terminal, -so I wrote this simple python script that interacts -with the API and lets me add/remove new posts/services -from the terminal + +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 os import remove, getenv +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 -URL = "" +API_URL_ENV = "API_URL" -def join(pth: str) -> str: - if URL == None: - return "" - if URL.endswith("/"): - return URL+pth - return URL+"/"+pth +# logger used by the script +class Log: + def __init__(self) -> None: + self.reset = Fore.RESET + Style.RESET_ALL -def get_token() -> str: - try: - f = open("/tmp/wa", "r") - token = f.read() - f.close() - return token - except: - print("[-] You are not authenticated") - exit(1) + def info(self, m: str) -> None: + print(Fore.BLUE + Style.BRIGHT + "[*]" + self.reset + " " + m) -def login() -> None: - pwd = getpass("[>] Enter your password: ") - res = req.get(join("admin/login")+f"?pass={pwd}").json() - if res["error"] != "": - print(f"[-] Error logging in: {res['error']}") - return + def error(self, m: str) -> None: + print(Fore.RED + Style.BRIGHT + "[-]" + self.reset + " " + m) - token = res["token"] - f = open("/tmp/wa", "w") - f.write(token) - f.close() + def input(self, m: str) -> str: + return input(Fore.CYAN + Style.BRIGHT + "[?]" + self.reset + " " + m + ": ") -def logout() -> None: - token = get_token() - res = req.get(join("admin/logout"), headers={ - "Authorization": token - }).json() - if res["error"] != "": - print(f"[-] Error logging out: {res['error']}") - return + def password(self, m: str) -> str: + return getpass(Fore.CYAN + Style.BRIGHT + "[?]" + self.reset + " " + m + ": ") - remove("/tmp/wa") - print("[+] Logged out") -def add_post() -> None: - token = get_token() - title = input("[>] Post title: ") - author = input("[>] Post author: ") - content_file = input("[>] Post content file: ") - public = input("[>] Should post be public? (y/n): ") +# 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 - try: - f = open(content_file, "r") - content = f.read() - f.close() - except: - print("[-] Content file not found") - return + def _title_to_id(self, title: str) -> str: + return title.lower().replace(" ", "_") - res = req.put(join("admin/blog/add"), json={ - "title": title, - "author": author, - "content": content, - "public": 1 if public == "y" else 0 - }, headers={ - "Authorization": token - }).json() + 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 - if res["error"] != "": - print(f"[-] Error adding post: {res['error']}") - return + def _api_url_join(self, path: str) -> str: + api_has_slash = self.api_url.endswith("/") + path_has_slash = path.startswith("/") - print("[+] Post has been added") + 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 remove_post() -> None: - token = get_token() - id = input("[>] Post ID: ") - res = req.delete(join("admin/blog/remove")+f"?id={id}", headers={ - "Authorization": token - }).json() + def _to_json(self, res: req.Response) -> dict: + if res.status_code == 403: + raise Exception("Authentication failed") - if res["error"] != "": - print(f"[-] Error removing post: {res['error']}") - return + json = res.json() - print("[-] Post has been removed") + if json["error"] != "": + raise Exception("API error: %s" % json["error"]) -def add_service() -> None: - token = get_token() - name = input("[>] Serivce name: ") - desc = input("[>] Serivce desc: ") - link = input("[>] Serivce URL: ") + return json - res = req.put(join("admin/service/add"), json={ - "name": name, - "desc": desc, - "url": link - }, headers={ - "Authorization": token - }).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}, + ) + ) - if res["error"] != "": - print(f"[-] Error adding service: {res['error']}") - return + def DELETE(self, url: str) -> req.Response: + return self._to_json( + req.delete( + self._api_url_join(url), headers={"Authorization": self.password} + ) + ) - print("[+] Service has been added") + def GET(self, url: str) -> req.Response: + return self._to_json( + req.get(self._api_url_join(url), headers={"Authorization": self.password}) + ) -def remove_service() -> None: - token = get_token() - name = input("[>] Service name: ") - res = req.delete(join("admin/service/remove")+f"?name={name}", headers={ - "Authorization": token - }).json() + 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 res["error"] != "": - print(f"[-] Error removing service: {res['error']}") - return + if not "desc" in service: + raise Exception('Service structure is missing required "desc" field') - print("[+] Serivce has been removed") + 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' + ) -cmds = { - "login": login, - "logout": logout, - "add_post": add_post, - "remove_post": remove_post, - "add_service": add_service, - "remove_service": remove_service, -} + if not self._check_multilang_field(service["desc"]): + raise Exception( + 'Service structure field "desc" needs at least one supported language entry' + ) -def main(): - global URL - URL = getenv("API") - if URL == None or URL == "": - print("[-] API enviroment variable not set") - exit(1) + self.PUT("/v1/admin/service/add", service) - if len(argv) != 2: - print(f"[-] Usage: admin_script ") - print(f"[+] Run \"admin_script help\" to get all commands") - exit(1) + def del_service(self, service: str) -> None: + if service == "": + raise Exception("Service name cannot be empty") - if argv[1] == "help": - print("Avaliable commands:") - for k in cmds.keys(): - print(f" {k}") - exit() + self.DELETE("/v1/admin/service/del?name=%s" % quote_plus(service)) - for k in cmds.keys(): - if k != argv[1]: - continue - try: - cmds[k]() - except KeyboardInterrupt: - pass - exit() + 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") - print("[-] Command not found") if __name__ == "__main__": - 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) diff --git a/admin/install.sh b/admin/install.sh deleted file mode 100755 index 350cd87..0000000 --- a/admin/install.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -echo -n "Enter API URL: " -read url -cat > /usr/bin/admin_script << EOF -#!/bin/sh -API=$url python3 $(pwd)/admin.py \$1 -EOF -chmod +x /usr/bin/admin_script diff --git a/admin/tests/test_news.json b/admin/tests/test_news.json new file mode 100644 index 0000000..45ee94a --- /dev/null +++ b/admin/tests/test_news.json @@ -0,0 +1,12 @@ +{ + "id": "test_news", + "title": { + "en": "Very important news", + "tr": "Çok önemli haber" + }, + "author": "ngn", + "content": { + "en": "Just letting you know that I'm testing the API", + "tr": "Sadece API'ı test ettiğimi bilmenizi istedim" + } +} diff --git a/admin/tests/test_service.json b/admin/tests/test_service.json new file mode 100644 index 0000000..68b7cae --- /dev/null +++ b/admin/tests/test_service.json @@ -0,0 +1,9 @@ +{ + "name": "Test Service", + "desc": { + "en": "Service used for testing the API", + "tr": "API'ı test etmek için kullanılan servis" + }, + "check_url": "http://localhost:7001", + "clear": "http://localhost:7001" +} diff --git a/api/.gitignore b/api/.gitignore index 2d6f3a5..6f62924 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -1,2 +1,2 @@ -server +*.elf *.db diff --git a/api/Dockerfile b/api/Dockerfile index 0836046..bcb112a 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,17 +1,19 @@ FROM golang:1.23.4 -WORKDIR /app +WORKDIR /api COPY *.go ./ COPY *.mod ./ COPY *.sum ./ COPY Makefile ./ +COPY util ./util +COPY views ./views COPY routes ./routes COPY config ./config +COPY status ./status COPY database ./database -COPY util ./util EXPOSE 7001 RUN make -ENTRYPOINT ["/app/server"] +ENTRYPOINT ["/api/api.elf"] diff --git a/api/Makefile b/api/Makefile index f6908ec..5a9f3f2 100644 --- a/api/Makefile +++ b/api/Makefile @@ -1,10 +1,12 @@ -all: server +GOSRCS = $(wildcard *.go) $(wildcard */*.go) -server: *.go routes/*.go database/*.go util/*.go config/*.go - go build -o $@ . +all: api.elf -test: - API_FRONTEND_URL=http://localhost:5173/ API_PASSWORD=test ./server +api.elf: $(GOSRCS) + go build -o $@ + +run: + API_DEBUG=true API_FRONTEND_URL=http://localhost:5173/ API_PASSWORD=test ./api.elf format: gofmt -s -w . diff --git a/api/config/config.go b/api/config/config.go index 29d5d60..52270fb 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -2,59 +2,112 @@ package config import ( "fmt" + "net/url" "os" - "strings" - - "github.com/ngn13/website/api/util" ) -type Option struct { - Name string - Value string - Required bool +type Type struct { + Options []Option + Count int } -func (o *Option) Env() string { - return strings.ToUpper(fmt.Sprintf("API_%s", o.Name)) -} - -var options []Option = []Option{ - {Name: "password", Value: "", Required: true}, - {Name: "frontend_url", Value: "http://localhost:5173/", Required: true}, -} - -func Load() bool { - var val string - - for i := range options { - if val = os.Getenv(options[i].Env()); val == "" { +func (c *Type) Find(name string, typ uint8) (*Option, error) { + for i := 0; i < c.Count; i++ { + if c.Options[i].Name != name { continue } - options[i].Value = val - options[i].Required = false - } - - for i := range options { - if options[i].Required && options[i].Value == "" { - util.Fail("please specify the required config option \"%s\" (\"%s\")", options[i].Name, options[i].Env()) - return false + if c.Options[i].Type != typ { + return nil, fmt.Errorf("bad option type") } - if options[i].Required && options[i].Value != "" { - util.Fail("using the default value \"%s\" for required config option \"%s\" (\"%s\")", options[i].Value, options[i].Name, options[i].Env()) - } + return &c.Options[i], nil } - return true + return nil, fmt.Errorf("option not found") } -func Get(name string) string { - for i := range options { - if options[i].Name != name { - continue - } - return options[i].Value +func (c *Type) Load() (err error) { + var ( + env_val string + env_name string + opt *Option + exists bool + ) + + // default options + c.Options = []Option{ + {Name: "debug", Value: "false", Type: OPTION_TYPE_BOOL, Required: true}, // should display debug messgaes? + {Name: "index", Value: "true", Type: OPTION_TYPE_BOOL, Required: false}, // should display the index page (view/index.md)? + + {Name: "api_url", Value: "http://localhost:7001/", Type: OPTION_TYPE_URL, Required: true}, // API URL for the website + {Name: "frontend_url", Value: "http://localhost:5173/", Type: OPTION_TYPE_URL, Required: true}, // frontend application URL for the website + + {Name: "password", Value: "", Type: OPTION_TYPE_STR, Required: true}, // admin password + {Name: "host", Value: "0.0.0.0:7001", Type: OPTION_TYPE_STR, Required: true}, // host the server should listen on + {Name: "ip_header", Value: "X-Real-IP", Type: OPTION_TYPE_STR, Required: false}, // header that should be checked for obtaining the client IP + {Name: "interval", Value: "1h", Type: OPTION_TYPE_STR, Required: false}, // service status check interval + {Name: "timeout", Value: "15s", Type: OPTION_TYPE_STR, Required: false}, // timeout for the service status check + {Name: "limit", Value: "5s", Type: OPTION_TYPE_STR, Required: false}, // if the service responds slower than this limit, it will be marked as "slow" } - return "" + c.Count = len(c.Options) + + for i := 0; i < c.Count; i++ { + opt = &c.Options[i] + + env_name = opt.Env() + + if env_val, exists = os.LookupEnv(env_name); exists { + opt.Value = env_val + } + + if opt.Value == "" && opt.Required { + return fmt.Errorf("please specify a value for the config option \"%s\" (\"%s\")", opt.Name, env_name) + } + + if err = opt.Load(); err != nil { + return fmt.Errorf("failed to load option \"%s\" (\"%s\"): %s", opt.Name, env_name, err.Error()) + } + } + + return nil +} + +func (c *Type) GetStr(name string) string { + var ( + opt *Option + err error + ) + + if opt, err = c.Find(name, OPTION_TYPE_STR); err != nil { + return "" + } + + return opt.TypeValue.Str +} + +func (c *Type) GetBool(name string) bool { + var ( + opt *Option + err error + ) + + if opt, err = c.Find(name, OPTION_TYPE_BOOL); err != nil { + return false + } + + return opt.TypeValue.Bool +} + +func (c *Type) GetURL(name string) *url.URL { + var ( + opt *Option + err error + ) + + if opt, err = c.Find(name, OPTION_TYPE_URL); err != nil { + return nil + } + + return opt.TypeValue.URL } diff --git a/api/config/option.go b/api/config/option.go new file mode 100644 index 0000000..ae667bd --- /dev/null +++ b/api/config/option.go @@ -0,0 +1,49 @@ +package config + +import ( + "fmt" + "net/url" + "strings" +) + +const ( + OPTION_TYPE_STR = 0 + OPTION_TYPE_BOOL = 1 + OPTION_TYPE_URL = 2 +) + +type Option struct { + Name string + Value string + Required bool + Type uint8 + TypeValue struct { + URL *url.URL + Str string + Bool bool + } +} + +func (o *Option) Env() string { + return strings.ToUpper(fmt.Sprintf("API_%s", o.Name)) +} + +func (o *Option) Load() (err error) { + err = nil + + switch o.Type { + case OPTION_TYPE_STR: + o.TypeValue.Str = o.Value + + case OPTION_TYPE_BOOL: + o.TypeValue.Bool = "1" == o.Value || "true" == strings.ToLower(o.Value) + + case OPTION_TYPE_URL: + o.TypeValue.URL, err = url.Parse(o.Value) + + default: + return fmt.Errorf("invalid option type") + } + + return err +} diff --git a/api/database/admin.go b/api/database/admin.go new file mode 100644 index 0000000..1da515f --- /dev/null +++ b/api/database/admin.go @@ -0,0 +1,62 @@ +package database + +import ( + "database/sql" + "fmt" + + "github.com/ngn13/website/api/util" +) + +type AdminLog struct { + Action string `json:"action"` // action that was performed (service removal, service addition etc.) + Time int64 `json:"time"` // time when the action was performed +} + +func (l *AdminLog) Scan(rows *sql.Rows) (err error) { + if rows != nil { + return rows.Scan(&l.Action, &l.Time) + } + + return fmt.Errorf("no row/rows specified") +} + +func (db *Type) AdminLogNext(l *AdminLog) bool { + var err error + + if nil == db.rows { + if db.rows, err = db.sql.Query("SELECT * FROM admin_log"); err != nil { + util.Fail("failed to query admin_log table: %s", err.Error()) + goto fail + } + } + + if !db.rows.Next() { + goto fail + } + + if err = l.Scan(db.rows); err != nil { + util.Fail("failed to scan the admin_log table: %s", err.Error()) + goto fail + } + + return true + +fail: + if db.rows != nil { + db.rows.Close() + } + db.rows = nil + + return false +} + +func (db *Type) AdminLogAdd(l *AdminLog) error { + _, err := db.sql.Exec( + `INSERT INTO admin_log( + action, time + ) values(?, ?)`, + &l.Action, &l.Time, + ) + + return err +} diff --git a/api/database/database.go b/api/database/database.go index 2adb27c..d9cb1de 100644 --- a/api/database/database.go +++ b/api/database/database.go @@ -1,44 +1,66 @@ package database import ( + "fmt" + "database/sql" + _ "github.com/mattn/go-sqlite3" ) -func Setup(db *sql.DB) error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS posts( - id TEXT NOT NULL UNIQUE, - title TEXT NOT NULL, - author TEXT NOT NULL, - date TEXT NOT NULL, - content TEXT NOT NULL, - public INTEGER NOT NULL, - vote INTEGER NOT NULL - ); - `) - - if err != nil { - return err - } - - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS services( - name TEXT NOT NULL UNIQUE, - desc TEXT NOT NULL, - url TEXT NOT NULL - ); - `) - - if err != nil { - return err - } - - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS votes( - hash TEXT NOT NULL UNIQUE, - is_upvote INTEGER NOT NULL - ); - `) - - return err +type Type struct { + sql *sql.DB + rows *sql.Rows +} + +func (db *Type) Load() (err error) { + if db.sql, err = sql.Open("sqlite3", "data.db"); err != nil { + return fmt.Errorf("cannot access the database: %s", err.Error()) + } + + // see database/service.go + _, err = db.sql.Exec(` + CREATE TABLE IF NOT EXISTS services( + name TEXT NOT NULL UNIQUE, + desc TEXT NOT NULL, + check_time INTEGER NOT NULL, + check_res INTEGER NOT NULL, + check_url TEXT NOT NULL, + clear TEXT, + onion TEXT, + i2p TEXT + ); + `) + + if err != nil { + return fmt.Errorf("failed to create the services table: %s", err.Error()) + } + + // see database/news.go + _, err = db.sql.Exec(` + CREATE TABLE IF NOT EXISTS news( + id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + author TEXT NOT NULL, + time INTEGER NOT NULL, + content TEXT NOT NULL + ); + `) + + if err != nil { + return fmt.Errorf("failed to create the news table: %s", err.Error()) + } + + // see database/admin.go + _, err = db.sql.Exec(` + CREATE TABLE IF NOT EXISTS admin_log( + action TEXT NOT NULL, + time INTEGER NOT NULL + ); + `) + + if err != nil { + return fmt.Errorf("failed to create the admin_log table: %s", err.Error()) + } + + return nil } diff --git a/api/database/multilang.go b/api/database/multilang.go new file mode 100644 index 0000000..628157b --- /dev/null +++ b/api/database/multilang.go @@ -0,0 +1,58 @@ +package database + +import ( + "encoding/json" + "reflect" + "strings" + "unicode" +) + +type Multilang struct { + En string `json:"en"` // english + Tr string `json:"tr"` // turkish +} + +func (ml *Multilang) Supports(lang string) bool { + ml_ref := reflect.ValueOf(ml).Elem() + + for i := 0; i < reflect.Indirect(ml_ref).NumField(); i++ { + if name := reflect.Indirect(ml_ref).Field(i).Type().Name(); strings.ToLower(name) == lang { + return true + } + } + + return false +} + +func (ml *Multilang) Get(lang string) string { + r := []rune(lang) + r[0] = unicode.ToUpper(r[0]) + l := string(r) + + ml_ref := reflect.ValueOf(ml) + return reflect.Indirect(ml_ref).FieldByName(l).String() +} + +func (ml *Multilang) Empty() bool { + ml_ref := reflect.ValueOf(ml) + + for i := 0; i < reflect.Indirect(ml_ref).NumField(); i++ { + if field := reflect.Indirect(ml_ref).Field(i); field.String() != "" { + return false + } + } + + return true +} + +func (ml *Multilang) Dump() (string, error) { + if data, err := json.Marshal(ml); err != nil { + return "", err + } else { + return string(data), nil + } +} + +func (ml *Multilang) Load(s string) error { + return json.Unmarshal([]byte(s), ml) +} diff --git a/api/database/news.go b/api/database/news.go new file mode 100644 index 0000000..a66c359 --- /dev/null +++ b/api/database/news.go @@ -0,0 +1,116 @@ +package database + +import ( + "database/sql" + + "github.com/ngn13/website/api/util" +) + +type News struct { + ID string `json:"id"` // ID of the news + title string `json:"-"` // title of the news (string) + Title Multilang `json:"title"` // title of the news + Author string `json:"author"` // author of the news + Time uint64 `json:"time"` // when the new was published + content string `json:"-"` // content of the news (string) + Content Multilang `json:"content"` // content of the news +} + +func (n *News) Supports(lang string) bool { + return n.Content.Supports(lang) && n.Title.Supports(lang) +} + +func (n *News) Load() (err error) { + if err = n.Title.Load(n.title); err != nil { + return err + } + + if err = n.Content.Load(n.content); err != nil { + return err + } + + return nil +} + +func (n *News) Dump() (err error) { + if n.title, err = n.Title.Dump(); err != nil { + return err + } + + if n.content, err = n.Content.Dump(); err != nil { + return err + } + + return nil +} + +func (n *News) Scan(rows *sql.Rows) (err error) { + err = rows.Scan( + &n.ID, &n.title, &n.Author, + &n.Time, &n.content) + + if err != nil { + return err + } + + return n.Load() +} + +func (n *News) IsValid() bool { + return n.Author != "" && n.ID != "" && !n.Title.Empty() && !n.Content.Empty() +} + +func (db *Type) NewsNext(n *News) bool { + var err error + + if nil == db.rows { + if db.rows, err = db.sql.Query("SELECT * FROM news"); err != nil { + util.Fail("failed to query news table: %s", err.Error()) + goto fail + } + } + + if !db.rows.Next() { + goto fail + } + + if err = n.Scan(db.rows); err != nil { + util.Fail("failed to scan the news table: %s", err.Error()) + goto fail + } + + return true + +fail: + if db.rows != nil { + db.rows.Close() + } + db.rows = nil + + return false +} + +func (db *Type) NewsRemove(id string) error { + _, err := db.sql.Exec( + "DELETE FROM news WHERE id = ?", + id, + ) + + return err +} + +func (db *Type) NewsAdd(n *News) (err error) { + if err = n.Dump(); err != nil { + return err + } + + _, err = db.sql.Exec( + `INSERT OR REPLACE INTO news( + id, title, author, time, content + ) values(?, ?, ?, ?, ?)`, + n.ID, n.title, + n.Author, n.Time, n.content, + ) + + return err +} diff --git a/api/database/post.go b/api/database/post.go deleted file mode 100644 index 479277b..0000000 --- a/api/database/post.go +++ /dev/null @@ -1,71 +0,0 @@ -package database - -import ( - "database/sql" - - "github.com/ngn13/website/api/util" -) - -type Post struct { - ID string `json:"id"` - Title string `json:"title"` - Author string `json:"author"` - Date string `json:"date"` - Content string `json:"content"` - Public int `json:"public"` - Vote int `json:"vote"` -} - -func (p *Post) Load(rows *sql.Rows) error { - return rows.Scan(&p.ID, &p.Title, &p.Author, &p.Date, &p.Content, &p.Public, &p.Vote) -} - -func (p *Post) Get(db *sql.DB, id string) (bool, error) { - var ( - success bool - rows *sql.Rows - err error - ) - - if rows, err = db.Query("SELECT * FROM posts WHERE id = ?", id); err != nil { - return false, err - } - defer rows.Close() - - if success = rows.Next(); !success { - return false, nil - } - - if err = p.Load(rows); err != nil { - return false, err - } - - return true, nil -} - -func (p *Post) Remove(db *sql.DB) error { - _, err := db.Exec("DELETE FROM posts WHERE id = ?", p.ID) - return err -} - -func (p *Post) Save(db *sql.DB) error { - p.ID = util.TitleToID(p.Title) - - _, err := db.Exec( - "INSERT INTO posts(id, title, author, date, content, public, vote) values(?, ?, ?, ?, ?, ?, ?)", - p.ID, p.Title, p.Author, p.Date, p.Content, p.Public, p.Vote, - ) - - return err -} - -func (p *Post) Update(db *sql.DB) error { - p.ID = util.TitleToID(p.Title) - - _, err := db.Exec( - "UPDATE posts SET title = ?, author = ?, date = ?, content = ?, public = ?, vote = ? WHERE id = ?", - p.Title, p.Author, p.Date, p.Content, p.Public, p.Vote, p.ID, - ) - - return err -} diff --git a/api/database/service.go b/api/database/service.go index 3b14c71..34d3b8f 100644 --- a/api/database/service.go +++ b/api/database/service.go @@ -2,54 +2,127 @@ package database import ( "database/sql" + "fmt" + + "github.com/ngn13/website/api/util" ) type Service struct { - Name string `json:"name"` - Desc string `json:"desc"` - Url string `json:"url"` + Name string `json:"name"` // name of the service + desc string `json:"-"` // description of the service (string) + Desc Multilang `json:"desc"` // description of the service + CheckTime uint64 `json:"check_time"` // last status check time + CheckRes uint8 `json:"check_res"` // result of the status check + CheckURL string `json:"check_url"` // URL used for status check + Clear string `json:"clear"` // Clearnet (cringe) URL for the service + Onion string `json:"onion"` // Onion (TOR) URL for the service + I2P string `json:"i2p"` // I2P URL for the service } -func (s *Service) Load(rows *sql.Rows) error { - return rows.Scan(&s.Name, &s.Desc, &s.Url) +func (s *Service) Load() error { + return s.Desc.Load(s.desc) } -func (s *Service) Get(db *sql.DB, name string) (bool, error) { +func (s *Service) Dump() (err error) { + s.desc, err = s.Desc.Dump() + return +} + +func (s *Service) Scan(rows *sql.Rows, row *sql.Row) (err error) { + if rows != nil { + err = rows.Scan( + &s.Name, &s.desc, + &s.CheckTime, &s.CheckRes, &s.CheckURL, + &s.Clear, &s.Onion, &s.I2P) + } else if row != nil { + err = row.Scan( + &s.Name, &s.desc, + &s.CheckTime, &s.CheckRes, &s.CheckURL, + &s.Clear, &s.Onion, &s.I2P) + } else { + return fmt.Errorf("no row/rows specified") + } + + if err != nil { + return err + } + + return s.Load() +} + +func (s *Service) IsValid() bool { + return s.Name != "" && (s.Clear != "" || s.Onion != "" || s.I2P != "") && !s.Desc.Empty() +} + +func (db *Type) ServiceNext(s *Service) bool { + var err error + + if nil == db.rows { + if db.rows, err = db.sql.Query("SELECT * FROM services"); err != nil { + util.Fail("failed to query services table: %s", err.Error()) + goto fail + } + } + + if !db.rows.Next() { + goto fail + } + + if err = s.Scan(db.rows, nil); err != nil { + util.Fail("failed to scan the services table: %s", err.Error()) + goto fail + } + + return true + +fail: + if db.rows != nil { + db.rows.Close() + } + db.rows = nil + + return false +} + +func (db *Type) ServiceFind(name string) (*Service, error) { var ( - success bool - rows *sql.Rows - err error + row *sql.Row + s Service + err error ) - if rows, err = db.Query("SELECT * FROM services WHERE name = ?", name); err != nil { - return false, err - } - defer rows.Close() - - if success = rows.Next(); !success { - return false, nil + if row = db.sql.QueryRow("SELECT * FROM services WHERE name = ?", name); row == nil || row.Err() == sql.ErrNoRows { + return nil, nil } - if err = s.Load(rows); err != nil { - return false, err + if err = s.Scan(nil, row); err != nil { + return nil, err } - return true, nil + return &s, nil } -func (s *Service) Remove(db *sql.DB) error { - _, err := db.Exec( +func (db *Type) ServiceRemove(name string) error { + _, err := db.sql.Exec( "DELETE FROM services WHERE name = ?", - s.Name, + name, ) return err } -func (s *Service) Save(db *sql.DB) error { - _, err := db.Exec( - "INSERT INTO services(name, desc, url) values(?, ?, ?)", - s.Name, s.Desc, s.Url, +func (db *Type) ServiceUpdate(s *Service) (err error) { + if err = s.Dump(); err != nil { + return err + } + + _, err = db.sql.Exec( + `INSERT OR REPLACE INTO services( + name, desc, check_time, check_res, check_url, clear, onion, i2p + ) values(?, ?, ?, ?, ?, ?, ?, ?)`, + s.Name, s.desc, + s.CheckTime, s.CheckRes, s.CheckURL, + s.Clear, s.Onion, s.I2P, ) return err diff --git a/api/database/vote.go b/api/database/vote.go deleted file mode 100644 index 109cc27..0000000 --- a/api/database/vote.go +++ /dev/null @@ -1,49 +0,0 @@ -package database - -import "database/sql" - -type Vote struct { - Hash string - IsUpvote bool -} - -func (v *Vote) Load(rows *sql.Rows) error { - return rows.Scan(&v.Hash, &v.IsUpvote) -} - -func (v *Vote) Get(db *sql.DB, hash string) (bool, error) { - var ( - success bool - rows *sql.Rows - err error - ) - - if rows, err = db.Query("SELECT * FROM votes WHERE hash = ?", hash); err != nil { - return false, err - } - defer rows.Close() - - if success = rows.Next(); !success { - return false, nil - } - - if err = v.Load(rows); err != nil { - return false, err - } - - return true, nil -} - -func (v *Vote) Update(db *sql.DB) error { - _, err := db.Exec("UPDATE votes SET is_upvote = ? WHERE hash = ?", v.IsUpvote, v.Hash) - return err -} - -func (v *Vote) Save(db *sql.DB) error { - _, err := db.Exec( - "INSERT INTO votes(hash, is_upvote) values(?, ?)", - v.Hash, v.IsUpvote, - ) - - return err -} diff --git a/api/go.mod b/api/go.mod index 74e6d81..b664cd7 100644 --- a/api/go.mod +++ b/api/go.mod @@ -2,11 +2,7 @@ module github.com/ngn13/website/api go 1.21.3 -require ( - github.com/gofiber/fiber/v2 v2.52.5 - github.com/gorilla/feeds v1.2.0 - github.com/mattn/go-sqlite3 v1.14.24 -) +require github.com/gofiber/fiber/v2 v2.52.5 require ( github.com/andybalholm/brotli v1.0.5 // indirect @@ -15,7 +11,9 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect diff --git a/api/go.sum b/api/go.sum index e42c05e..d1f72e1 100644 --- a/api/go.sum +++ b/api/go.sum @@ -4,14 +4,8 @@ github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yG github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= -github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -23,8 +17,8 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= diff --git a/api/main.go b/api/main.go index 4751ce6..bd92130 100644 --- a/api/main.go +++ b/api/main.go @@ -1,102 +1,124 @@ package main +/* + + * website/api | API server 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 . + + */ + import ( - "database/sql" + "net/http" "github.com/gofiber/fiber/v2" "github.com/ngn13/website/api/config" "github.com/ngn13/website/api/database" "github.com/ngn13/website/api/routes" + "github.com/ngn13/website/api/status" "github.com/ngn13/website/api/util" ) -var db *sql.DB - func main() { var ( - app *fiber.App - //db *sql.DB + app *fiber.App + stat status.Type + + conf config.Type + db database.Type + err error ) - if !config.Load() { - util.Fail("failed to load the configuration") + if err = conf.Load(); err != nil { + util.Fail("failed to load the configuration: %s", err.Error()) return } - if db, err = sql.Open("sqlite3", "data.db"); err != nil { - util.Fail("cannot access the database: %s", err.Error()) + if !conf.GetBool("debug") { + util.Debg = func(m string, v ...any) {} + } + + if err = db.Load(); err != nil { + util.Fail("failed to load the database: %s", err.Error()) return } - defer db.Close() - if err = database.Setup(db); err != nil { - util.Fail("cannot setup the database: %s", err.Error()) + if err = stat.Setup(&conf, &db); err != nil { + util.Fail("failed to setup the status checker: %s", err.Error()) return } app = fiber.New(fiber.Config{ + AppName: "ngn's website", DisableStartupMessage: true, + ServerHeader: "", }) app.Use("*", func(c *fiber.Ctx) error { + // CORS stuff c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") - c.Set("Access-Control-Allow-Methods", "PUT, DELETE, GET") + c.Set("Access-Control-Allow-Methods", "PUT, DELETE, GET") // POST can be sent from HTML forms, so I prefer PUT for API endpoints + c.Locals("status", &stat) + c.Locals("config", &conf) c.Locals("database", &db) return c.Next() }) // index route - app.Get("/", func(c *fiber.Ctx) error { - return c.Send([]byte("o/")) - }) + app.Get("/", routes.GET_Index) - // blog routes - blog_routes := app.Group("/blog") + // version groups + v1 := app.Group("v1") - // blog feed routes - blog_routes.Get("/feed.*", routes.GET_Feed) + // v1 user routes + v1.Get("/services", routes.GET_Services) + v1.Get("/news/:lang", routes.GET_News) - // blog post routes - blog_routes.Get("/sum", routes.GET_PostSum) - blog_routes.Get("/get", routes.GET_Post) - - // blog post vote routes - blog_routes.Get("/vote/set", routes.GET_VoteSet) - blog_routes.Get("/vote/get", routes.GET_VoteGet) - - // service routes - service_routes := app.Group("services") - service_routes.Get("/all", routes.GET_Services) - - // admin routes - admin_routes := app.Group("admin") - admin_routes.Use("*", routes.AuthMiddleware) - - // admin auth routes - admin_routes.Get("/login", routes.GET_Login) - admin_routes.Get("/logout", routes.GET_Logout) - - // admin service managment routes - admin_routes.Put("/service/add", routes.PUT_AddService) - admin_routes.Delete("/service/remove", routes.DEL_RemoveService) - - // admin blog managment routes - admin_routes.Put("/blog/add", routes.PUT_AddPost) - admin_routes.Delete("/blog/remove", routes.DEL_RemovePost) + // v1 admin routes + v1.Use("/admin", routes.AuthMiddleware) + v1.Get("/admin/logs", routes.GET_AdminLogs) + v1.Get("/admin/service/check", routes.GET_CheckService) + v1.Put("/admin/service/add", routes.PUT_AddService) + v1.Delete("/admin/service/del", routes.DEL_DelService) + v1.Put("/admin/news/add", routes.PUT_AddNews) + v1.Delete("/admin/news/del", routes.DEL_DelNews) // 404 route app.All("*", func(c *fiber.Ctx) error { - return util.ErrNotFound(c) + return util.JSON(c, http.StatusNotFound, fiber.Map{ + "error": "Endpoint not found", + }) }) - util.Info("starting web server at port 7001") - - if err = app.Listen("0.0.0.0:7001"); err != nil { - util.Fail("error starting the webserver: %s", err.Error()) + // start the status checker + if err = stat.Run(); err != nil { + util.Fail("failed to start the status checker: %s", err.Error()) + return } + + // start the app + util.Info("starting web server on %s", conf.GetStr("host")) + + if err = app.Listen(conf.GetStr("host")); err != nil { + util.Fail("failed to start the web server: %s", err.Error()) + } + + stat.Stop() } diff --git a/api/routes/admin.go b/api/routes/admin.go index c0f0451..649e92c 100644 --- a/api/routes/admin.go +++ b/api/routes/admin.go @@ -1,183 +1,154 @@ package routes import ( - "database/sql" - "net/http" - "strings" + "fmt" "time" "github.com/gofiber/fiber/v2" - "github.com/mattn/go-sqlite3" "github.com/ngn13/website/api/config" "github.com/ngn13/website/api/database" + "github.com/ngn13/website/api/status" "github.com/ngn13/website/api/util" ) -var Token string = util.CreateToken() +func admin_log(c *fiber.Ctx, m string) error { + return c.Locals("database").(*database.Type).AdminLogAdd(&database.AdminLog{ + Action: m, // action that the admin peformed + Time: time.Now().Unix(), // current time + }) +} func AuthMiddleware(c *fiber.Ctx) error { - if c.Path() == "/admin/login" { - return c.Next() - } + conf := c.Locals("config").(*config.Type) - if c.Get("Authorization") != Token { + if c.Get("Authorization") != conf.GetStr("password") { return util.ErrAuth(c) } return c.Next() } -func GET_Login(c *fiber.Ctx) error { - if c.Query("pass") != config.Get("password") { - return util.ErrAuth(c) - } - - util.Info("new login from %s", util.GetIP(c)) - - return c.Status(http.StatusOK).JSON(fiber.Map{ - "error": "", - "token": Token, - }) -} - -func GET_Logout(c *fiber.Ctx) error { - Token = util.CreateToken() - - util.Info("logout from %s", util.GetIP(c)) - - return c.Status(http.StatusOK).JSON(fiber.Map{ - "error": "", - }) -} - -func DEL_RemoveService(c *fiber.Ctx) error { +func GET_AdminLogs(c *fiber.Ctx) error { var ( - db *sql.DB - service database.Service - name string - found bool - err error + list []database.AdminLog + log database.AdminLog ) - db = *(c.Locals("database").(**sql.DB)) - name = c.Query("name") + db := c.Locals("database").(*database.Type) - if name == "" { - util.ErrBadData(c) + for db.AdminLogNext(&log) { + list = append(list, log) } - if found, err = service.Get(db, name); err != nil { - util.Fail("error while searching for a service (\"%s\"): %s", name, err.Error()) - return util.ErrServer(c) + return util.JSON(c, 200, fiber.Map{ + "result": list, + }) +} + +func DEL_DelService(c *fiber.Ctx) error { + var ( + name string + err error + ) + + db := c.Locals("database").(*database.Type) + + if name = c.Query("name"); name == "" { + util.ErrBadReq(c) } - if !found { - return util.ErrEntryNotExists(c) + if err = admin_log(c, fmt.Sprintf("Removed service \"%s\"", name)); err != nil { + return util.ErrInternal(c, err) } - if err = service.Remove(db); err != nil { - util.Fail("error while removing a service (\"%s\"): %s", service.Name, err.Error()) - return util.ErrServer(c) + if err = db.ServiceRemove(name); err != nil { + return util.ErrInternal(c, err) } - return util.NoError(c) + return util.JSON(c, 200, nil) } func PUT_AddService(c *fiber.Ctx) error { var ( service database.Service - db *sql.DB - found bool err error ) - db = *(c.Locals("database").(**sql.DB)) + db := c.Locals("database").(*database.Type) if c.BodyParser(&service) != nil { return util.ErrBadJSON(c) } - if service.Name == "" || service.Desc == "" || service.Url == "" { - return util.ErrBadData(c) + if !service.IsValid() { + return util.ErrBadReq(c) } - if found, err = service.Get(db, service.Name); err != nil { - util.Fail("error while searching for a service (\"%s\"): %s", service.Name, err.Error()) - return util.ErrServer(c) + if err = admin_log(c, fmt.Sprintf("Added service \"%s\"", service.Name)); err != nil { + return util.ErrInternal(c, err) } - if found { - return util.ErrEntryExists(c) + if err = db.ServiceUpdate(&service); err != nil { + return util.ErrInternal(c, err) } - if err = service.Save(db); err != nil { - util.Fail("error while saving a new service (\"%s\"): %s", service.Name, err.Error()) - return util.ErrServer(c) - } + // force a status check so we can get the status of the new service + c.Locals("status").(*status.Type).Check() - return util.NoError(c) + return util.JSON(c, 200, nil) } -func DEL_RemovePost(c *fiber.Ctx) error { +func GET_CheckService(c *fiber.Ctx) error { + c.Locals("status").(*status.Type).Check() + return util.JSON(c, 200, nil) +} + +func DEL_DelNews(c *fiber.Ctx) error { var ( - db *sql.DB - id string - found bool - err error - post database.Post + id string + err error ) - db = *(c.Locals("database").(**sql.DB)) + db := c.Locals("database").(*database.Type) if id = c.Query("id"); id == "" { - return util.ErrBadData(c) + util.ErrBadReq(c) } - if found, err = post.Get(db, id); err != nil { - util.Fail("error while searching for a post (\"%s\"): %s", id, err.Error()) - return util.ErrServer(c) + if err = admin_log(c, fmt.Sprintf("Removed news \"%s\"", id)); err != nil { + return util.ErrInternal(c, err) } - if !found { - return util.ErrEntryNotExists(c) + if err = db.NewsRemove(id); err != nil { + return util.ErrInternal(c, err) } - if err = post.Remove(db); err != nil { - util.Fail("error while removing a post (\"%s\"): %s", post.ID, err.Error()) - return util.ErrServer(c) - } - - return util.NoError(c) + return util.JSON(c, 200, nil) } -func PUT_AddPost(c *fiber.Ctx) error { +func PUT_AddNews(c *fiber.Ctx) error { var ( - db *sql.DB - post database.Post + news database.News err error ) - db = *(c.Locals("database").(**sql.DB)) - post.Public = 1 + db := c.Locals("database").(*database.Type) - if c.BodyParser(&post) != nil { + if c.BodyParser(&news) != nil { return util.ErrBadJSON(c) } - if post.Title == "" || post.Author == "" || post.Content == "" { - return util.ErrBadData(c) + if !news.IsValid() { + return util.ErrBadReq(c) } - post.Date = time.Now().Format("02/01/06") - - if err = post.Save(db); err != nil && strings.Contains(err.Error(), sqlite3.ErrConstraintUnique.Error()) { - return util.ErrEntryExists(c) + if err = admin_log(c, fmt.Sprintf("Added news \"%s\"", news.ID)); err != nil { + return util.ErrInternal(c, err) } - if err != nil { - util.Fail("error while saving a new post (\"%s\"): %s", post.ID, err.Error()) - return util.ErrServer(c) + if err = db.NewsAdd(&news); err != nil { + return util.ErrInternal(c, err) } - return util.NoError(c) + return util.JSON(c, 200, nil) } diff --git a/api/routes/blog.go b/api/routes/blog.go deleted file mode 100644 index c4e988f..0000000 --- a/api/routes/blog.go +++ /dev/null @@ -1,203 +0,0 @@ -package routes - -import ( - "database/sql" - "fmt" - "net/url" - "path" - "strings" - "time" - - "github.com/gofiber/fiber/v2" - "github.com/gorilla/feeds" - "github.com/ngn13/website/api/config" - "github.com/ngn13/website/api/database" - "github.com/ngn13/website/api/util" -) - -func GET_Post(c *fiber.Ctx) error { - var ( - post database.Post - id string - db *sql.DB - found bool - err error - ) - - db = *(c.Locals("database").(**sql.DB)) - - if id = c.Query("id"); id == "" { - return util.ErrBadData(c) - } - - if found, err = post.Get(db, id); err != nil { - util.Fail("error while search for a post (\"%s\"): %s", id, err.Error()) - return util.ErrServer(c) - } - - if !found { - return util.ErrEntryNotExists(c) - } - - return c.JSON(fiber.Map{ - "error": "", - "result": post, - }) -} - -func GET_PostSum(c *fiber.Ctx) error { - var ( - posts []database.Post - rows *sql.Rows - db *sql.DB - err error - ) - - db = *(c.Locals("database").(**sql.DB)) - - if rows, err = db.Query("SELECT * FROM posts"); err != nil { - util.Fail("cannot load posts: %s", err.Error()) - return util.ErrServer(c) - } - defer rows.Close() - - for rows.Next() { - var post database.Post - - if err = post.Load(rows); err != nil { - util.Fail("error while loading post: %s", err.Error()) - return util.ErrServer(c) - } - - if post.Public == 0 { - continue - } - - if len(post.Content) > 255 { - post.Content = post.Content[0:250] - } - - posts = append(posts, post) - } - - return c.JSON(fiber.Map{ - "error": "", - "result": posts, - }) -} - -func getFeed(db *sql.DB) (*feeds.Feed, error) { - var ( - posts []database.Post - err error - ) - - rows, err := db.Query("SELECT * FROM posts") - if err != nil { - return nil, err - } - - for rows.Next() { - var post database.Post - - if err = post.Load(rows); err != nil { - return nil, err - } - - if post.Public == 0 { - continue - } - - posts = append(posts, post) - } - rows.Close() - - blogurl, err := url.JoinPath( - config.Get("frontend_url"), "/blog", - ) - - if err != nil { - return nil, fmt.Errorf("failed to create the blog URL: %s", err.Error()) - } - - feed := &feeds.Feed{ - Title: "[ngn.tf] | blog", - Link: &feeds.Link{Href: blogurl}, - Description: "ngn's personal blog", - Author: &feeds.Author{Name: "ngn", Email: "ngn@ngn.tf"}, - Created: time.Now(), - } - - feed.Items = []*feeds.Item{} - for _, p := range posts { - purl, err := url.JoinPath(blogurl, p.ID) - if err != nil { - return nil, fmt.Errorf("failed to create URL for '%s': %s\n", p.ID, err.Error()) - } - - parsed, err := time.Parse("02/01/06", p.Date) - if err != nil { - return nil, fmt.Errorf("failed to parse time for '%s': %s\n", p.ID, err.Error()) - } - - feed.Items = append(feed.Items, &feeds.Item{ - Id: p.ID, - Title: p.Title, - Link: &feeds.Link{Href: purl}, - Author: &feeds.Author{Name: p.Author}, - Created: parsed, - }) - } - - return feed, nil -} - -func GET_Feed(c *fiber.Ctx) error { - var ( - db *sql.DB - err error - feed *feeds.Feed - name []string - res string - ext string - ) - - db = *(c.Locals("database").(**sql.DB)) - - if name = strings.Split(path.Base(c.Path()), "."); len(name) != 2 { - return util.ErrNotFound(c) - } - ext = name[1] - - if feed, err = getFeed(db); err != nil { - util.Fail("cannot obtain the feed: %s", err.Error()) - return util.ErrServer(c) - } - - switch ext { - case "atom": - res, err = feed.ToAtom() - c.Set("Content-Type", "application/atom+xml") - break - - case "json": - res, err = feed.ToJSON() - c.Set("Content-Type", "application/feed+json") - break - - case "rss": - res, err = feed.ToRss() - c.Set("Content-Type", "application/rss+xml") - break - - default: - return util.ErrNotFound(c) - } - - if err != nil { - util.Fail("cannot obtain the feed as the specified format: %s", err.Error()) - return util.ErrServer(c) - } - - return c.Send([]byte(res)) -} diff --git a/api/routes/index.go b/api/routes/index.go new file mode 100644 index 0000000..970b2f2 --- /dev/null +++ b/api/routes/index.go @@ -0,0 +1,32 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + "github.com/ngn13/website/api/config" + "github.com/ngn13/website/api/util" +) + +func GET_Index(c *fiber.Ctx) error { + var ( + md []byte + err error + ) + + conf := c.Locals("config").(*config.Type) + + if !conf.GetBool("index") { + return util.ErrNotFound(c) + } + + frontend := conf.GetURL("frontend_url") + api := conf.GetURL("api_url") + + if md, err = util.Render("views/index.md", fiber.Map{ + "frontend": frontend, + "api": api, + }); err != nil { + return util.ErrInternal(c, err) + } + + return util.Markdown(c, md) +} diff --git a/api/routes/news.go b/api/routes/news.go new file mode 100644 index 0000000..39d5a79 --- /dev/null +++ b/api/routes/news.go @@ -0,0 +1,47 @@ +package routes + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/ngn13/website/api/config" + "github.com/ngn13/website/api/database" + "github.com/ngn13/website/api/util" +) + +func GET_News(c *fiber.Ctx) error { + var ( + news []database.News + n database.News + feed []byte + err error + ) + + db := c.Locals("database").(*database.Type) + conf := c.Locals("config").(*config.Type) + frontend := conf.GetURL("frontend_url") + lang := c.Params("lang") + + if lang == "" || len(lang) != 2 { + return util.ErrBadReq(c) + } + + lang = strings.ToLower(lang) + + for db.NewsNext(&n) { + if n.Supports(lang) { + news = append(news, n) + } + } + + if feed, err = util.Render("views/news.xml", fiber.Map{ + "frontend": frontend, + "lang": lang, + "news": news, + }); err != nil { + return util.ErrInternal(c, err) + } + + c.Set("Content-Type", "application/atom+xml; charset=utf-8") + return c.Send(feed) +} diff --git a/api/routes/services.go b/api/routes/services.go index 0ded696..c84efa1 100644 --- a/api/routes/services.go +++ b/api/routes/services.go @@ -1,8 +1,6 @@ package routes import ( - "database/sql" - "github.com/gofiber/fiber/v2" "github.com/ngn13/website/api/database" "github.com/ngn13/website/api/util" @@ -11,32 +9,29 @@ import ( func GET_Services(c *fiber.Ctx) error { var ( services []database.Service - rows *sql.Rows - db *sql.DB - err error + service database.Service ) - db = *(c.Locals("database").(**sql.DB)) + db := c.Locals("database").(*database.Type) + name := c.Query("name") - if rows, err = db.Query("SELECT * FROM services"); err != nil { - util.Fail("cannot load services: %s", err.Error()) - return util.ErrServer(c) - } - defer rows.Close() - - for rows.Next() { - var service database.Service - - if err = service.Load(rows); err != nil { - util.Fail("error while loading service: %s", err.Error()) - return util.ErrServer(c) + if name != "" { + if s, err := db.ServiceFind(name); err != nil { + return util.ErrInternal(c, err) + } else if s != nil { + return util.JSON(c, 200, fiber.Map{ + "result": s, + }) } + return util.ErrNotExist(c) + } + + for db.ServiceNext(&service) { services = append(services, service) } - return c.JSON(fiber.Map{ - "error": "", + return util.JSON(c, 200, fiber.Map{ "result": services, }) } diff --git a/api/routes/vote.go b/api/routes/vote.go deleted file mode 100644 index be718a0..0000000 --- a/api/routes/vote.go +++ /dev/null @@ -1,139 +0,0 @@ -package routes - -import ( - "database/sql" - - "github.com/gofiber/fiber/v2" - "github.com/ngn13/website/api/database" - "github.com/ngn13/website/api/util" -) - -func getVoteHash(id string, ip string) string { - return util.GetSHA512(id + "_" + ip) -} - -func GET_VoteGet(c *fiber.Ctx) error { - var ( - db *sql.DB - id string - hash string - vote database.Vote - found bool - err error - ) - - db = *(c.Locals("database").(**sql.DB)) - - if id = c.Query("id"); id == "" { - return util.ErrBadData(c) - } - - hash = getVoteHash(id, util.GetIP(c)) - - if found, err = vote.Get(db, hash); err != nil { - util.Fail("error while searchig for a vote (\"%s\"): %s", hash, err.Error()) - return util.ErrServer(c) - } - - if !found { - return util.ErrEntryNotExists(c) - } - - if vote.IsUpvote { - return c.JSON(fiber.Map{ - "error": "", - "result": "upvote", - }) - } - - return c.JSON(fiber.Map{ - "error": "", - "result": "downvote", - }) -} - -func GET_VoteSet(c *fiber.Ctx) error { - var ( - db *sql.DB - id string - is_upvote bool - hash string - vote database.Vote - post database.Post - found bool - err error - ) - - db = *(c.Locals("database").(**sql.DB)) - id = c.Query("id") - - if c.Query("to") == "" || id == "" { - return util.ErrBadData(c) - } - - if found, err = post.Get(db, id); err != nil { - util.Fail("error while searching for a post (\"%s\"): %s", id, err.Error()) - return util.ErrServer(c) - } - - if !found { - return util.ErrEntryNotExists(c) - } - - is_upvote = c.Query("to") == "upvote" - hash = getVoteHash(id, util.GetIP(c)) - - if found, err = vote.Get(db, hash); err != nil { - util.Fail("error while searching for a vote (\"%s\"): %s", hash, err.Error()) - return util.ErrServer(c) - } - - if found { - if vote.IsUpvote == is_upvote { - return util.ErrEntryExists(c) - } - - if vote.IsUpvote && !is_upvote { - post.Vote -= 2 - } - - if !vote.IsUpvote && is_upvote { - post.Vote += 2 - } - - vote.IsUpvote = is_upvote - - if err = post.Update(db); err != nil { - util.Fail("error while updating post (\"%s\"): %s", post.ID, err.Error()) - return util.ErrServer(c) - } - - if err = vote.Update(db); err != nil { - util.Fail("error while updating vote (\"%s\"): %s", vote.Hash, err.Error()) - return util.ErrServer(c) - } - - return util.NoError(c) - } - - vote.Hash = hash - vote.IsUpvote = is_upvote - - if is_upvote { - post.Vote++ - } else { - post.Vote-- - } - - if err = post.Update(db); err != nil { - util.Fail("error while updating post (\"%s\"): %s", post.ID, err.Error()) - return util.ErrServer(c) - } - - if err = vote.Save(db); err != nil { - util.Fail("error while updating vote (\"%s\"): %s", vote.Hash, err.Error()) - return util.ErrServer(c) - } - - return util.NoError(c) -} diff --git a/api/status/service.go b/api/status/service.go new file mode 100644 index 0000000..26d25d0 --- /dev/null +++ b/api/status/service.go @@ -0,0 +1,105 @@ +package status + +import ( + "net/http" + "net/http/httptrace" + "net/url" + "time" + + "github.com/ngn13/website/api/database" + "github.com/ngn13/website/api/util" +) + +const ( + STATUS_RES_DOWN = 0 // service is down + STATUS_RES_OK = 1 // service is up + STATUS_RES_SLOW = 2 // service is up, but slow + STATUS_RES_NONE = 3 // service doesn't support status checking/status checking is disabled +) + +func (s *Type) check_http_service(service *database.Service) (r uint8, err error) { + var ( + req *http.Request + res *http.Response + + start time.Time + elapsed time.Duration + ) + + r = STATUS_RES_NONE + + if req, err = http.NewRequest("GET", service.CheckURL, nil); err != nil { + return + } + + trace := &httptrace.ClientTrace{ + GetConn: func(_ string) { start = time.Now() }, + GotFirstResponseByte: func() { elapsed = time.Since(start) }, + } + + http.DefaultClient.Timeout = s.timeout + req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) + res, err = http.DefaultClient.Do(req) + + if res != nil { + defer res.Body.Close() + } + + if err != nil { + util.Debg("marking service \"%s\" as down (%s)", service.Name, err.Error()) + err = nil + r = STATUS_RES_DOWN + } else if res.StatusCode != 200 { + util.Debg("marking service \"%s\" as down (status code %d)", service.Name, res.StatusCode) + r = STATUS_RES_DOWN + } else if elapsed.Microseconds() > s.limit.Microseconds() { + r = STATUS_RES_SLOW + } else { + r = STATUS_RES_OK + } + + return +} + +func (s *Type) check_service(service *database.Service) error { + var ( + res uint8 + url *url.URL + err error + ) + + if s.disabled || service.CheckURL == "" { + err = nil + goto fail + } + + if url, err = url.Parse(service.CheckURL); err != nil { + return err + } + + switch url.Scheme { + case "https": + if res, err = s.check_http_service(service); err != nil { + goto fail + } + + case "http": + if res, err = s.check_http_service(service); err != nil { + goto fail + } + + default: + // unsupported protocol + err = nil + goto fail + } + + service.CheckTime = uint64(time.Now().Unix()) + service.CheckRes = res + return nil + +fail: + service.CheckTime = 0 + service.CheckRes = STATUS_RES_NONE + return err +} diff --git a/api/status/status.go b/api/status/status.go new file mode 100644 index 0000000..8c2a74f --- /dev/null +++ b/api/status/status.go @@ -0,0 +1,139 @@ +package status + +import ( + "fmt" + "time" + + "github.com/ngn13/website/api/config" + "github.com/ngn13/website/api/database" + "github.com/ngn13/website/api/util" +) + +type Type struct { + conf *config.Type + db *database.Type + + ticker *time.Ticker + updateChan chan int + closeChan chan int + + disabled bool + timeout time.Duration + limit time.Duration +} + +func (s *Type) check() { + var ( + services []database.Service + service database.Service + err error + ) + + for s.db.ServiceNext(&service) { + services = append(services, service) + } + + for i := range services { + if err = s.check_service(&services[i]); err != nil { + util.Fail("failed to check the service status for \"%s\": %s", services[i].Name, err.Error()) + } + + if err = s.db.ServiceUpdate(&services[i]); err != nil { + util.Fail("failed to update service status for \"%s\": %s", services[i].Name, err.Error()) + } + } +} + +func (s *Type) loop() { + s.check() + + for { + select { + case <-s.closeChan: + close(s.updateChan) + s.ticker.Stop() + s.closeChan <- 0 + return + + case <-s.updateChan: + s.check() + + case <-s.ticker.C: + s.check() + } + } +} + +func (s *Type) Setup(conf *config.Type, db *database.Type) error { + var ( + dur time.Duration + iv, to, lm string + err error + ) + + iv = conf.GetStr("interval") + to = conf.GetStr("timeout") + lm = conf.GetStr("limit") + + if iv == "" || to == "" || lm == "" { + s.disabled = true + return nil + } + + if dur, err = util.GetDuration(iv); err != nil { + return err + } + + if s.timeout, err = util.GetDuration(iv); err != nil { + return err + } + + if s.limit, err = util.GetDuration(iv); err != nil { + return err + } + + s.conf = conf + s.db = db + + s.ticker = time.NewTicker(dur) + s.updateChan = make(chan int) + s.closeChan = make(chan int) + + s.disabled = false + + return nil +} + +func (s *Type) Run() error { + if s.ticker == nil || s.updateChan == nil || s.closeChan == nil { + return fmt.Errorf("you either didn't call Setup() or you called it and it failed") + } + + if s.disabled { + go s.check() + return nil + } + + go s.loop() + return nil +} + +func (s *Type) Check() { + if !s.disabled { + s.updateChan <- 0 + } +} + +func (s *Type) Stop() { + // tell loop() to stop + s.closeChan <- 0 + + // wait till loop() stops + for { + select { + case <-s.closeChan: + close(s.closeChan) + return + } + } +} diff --git a/api/util/log.go b/api/util/log.go index 3d540c7..d84645a 100644 --- a/api/util/log.go +++ b/api/util/log.go @@ -5,8 +5,17 @@ import ( "os" ) -var ( - Info = log.New(os.Stdout, "\033[34m[info]\033[0m ", log.Ltime|log.Lshortfile).Printf - Warn = log.New(os.Stderr, "\033[33m[warn]\033[0m ", log.Ltime|log.Lshortfile).Printf - Fail = log.New(os.Stderr, "\033[31m[fail]\033[0m ", log.Ltime|log.Lshortfile).Printf +const ( + COLOR_BLUE = "\033[34m" + COLOR_YELLOW = "\033[33m" + COLOR_RED = "\033[31m" + COLOR_CYAN = "\033[36m" + COLOR_RESET = "\033[0m" +) + +var ( + Debg = log.New(os.Stdout, COLOR_CYAN+"[debg]"+COLOR_RESET+" ", log.Ltime|log.Lshortfile).Printf + Info = log.New(os.Stdout, COLOR_BLUE+"[info]"+COLOR_RESET+" ", log.Ltime|log.Lshortfile).Printf + Warn = log.New(os.Stderr, COLOR_YELLOW+"[warn]"+COLOR_RESET+" ", log.Ltime|log.Lshortfile).Printf + Fail = log.New(os.Stderr, COLOR_RED+"[fail]"+COLOR_RESET+" ", log.Ltime|log.Lshortfile).Printf ) diff --git a/api/util/res.go b/api/util/res.go new file mode 100644 index 0000000..2166cf0 --- /dev/null +++ b/api/util/res.go @@ -0,0 +1,89 @@ +package util + +import ( + "net/http" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/ngn13/website/api/config" + "github.com/russross/blackfriday/v2" +) + +func IP(c *fiber.Ctx) string { + conf := c.Locals("config").(*config.Type) + ip_header := conf.GetStr("ip_header") + + if ip_header != "" && c.Get(ip_header) != "" { + return strings.Clone(c.Get(ip_header)) + } + + return c.IP() +} + +func Markdown(c *fiber.Ctx, raw []byte) error { + exts := blackfriday.FencedCode + exts |= blackfriday.NoEmptyLineBeforeBlock + exts |= blackfriday.HardLineBreak + + c.Set("Content-Type", "text/html; charset=utf-8") + return c.Send(blackfriday.Run(raw, blackfriday.WithExtensions(exts))) +} + +func JSON(c *fiber.Ctx, code int, data fiber.Map) error { + if data == nil { + data = fiber.Map{} + data["error"] = "" + } else if _, ok := data["error"]; !ok { + data["error"] = "" + } + + if data["error"] == 200 { + Warn("200 response with an error at %s", c.Path()) + } + + return c.Status(code).JSON(data) +} + +func ErrInternal(c *fiber.Ctx, err error) error { + Warn("Internal server error at %s: %s", c.Path(), err.Error()) + + return JSON(c, http.StatusInternalServerError, fiber.Map{ + "error": "Server error", + }) +} + +func ErrExists(c *fiber.Ctx) error { + return JSON(c, http.StatusConflict, fiber.Map{ + "error": "Entry already exists", + }) +} + +func ErrNotExist(c *fiber.Ctx) error { + return JSON(c, http.StatusNotFound, fiber.Map{ + "error": "Entry does not exist", + }) +} + +func ErrBadReq(c *fiber.Ctx) error { + return JSON(c, http.StatusBadRequest, fiber.Map{ + "error": "Provided data is invalid", + }) +} + +func ErrNotFound(c *fiber.Ctx) error { + return JSON(c, http.StatusNotFound, fiber.Map{ + "error": "Endpoint not found", + }) +} + +func ErrBadJSON(c *fiber.Ctx) error { + return JSON(c, http.StatusBadRequest, fiber.Map{ + "error": "Invalid JSON data", + }) +} + +func ErrAuth(c *fiber.Ctx) error { + return JSON(c, http.StatusUnauthorized, fiber.Map{ + "error": "Authentication failed", + }) +} diff --git a/api/util/util.go b/api/util/util.go new file mode 100644 index 0000000..90602fb --- /dev/null +++ b/api/util/util.go @@ -0,0 +1,67 @@ +package util + +import ( + "bytes" + "crypto/sha1" + "encoding/hex" + "fmt" + "os" + "strconv" + "strings" + "text/template" + "time" +) + +func Render(file string, data interface{}) ([]byte, error) { + var ( + rendered *bytes.Buffer + tmpl *template.Template + content []byte + err error + ) + + if content, err = os.ReadFile(file); err != nil { + return nil, err + } + + if tmpl, err = template.New("template").Parse(string(content)); err != nil { + return nil, err + } + + rendered = bytes.NewBuffer(nil) + err = tmpl.Execute(rendered, data) + + return rendered.Bytes(), err +} + +func GetDuration(d string) (time.Duration, error) { + var ( + d_num uint64 + err error + ) + + d_num_end := d[len(d)-1] + d_num_str := strings.TrimSuffix(d, string(d_num_end)) + + if d_num, err = strconv.ParseUint(d_num_str, 10, 64); err != nil { + return 0, err + } + + switch d_num_end { + case 's': + return time.Duration(d_num) * (time.Second), nil + + case 'm': + return time.Duration(d_num) * (time.Second * 60), nil + + case 'h': + return time.Duration(d_num) * ((time.Second * 60) * 60), nil + } + + return 0, fmt.Errorf("invalid time duration format") +} + +func GetSHA1(s string) string { + hasher := sha1.New() + return hex.EncodeToString(hasher.Sum([]byte(s))) +} diff --git a/api/util/utils.go b/api/util/utils.go deleted file mode 100644 index 61e1376..0000000 --- a/api/util/utils.go +++ /dev/null @@ -1,74 +0,0 @@ -package util - -import ( - "crypto/sha512" - "fmt" - "math/rand" - "net/http" - "strings" - - "github.com/gofiber/fiber/v2" -) - -func GetSHA512(s string) string { - hasher := sha512.New() - return fmt.Sprintf("%x", hasher.Sum([]byte(s))) -} - -func TitleToID(name string) string { - return strings.ToLower(strings.ReplaceAll(name, " ", "")) -} - -func CreateToken() string { - s := make([]byte, 32) - for i := 0; i < 32; i++ { - s[i] = byte(65 + rand.Intn(25)) - } - return string(s) -} - -func ErrorJSON(error string) fiber.Map { - return fiber.Map{ - "error": error, - } -} - -func GetIP(c *fiber.Ctx) string { - if c.Get("X-Real-IP") != "" { - return strings.Clone(c.Get("X-Real-IP")) - } - - return c.IP() -} - -func ErrServer(c *fiber.Ctx) error { - return c.Status(http.StatusInternalServerError).JSON(ErrorJSON("Server error")) -} - -func ErrEntryExists(c *fiber.Ctx) error { - return c.Status(http.StatusConflict).JSON(ErrorJSON("Entry already exists")) -} - -func ErrEntryNotExists(c *fiber.Ctx) error { - return c.Status(http.StatusNotFound).JSON(ErrorJSON("Entry does not exist")) -} - -func ErrBadData(c *fiber.Ctx) error { - return c.Status(http.StatusBadRequest).JSON(ErrorJSON("Provided data is invalid")) -} - -func ErrBadJSON(c *fiber.Ctx) error { - return c.Status(http.StatusBadRequest).JSON(ErrorJSON("Bad JSON data")) -} - -func ErrAuth(c *fiber.Ctx) error { - return c.Status(http.StatusUnauthorized).JSON(ErrorJSON("Authentication failed")) -} - -func ErrNotFound(c *fiber.Ctx) error { - return c.Status(http.StatusNotFound).JSON(ErrorJSON("Requested endpoint not found")) -} - -func NoError(c *fiber.Ctx) error { - return c.Status(http.StatusOK).JSON(ErrorJSON("")) -} diff --git a/api/views/index.md b/api/views/index.md new file mode 100644 index 0000000..2a97bbd --- /dev/null +++ b/api/views/index.md @@ -0,0 +1,161 @@ + + + + +# [{{.api.Host}}]({{.api.String}}) +This is the API for my personal website, [{{.frontend.Host}}]({{.frontend.String}}). + +It stores information about the self-hosted services I provide and it also allows me +to publish news and updates about these services using an Atom feed. It's written in +Go and uses SQLite for storage. Licensed under GNU GPL version 3. + +**Source code and the license is available at**: [https://github.com/ngn13/website](https://github.com/ngn13/website) +**You can report issues to**: [https://github.com/ngn13/website/issues](https://github.com/ngn13/website/issues) + +The rest of this document contains documentation for all the available API endpoints. + +## Version 1 Endpoints +Each version 1 endpoint, can be accessed using the /v1 route. + +All the endpoints return JSON formatted data. + +### Errors +If any error occurs, you will get a non-200 response. And the JSON data will have an +"error" key, which will contain information about the error that occured, in the +string format. This is the only JSON key that will be set in non-200 responses. + +### Results +If no error occurs, "error" key will be set to an emtpy string (""). If the endpoint +returns any data, this will be stored using the "result" key. The "result" have a +different expected type and a format for each endpoint. + +### Multilang +Some "result" formats may use a structure called "Multilang". This is a simple JSON +structure that includes one key for each supported language. The key is named after +the language it represents. Currently only supported languages are: +- English (`en`) +- Turkish (`tr`) + +So each multilang structure, will have **at least** one of these keys. + +Here is an example multilang structure: +``` +{ + "en": "Hello, world!", + "tr": "Merhaba, dünya!" +} +``` +If a "result" field is using a multilang structure, it will be specified as "Multilang" +in the rest of the documentation. + +### Administrator routes +The endpoints under the "/v1/admin" route, are administrator-only routes. To access +these routes you'll need to specfiy and password using the "Authorization" header. +If the password you specify, matches with the password specified using the +`API_PASSWORD` environment variable, you will be able to access the route. + +### GET /v1/services +Returns a list of available services. Each service has the following JSON format: +``` +{ + "name": "Test Service", + "desc": { + "en": "Service used for testing the API", + "tr": "API'ı test etmek için kullanılan servis" + }, + "check_time": 1735861944, + "check_res": 1, + "check_url": "http://localhost:7001", + "clear": "http://localhost:7001", + "onion": "", + "i2p": "" +} +``` +Where: +- `name`: Service name (string) +- `desc`: Service description (Multilang) +- `check_time`: Last time status check time for the service, set 0 if status checking is +not supported for this service/status checking is disabled (integer, UNIX timestamp) +- `check_res`: Last service status check result (integer) + * 0 if the service is down + * 1 if the service is up + * 2 if the service is up, but slow + * 3 if the service doesn't support status checking/status checking is disabled +- `check_url`: URL used for service's status check (string, empty if none) +- `clear`: Clearnet URL for the service (string, empty string if none) +- `onion`: Onion (TOR) URL for the service (string, empty string if none) +- `i2p`: I2P URL for the service (string, empty string if none) + +You can also get information about a specific service by specifying it's name using +a URL query named "name". + +### GET /v1/news/:language +Returns a Atom feed of news for the given language. Supports languages that are supported +by Multilang. + +### GET /v1/admin/logs +Returns a list of administrator logs. Each log has the following JSON format: +``` +{ + "action": "Added service \"Test Service\"", + "time": 1735861794 +} +``` +Where: +- `action`: Action that the administrator performed (string) +- `time`: Time when the administrator action was performed (integer, UNIX timestamp) + +Client can get the logs for only a single address, by setting the URL query "addr". + +### PUT /v1/admin/service/add +Creates a new service. The request body needs to contain JSON data, and it needs to +have the JSON format used to represent a service. See "/v1/services/all" route to +see this format. + +Returns no data on success. + +### DELETE /v1/admin/service/del +Deletes a service. The client needs to specify the name of the service to delete, by +setting the URL query "name". + +Returns no data on success. + +### GET /v1/admin/service/check +Forces a status check for all the services. + +Returns no data on success. + +### PUT /v1/admin/news/add +Creates a news post. The request body needs to contain JSON data, and it needs +to use the following JSON format: +``` +{ + "id": "test_news", + "title": { + "en": "Very important news", + "tr": "Çok önemli haber" + }, + "author": "ngn", + "content": { + "en": "Just letting you know that I'm testing the API", + "tr": "Sadece API'ı test ettiğimi bilmenizi istedim" + } +} +``` +Where: +- `id`: Unique ID for the news post (string) +- `title`: Title for the news post (Multilang) +- `author`: Author of the news post (string) +- `content`: Contents of the news post (Multilang) + +Returns no data on success. + +### DELETE /v1/admin/news/del +Deletes a news post. The client needs to specify the ID of the news post to delete, +by setting the URL query "id". + +Returns no data on success. diff --git a/api/views/news.xml b/api/views/news.xml new file mode 100644 index 0000000..b78643f --- /dev/null +++ b/api/views/news.xml @@ -0,0 +1,6 @@ + + {{.frontend.Host}} news + 2025-01-02T20:46:24Z + News and updates about my self-hosted services and projects + +