restructure the API and update the admin script

This commit is contained in:
ngn 2025-01-04 00:00:10 +03:00
parent 03586da8df
commit 26e8909998
34 changed files with 1699 additions and 983 deletions

12
admin/Makefile Normal file
View File

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

View File

@ -1,176 +1,331 @@
#!/bin/python3 #!/bin/python3
""" """
Administration script for my website (ngn.tf)
############################################# website/admin | Administration script for my personal website
I really enjoy doing stuff from the terminal, written by ngn (https://ngn.tf) (2025)
so I wrote this simple python script that interacts
with the API and lets me add/remove new posts/services This program is free software: you can redistribute it and/or modify
from the terminal 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 <https://www.gnu.org/licenses/>.
""" """
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 from getpass import getpass
import requests as req import requests as req
from os import getenv
from sys import argv from sys import argv
URL = "" API_URL_ENV = "API_URL"
def join(pth: str) -> str:
if URL == None:
return ""
if URL.endswith("/"): # logger used by the script
return URL+pth class Log:
return URL+"/"+pth def __init__(self) -> None:
self.reset = Fore.RESET + Style.RESET_ALL
def get_token() -> str: def info(self, m: str) -> None:
try: print(Fore.BLUE + Style.BRIGHT + "[*]" + self.reset + " " + m)
f = open("/tmp/wa", "r")
token = f.read()
f.close()
return token
except:
print("[-] You are not authenticated")
exit(1)
def login() -> None: def error(self, m: str) -> None:
pwd = getpass("[>] Enter your password: ") print(Fore.RED + Style.BRIGHT + "[-]" + self.reset + " " + m)
res = req.get(join("admin/login")+f"?pass={pwd}").json()
if res["error"] != "":
print(f"[-] Error logging in: {res['error']}")
return
token = res["token"] def input(self, m: str) -> str:
f = open("/tmp/wa", "w") return input(Fore.CYAN + Style.BRIGHT + "[?]" + self.reset + " " + m + ": ")
f.write(token)
f.close()
def logout() -> None: def password(self, m: str) -> str:
token = get_token() return getpass(Fore.CYAN + Style.BRIGHT + "[?]" + self.reset + " " + m + ": ")
res = req.get(join("admin/logout"), headers={
"Authorization": token
}).json()
if res["error"] != "":
print(f"[-] Error logging out: {res['error']}")
return
remove("/tmp/wa")
print("[+] Logged out")
def add_post() -> None: # API interface for the admin endpoints
token = get_token() class AdminAPI:
title = input("[>] Post title: ") def __init__(self, url: str, password: str) -> None:
author = input("[>] Post author: ") self.languages: List[str] = [
content_file = input("[>] Post content file: ") "en",
public = input("[>] Should post be public? (y/n): ") "tr",
] # languages supported by multilang fields
self.password = password
self.api_url = url
try: def _title_to_id(self, title: str) -> str:
f = open(content_file, "r") return title.lower().replace(" ", "_")
content = f.read()
f.close()
except:
print("[-] Content file not found")
return
res = req.put(join("admin/blog/add"), json={ def _check_multilang_field(self, ml: Dict[str, str]) -> bool:
"title": title, for l in self.languages:
"author": author, if l in ml and ml[l] != "":
"content": content, return True
"public": 1 if public == "y" else 0 return False
}, headers={
"Authorization": token
}).json()
if res["error"] != "": def _api_url_join(self, path: str) -> str:
print(f"[-] Error adding post: {res['error']}") api_has_slash = self.api_url.endswith("/")
return 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: def _to_json(self, res: req.Response) -> dict:
token = get_token() if res.status_code == 403:
id = input("[>] Post ID: ") raise Exception("Authentication failed")
res = req.delete(join("admin/blog/remove")+f"?id={id}", headers={
"Authorization": token
}).json()
if res["error"] != "": json = res.json()
print(f"[-] Error removing post: {res['error']}")
return
print("[-] Post has been removed") if json["error"] != "":
raise Exception("API error: %s" % json["error"])
def add_service() -> None: return json
token = get_token()
name = input("[>] Serivce name: ")
desc = input("[>] Serivce desc: ")
link = input("[>] Serivce URL: ")
res = req.put(join("admin/service/add"), json={ def PUT(self, url: str, data: dict) -> req.Response:
"name": name, return self._to_json(
"desc": desc, req.put(
"url": link self._api_url_join(url),
}, headers={ json=data,
"Authorization": token headers={"Authorization": self.password},
}).json() )
)
if res["error"] != "": def DELETE(self, url: str) -> req.Response:
print(f"[-] Error adding service: {res['error']}") return self._to_json(
return 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: def add_service(self, service: Dict[str, str]):
token = get_token() if not "name" in service or service["name"] == "":
name = input("[>] Service name: ") raise Exception('Service structure is missing required "name" field')
res = req.delete(join("admin/service/remove")+f"?name={name}", headers={
"Authorization": token
}).json()
if res["error"] != "": if not "desc" in service:
print(f"[-] Error removing service: {res['error']}") raise Exception('Service structure is missing required "desc" field')
return
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 = { if not self._check_multilang_field(service["desc"]):
"login": login, raise Exception(
"logout": logout, 'Service structure field "desc" needs at least one supported language entry'
"add_post": add_post, )
"remove_post": remove_post,
"add_service": add_service,
"remove_service": remove_service,
}
def main(): self.PUT("/v1/admin/service/add", service)
global URL
URL = getenv("API")
if URL == None or URL == "":
print("[-] API enviroment variable not set")
exit(1)
if len(argv) != 2: def del_service(self, service: str) -> None:
print(f"[-] Usage: admin_script <command>") if service == "":
print(f"[+] Run \"admin_script help\" to get all commands") raise Exception("Service name cannot be empty")
exit(1)
if argv[1] == "help": self.DELETE("/v1/admin/service/del?name=%s" % quote_plus(service))
print("Avaliable commands:")
for k in cmds.keys():
print(f" {k}")
exit()
for k in cmds.keys(): def check_services(self) -> None:
if k != argv[1]: self.GET("/v1/admin/service/check")
continue
try: def add_news(self, news: Dict[str, str]):
cmds[k]() if not "id" in news or news["id"] == "":
except KeyboardInterrupt: raise Exception('News structure is missing required "id" field')
pass
exit() 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__": if __name__ == "__main__":
main() log = Log()
if len(argv) < 2 or len(argv) > 3:
log.error("Usage: %s [command] <file>" % 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)

View File

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

View File

@ -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"
}
}

