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
"""
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 <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
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 <command>")
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] <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

View File

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

View File

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

View File

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

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

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 (
"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

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

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/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=

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}

View File

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

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

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"
)
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
)

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>