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
+
+