View File

@ -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"
}

2
api/.gitignore vendored
View File

@ -1,2 +1,2 @@
server *.elf
*.db *.db

View File

@ -1,17 +1,19 @@
FROM golang:1.23.4 FROM golang:1.23.4
WORKDIR /app WORKDIR /api
COPY *.go ./ COPY *.go ./
COPY *.mod ./ COPY *.mod ./
COPY *.sum ./ COPY *.sum ./
COPY Makefile ./ COPY Makefile ./
COPY util ./util
COPY views ./views
COPY routes ./routes COPY routes ./routes
COPY config ./config COPY config ./config
COPY status ./status
COPY database ./database COPY database ./database
COPY util ./util
EXPOSE 7001 EXPOSE 7001
RUN make RUN make
ENTRYPOINT ["/app/server"] ENTRYPOINT ["/api/api.elf"]

View File

@ -1,10 +1,12 @@
all: server GOSRCS = $(wildcard *.go) $(wildcard */*.go)
server: *.go routes/*.go database/*.go util/*.go config/*.go all: api.elf
go build -o $@ .
test: api.elf: $(GOSRCS)
API_FRONTEND_URL=http://localhost:5173/ API_PASSWORD=test ./server go build -o $@
run:
API_DEBUG=true API_FRONTEND_URL=http://localhost:5173/ API_PASSWORD=test ./api.elf
format: format:
gofmt -s -w . gofmt -s -w .

View File

@ -2,59 +2,112 @@ package config
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
"strings"
"github.com/ngn13/website/api/util"
) )
type Option struct { type Type struct {
Name string Options []Option
Value string Count int
Required bool
} }
func (o *Option) Env() string { func (c *Type) Find(name string, typ uint8) (*Option, error) {
return strings.ToUpper(fmt.Sprintf("API_%s", o.Name)) for i := 0; i < c.Count; i++ {
} if c.Options[i].Name != 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 == "" {
continue continue
} }
options[i].Value = val if c.Options[i].Type != typ {
options[i].Required = false return nil, fmt.Errorf("bad option type")
}
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 options[i].Required && options[i].Value != "" { return &c.Options[i], nil
util.Fail("using the default value \"%s\" for required config option \"%s\" (\"%s\")", options[i].Value, options[i].Name, options[i].Env())
}
} }
return true return nil, fmt.Errorf("option not found")
} }
func Get(name string) string { func (c *Type) Load() (err error) {
for i := range options { var (
if options[i].Name != name { env_val string
continue env_name string
} opt *Option
return options[i].Value 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
} }

49
api/config/option.go Normal file
View File

@ -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
}

62
api/database/admin.go Normal file
View File

@ -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
}

View File

@ -1,44 +1,66 @@
package database package database
import ( import (
"fmt"
"database/sql" "database/sql"
_ "github.com/mattn/go-sqlite3"
) )
func Setup(db *sql.DB) error { type Type struct {
_, err := db.Exec(` sql *sql.DB
CREATE TABLE IF NOT EXISTS posts( rows *sql.Rows
id TEXT NOT NULL UNIQUE, }
title TEXT NOT NULL,
author TEXT NOT NULL, func (db *Type) Load() (err error) {
date TEXT NOT NULL, if db.sql, err = sql.Open("sqlite3", "data.db"); err != nil {
content TEXT NOT NULL, return fmt.Errorf("cannot access the database: %s", err.Error())
public INTEGER NOT NULL, }
vote INTEGER NOT NULL
); // see database/service.go
`) _, err = db.sql.Exec(`
CREATE TABLE IF NOT EXISTS services(
if err != nil { name TEXT NOT NULL UNIQUE,
return err desc TEXT NOT NULL,
} check_time INTEGER NOT NULL,
check_res INTEGER NOT NULL,
_, err = db.Exec(` check_url TEXT NOT NULL,
CREATE TABLE IF NOT EXISTS services( clear TEXT,
name TEXT NOT NULL UNIQUE, onion TEXT,
desc TEXT NOT NULL, i2p TEXT
url TEXT NOT NULL );
); `)
`)
if err != nil {
if err != nil { return fmt.Errorf("failed to create the services table: %s", err.Error())
return err }
}
// see database/news.go
_, err = db.Exec(` _, err = db.sql.Exec(`
CREATE TABLE IF NOT EXISTS votes( CREATE TABLE IF NOT EXISTS news(
hash TEXT NOT NULL UNIQUE, id TEXT NOT NULL UNIQUE,
is_upvote INTEGER NOT NULL title TEXT NOT NULL,
); author TEXT NOT NULL,
`) time INTEGER NOT NULL,
content TEXT NOT NULL
return err );
`)
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
} }

58
api/database/multilang.go Normal file
View File

@ -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)
}

116
api/database/news.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -2,54 +2,127 @@ package database
import ( import (
"database/sql" "database/sql"
"fmt"
"github.com/ngn13/website/api/util"
) )
type Service struct { type Service struct {
Name string `json:"name"` Name string `json:"name"` // name of the service
Desc string `json:"desc"` desc string `json:"-"` // description of the service (string)
Url string `json:"url"` 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 { func (s *Service) Load() error {
return rows.Scan(&s.Name, &s.Desc, &s.Url) 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 ( var (
success bool row *sql.Row
rows *sql.Rows s Service
err error err error
) )
if rows, err = db.Query("SELECT * FROM services WHERE name = ?", name); err != nil { if row = db.sql.QueryRow("SELECT * FROM services WHERE name = ?", name); row == nil || row.Err() == sql.ErrNoRows {
return false, err return nil, nil
}
defer rows.Close()
if success = rows.Next(); !success {
return false, nil
} }
if err = s.Load(rows); err != nil { if err = s.Scan(nil, row); err != nil {
return false, err return nil, err
} }
return true, nil return &s, nil
} }
func (s *Service) Remove(db *sql.DB) error { func (db *Type) ServiceRemove(name string) error {
_, err := db.Exec( _, err := db.sql.Exec(
"DELETE FROM services WHERE name = ?", "DELETE FROM services WHERE name = ?",
s.Name, name,
) )
return err return err
} }
func (s *Service) Save(db *sql.DB) error { func (db *Type) ServiceUpdate(s *Service) (err error) {
_, err := db.Exec( if err = s.Dump(); err != nil {
"INSERT INTO services(name, desc, url) values(?, ?, ?)", return err
s.Name, s.Desc, s.Url, }
_, 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 return err

View File

@ -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
}

View File

@ -2,11 +2,7 @@ module github.com/ngn13/website/api
go 1.21.3 go 1.21.3
require ( require github.com/gofiber/fiber/v2 v2.52.5
github.com/gofiber/fiber/v2 v2.52.5
github.com/gorilla/feeds v1.2.0
github.com/mattn/go-sqlite3 v1.14.24
)
require ( require (
github.com/andybalholm/brotli v1.0.5 // indirect 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-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // 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/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/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect

View File

@ -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/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 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 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= 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/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 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=

View File

@ -1,102 +1,124 @@
package main 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 <https://www.gnu.org/licenses/>.
*/
import ( import (
"database/sql" "net/http"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/ngn13/website/api/config" "github.com/ngn13/website/api/config"
"github.com/ngn13/website/api/database" "github.com/ngn13/website/api/database"
"github.com/ngn13/website/api/routes" "github.com/ngn13/website/api/routes"
"github.com/ngn13/website/api/status"
"github.com/ngn13/website/api/util" "github.com/ngn13/website/api/util"
) )
var db *sql.DB
func main() { func main() {
var ( var (
app *fiber.App app *fiber.App
//db *sql.DB stat status.Type
conf config.Type
db database.Type
err error err error
) )
if !config.Load() { if err = conf.Load(); err != nil {
util.Fail("failed to load the configuration") util.Fail("failed to load the configuration: %s", err.Error())
return return
} }
if db, err = sql.Open("sqlite3", "data.db"); err != nil { if !conf.GetBool("debug") {
util.Fail("cannot access the database: %s", err.Error()) util.Debg = func(m string, v ...any) {}
}
if err = db.Load(); err != nil {
util.Fail("failed to load the database: %s", err.Error())
return return
} }
defer db.Close()
if err = database.Setup(db); err != nil { if err = stat.Setup(&conf, &db); err != nil {
util.Fail("cannot setup the database: %s", err.Error()) util.Fail("failed to setup the status checker: %s", err.Error())
return return
} }
app = fiber.New(fiber.Config{ app = fiber.New(fiber.Config{
AppName: "ngn's website",
DisableStartupMessage: true, DisableStartupMessage: true,
ServerHeader: "",
}) })
app.Use("*", func(c *fiber.Ctx) error { app.Use("*", func(c *fiber.Ctx) error {
// CORS stuff
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
c.Set("Access-Control-Allow-Headers", c.Set("Access-Control-Allow-Headers",
"Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") "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) c.Locals("database", &db)
return c.Next() return c.Next()
}) })
// index route // index route
app.Get("/", func(c *fiber.Ctx) error { app.Get("/", routes.GET_Index)
return c.Send([]byte("o/"))
})
// blog routes // version groups
blog_routes := app.Group("/blog") v1 := app.Group("v1")
// blog feed routes // v1 user routes
blog_routes.Get("/feed.*", routes.GET_Feed) v1.Get("/services", routes.GET_Services)
v1.Get("/news/:lang", routes.GET_News)
// blog post routes // v1 admin routes
blog_routes.Get("/sum", routes.GET_PostSum) v1.Use("/admin", routes.AuthMiddleware)
blog_routes.Get("/get", routes.GET_Post) v1.Get("/admin/logs", routes.GET_AdminLogs)
v1.Get("/admin/service/check", routes.GET_CheckService)
// blog post vote routes v1.Put("/admin/service/add", routes.PUT_AddService)
blog_routes.Get("/vote/set", routes.GET_VoteSet) v1.Delete("/admin/service/del", routes.DEL_DelService)
blog_routes.Get("/vote/get", routes.GET_VoteGet) v1.Put("/admin/news/add", routes.PUT_AddNews)
v1.Delete("/admin/news/del", routes.DEL_DelNews)
// 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)
// 404 route // 404 route
app.All("*", func(c *fiber.Ctx) error { 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") // start the status checker
if err = stat.Run(); err != nil {
if err = app.Listen("0.0.0.0:7001"); err != nil { util.Fail("failed to start the status checker: %s", err.Error())
util.Fail("error starting the webserver: %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()
} }

View File

@ -1,183 +1,154 @@
package routes package routes
import ( import (
"database/sql" "fmt"
"net/http"
"strings"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/mattn/go-sqlite3"
"github.com/ngn13/website/api/config" "github.com/ngn13/website/api/config"
"github.com/ngn13/website/api/database" "github.com/ngn13/website/api/database"
"github.com/ngn13/website/api/status"
"github.com/ngn13/website/api/util" "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 { func AuthMiddleware(c *fiber.Ctx) error {
if c.Path() == "/admin/login" { conf := c.Locals("config").(*config.Type)
return c.Next()
}
if c.Get("Authorization") != Token { if c.Get("Authorization") != conf.GetStr("password") {
return util.ErrAuth(c) return util.ErrAuth(c)
} }
return c.Next() return c.Next()
} }
func GET_Login(c *fiber.Ctx) error { func GET_AdminLogs(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 {
var ( var (
db *sql.DB list []database.AdminLog
service database.Service log database.AdminLog
name string
found bool
err error
) )
db = *(c.Locals("database").(**sql.DB)) db := c.Locals("database").(*database.Type)
name = c.Query("name")
if name == "" { for db.AdminLogNext(&log) {
util.ErrBadData(c) list = append(list, log)
} }
if found, err = service.Get(db, name); err != nil { return util.JSON(c, 200, fiber.Map{
util.Fail("error while searching for a service (\"%s\"): %s", name, err.Error()) "result": list,
return util.ErrServer(c) })
}
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 { if err = admin_log(c, fmt.Sprintf("Removed service \"%s\"", name)); err != nil {
return util.ErrEntryNotExists(c) return util.ErrInternal(c, err)
} }
if err = service.Remove(db); err != nil { if err = db.ServiceRemove(name); err != nil {
util.Fail("error while removing a service (\"%s\"): %s", service.Name, err.Error()) return util.ErrInternal(c, err)
return util.ErrServer(c)
} }
return util.NoError(c) return util.JSON(c, 200, nil)
} }
func PUT_AddService(c *fiber.Ctx) error { func PUT_AddService(c *fiber.Ctx) error {
var ( var (
service database.Service service database.Service
db *sql.DB
found bool
err error err error
) )
db = *(c.Locals("database").(**sql.DB)) db := c.Locals("database").(*database.Type)
if c.BodyParser(&service) != nil { if c.BodyParser(&service) != nil {
return util.ErrBadJSON(c) return util.ErrBadJSON(c)
} }
if service.Name == "" || service.Desc == "" || service.Url == "" { if !service.IsValid() {
return util.ErrBadData(c) return util.ErrBadReq(c)
} }
if found, err = service.Get(db, service.Name); err != nil { if err = admin_log(c, fmt.Sprintf("Added service \"%s\"", service.Name)); err != nil {
util.Fail("error while searching for a service (\"%s\"): %s", service.Name, err.Error()) return util.ErrInternal(c, err)
return util.ErrServer(c)
} }
if found { if err = db.ServiceUpdate(&service); err != nil {
return util.ErrEntryExists(c) return util.ErrInternal(c, err)
} }
if err = service.Save(db); err != nil { // force a status check so we can get the status of the new service
util.Fail("error while saving a new service (\"%s\"): %s", service.Name, err.Error()) c.Locals("status").(*status.Type).Check()
return util.ErrServer(c)
}
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 ( var (
db *sql.DB id string
id string err error
found bool
err error
post database.Post
) )
db = *(c.Locals("database").(**sql.DB)) db := c.Locals("database").(*database.Type)
if id = c.Query("id"); id == "" { if id = c.Query("id"); id == "" {
return util.ErrBadData(c) util.ErrBadReq(c)
} }
if found, err = post.Get(db, id); err != nil { if err = admin_log(c, fmt.Sprintf("Removed news \"%s\"", id)); err != nil {
util.Fail("error while searching for a post (\"%s\"): %s", id, err.Error()) return util.ErrInternal(c, err)
return util.ErrServer(c)
} }
if !found { if err = db.NewsRemove(id); err != nil {
return util.ErrEntryNotExists(c) return util.ErrInternal(c, err)
} }
if err = post.Remove(db); err != nil { return util.JSON(c, 200, nil)
util.Fail("error while removing a post (\"%s\"): %s", post.ID, err.Error())
return util.ErrServer(c)
}
return util.NoError(c)
} }
func PUT_AddPost(c *fiber.Ctx) error { func PUT_AddNews(c *fiber.Ctx) error {
var ( var (
db *sql.DB news database.News
post database.Post
err error err error
) )
db = *(c.Locals("database").(**sql.DB)) db := c.Locals("database").(*database.Type)
post.Public = 1
if c.BodyParser(&post) != nil { if c.BodyParser(&news) != nil {
return util.ErrBadJSON(c) return util.ErrBadJSON(c)
} }
if post.Title == "" || post.Author == "" || post.Content == "" { if !news.IsValid() {
return util.ErrBadData(c) return util.ErrBadReq(c)
} }
post.Date = time.Now().Format("02/01/06") if err = admin_log(c, fmt.Sprintf("Added news \"%s\"", news.ID)); err != nil {
return util.ErrInternal(c, err)
if err = post.Save(db); err != nil && strings.Contains(err.Error(), sqlite3.ErrConstraintUnique.Error()) {
return util.ErrEntryExists(c)
} }
if err != nil { if err = db.NewsAdd(&news); err != nil {
util.Fail("error while saving a new post (\"%s\"): %s", post.ID, err.Error()) return util.ErrInternal(c, err)
return util.ErrServer(c)
} }
return util.NoError(c) return util.JSON(c, 200, nil)
} }

View File

@ -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))
}

32
api/routes/index.go Normal file
View File

@ -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)
}

47
api/routes/news.go Normal file
View File

@ -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)
}

View File

@ -1,8 +1,6 @@
package routes package routes
import ( import (
"database/sql"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/ngn13/website/api/database" "github.com/ngn13/website/api/database"
"github.com/ngn13/website/api/util" "github.com/ngn13/website/api/util"
@ -11,32 +9,29 @@ import (
func GET_Services(c *fiber.Ctx) error { func GET_Services(c *fiber.Ctx) error {
var ( var (
services []database.Service services []database.Service
rows *sql.Rows service database.Service
db *sql.DB
err error
) )
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 { if name != "" {
util.Fail("cannot load services: %s", err.Error()) if s, err := db.ServiceFind(name); err != nil {
return util.ErrServer(c) return util.ErrInternal(c, err)
} } else if s != nil {
defer rows.Close() return util.JSON(c, 200, fiber.Map{
"result": s,
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)
} }
return util.ErrNotExist(c)
}
for db.ServiceNext(&service) {
services = append(services, service) services = append(services, service)
} }
return c.JSON(fiber.Map{ return util.JSON(c, 200, fiber.Map{
"error": "",
"result": services, "result": services,
}) })
} }

View File

@ -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)
}

105
api/status/service.go Normal file
View File

@ -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
}

139
api/status/status.go Normal file
View File

@ -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
}
}
}

View File

@ -5,8 +5,17 @@ import (
"os" "os"
) )
var ( const (
Info = log.New(os.Stdout, "\033[34m[info]\033[0m ", log.Ltime|log.Lshortfile).Printf COLOR_BLUE = "\033[34m"
Warn = log.New(os.Stderr, "\033[33m[warn]\033[0m ", log.Ltime|log.Lshortfile).Printf COLOR_YELLOW = "\033[33m"
Fail = log.New(os.Stderr, "\033[31m[fail]\033[0m ", log.Ltime|log.Lshortfile).Printf 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
) )

89
api/util/res.go Normal file
View File

@ -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",
})
}

67
api/util/util.go Normal file
View File

@ -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)))
}

View File

@ -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(""))
}

161
api/views/index.md Normal file
View File

@ -0,0 +1,161 @@
<!-- This is the markdown file that will be served by the index route -->
<style>
* {
font-family: monospace;
}
</style>
# [{{.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.

6
api/views/news.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom">
<title>{{.frontend.Host}} news</title>
<updated>2025-01-02T20:46:24Z</updated>
<subtitle>News and updates about my self-hosted services and projects</subtitle>
<link href="{{.frontend.String}}/news"></link>
</feed>