add projects and metrics routes

This commit is contained in:
ngn 2025-01-09 00:30:59 +03:00
parent dee3ef4d85
commit ac307de76c
32 changed files with 781 additions and 492 deletions

View File

@ -30,8 +30,6 @@ import requests as req
from os import getenv
from sys import argv
API_URL_ENV = "API_URL"
# logger used by the script
class Log:
@ -138,11 +136,32 @@ class AdminAPI:
self.PUT("/v1/admin/service/add", service)
def del_service(self, service: str) -> None:
if service == "":
def del_service(self, name: str) -> None:
if name == "":
raise Exception("Service name cannot be empty")
self.DELETE("/v1/admin/service/del?name=%s" % quote_plus(service))
self.DELETE("/v1/admin/service/del?name=%s" % quote_plus(name))
def add_project(self, project: Dict[str, str]):
if "name" not in project or project["name"] == "":
raise Exception('Project structure is missing required "name" field')
if "desc" not in project:
raise Exception('Project structure is missing required "desc" field')
if not self._check_multilang_field(project["desc"]):
raise Exception(
'Project structure field "desc" needs at least '
+ "one supported language entry"
)
self.PUT("/v1/admin/project/add", project)
def del_project(self, name: str) -> None:
if name == "":
raise Exception("Project name cannot be empty")
self.DELETE("/v1/admin/project/del?name=%s" % quote_plus(name))
def check_services(self) -> None:
self.GET("/v1/admin/service/check")
@ -174,184 +193,212 @@ class AdminAPI:
self.PUT("/v1/admin/news/add", news)
def del_news(self, news: str) -> None:
if news == "":
def del_news(self, id: str) -> None:
if id == "":
raise Exception("News ID cannot be empty")
self.DELETE("/v1/admin/news/del?id=%s" % quote_plus(news))
self.DELETE("/v1/admin/news/del?id=%s" % quote_plus(id))
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:
class AdminScript:
def __init__(self):
self.log: Log = Log()
self.api: AdminAPI = None
self.commands = {
"add_service": self.add_service,
"del_service": self.del_service,
"add_project": self.add_project,
"del_project": self.del_project,
"add_news": self.add_news,
"del_news": self.del_news,
"check_services": self.check_services,
"logs": self.get_logs,
}
self.api_url_env = "API_URL"
def __format_time(self, ts: int) -> str:
return datetime.fromtimestamp(ts, UTC).strftime("%H:%M:%S %d/%m/%Y")
def __load_json_file(file: str) -> Dict[str, Any]:
def __load_json_file(self, 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:
def __dump_json_file(self, 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 lang in api.languages:
data["desc"][lang] = log.input("Serivce desc (%s)" % lang)
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(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 lang in api.languages:
data["title"][lang] = log.input("News title (%s)" % lang)
data["author"] = log.input("News author")
for lang in api.languages:
data["content"][lang] = log.input("News content (%s)" % lang)
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 logs["result"] is None or len(logs["result"]) == 0:
return log.info("No available logs")
for log in logs["result"]:
log.info(
"Time: %s | Action: %s"
% (__format_time(log["time"]), log["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 logs["result"] is None or len(logs["result"]) == 0:
return log.info("No available logs")
__dump_json_file(logs["result"], file)
log.info("Logs has been saved")
commands = [
"add_service",
"del_service",
"check_services",
"add_news",
"del_news",
"logs",
]
if __name__ == "__main__":
log = Log()
def run(self) -> bool:
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)
self.log.error("Usage: %s [command] <file>" % argv[0])
self.log.info("Here is a list of available commands:")
url = getenv(API_URL_ENV)
for command in self.commands.keys():
print("\t%s" % command)
return False
url = getenv(self.api_url_env)
valid_cmd = False
for cmd in commands:
if url is None:
self.log.error(
"Please specify the API URL using %s environment variable"
% self.api_url_env
)
return False
for cmd in self.commands:
if argv[1] == cmd:
valid_cmd = True
break
if not valid_cmd:
log.error(
self.log.error(
"Invalid command, run the script with no commands to list the available commands"
)
exit(1)
if url is None:
log.error(
"Please specify the API URL using %s environment variable" % API_URL_ENV
)
exit(1)
return False
try:
password = log.password("Please enter the admin password")
api = AdminAPI(url, password)
password = self.log.password("Please enter the admin password")
self.api = AdminAPI(url, password)
if len(argv) == 2:
__handle_command(log, api, argv[1])
self.handle_command(argv[1])
elif len(argv) == 3:
__handle_command_with_file(log, api, argv[1], argv[2])
self.handle_command(argv[1], argv[2])
except KeyboardInterrupt:
print()
log.error("Command cancelled")
exit(1)
self.log.error("Command cancelled")
return False
except Exception as e:
log.error("Command failed: %s" % e)
exit(1)
self.log.error("Command failed: %s" % e)
return False
# service commands
def add_service(self, data: Dict[str, Any] = None) -> None:
if data is None:
data: Dict[str, str] = {}
data["desc"] = {}
data["name"] = self.log.input("Serivce name")
for lang in self.api.languages:
data["desc"][lang] = self.log.input("Serivce desc (%s)" % lang)
data["check_url"] = self.log.input("Serivce status check URL")
data["clear"] = self.log.input("Serivce clearnet URL")
data["onion"] = self.log.input("Serivce onion URL")
data["i2p"] = self.log.input("Serivce I2P URL")
self.api.add_service(data)
self.log.info("Service has been added")
def del_service(self, data: Dict[str, Any] = None) -> None:
if data is None:
data: Dict[str, str] = {}
data["name"] = self.log.input("Service name")
self.api.del_service(data["name"])
self.log.info("Service has been deleted")
# project commands
def add_project(self, data: Dict[str, Any] = None) -> None:
if data is None:
data: Dict[str, str] = {}
data["desc"] = {}
data["name"] = self.log.input("Project name")
for lang in self.api.languages:
data["desc"][lang] = self.log.input("Project desc (%s)" % lang)
data["url"] = self.log.input("Project URL")
data["license"] = self.log.input("Project license")
self.api.add_project(data)
self.log.info("Project has been added")
def del_project(self, data: Dict[str, Any] = None) -> None:
if data is None:
data: Dict[str, str] = {}
data["name"] = self.log.input("Project name")
self.api.del_project(data["name"])
self.log.info("Project has been deleted")
# news command
def add_news(self, data: Dict[str, Any] = None) -> None:
if data is None:
news: Dict[str, str] = {}
news["title"] = {}
news["content"] = {}
data["id"] = self.log.input("News ID")
for lang in self.api.languages:
data["title"][lang] = self.log.input("News title (%s)" % lang)
data["author"] = self.log.input("News author")
for lang in self.api.languages:
data["content"][lang] = self.log.input("News content (%s)" % lang)
self.api.add_news(data)
self.log.info("News has been added")
def del_news(self, data: Dict[str, Any] = None) -> None:
if data is None:
data: Dict[str, str] = {}
data["id"] = self.log.input("News ID")
self.api.del_project(data["id"])
self.log.info("News has been deleted")
def check_services(self, data: Dict[str, Any] = None) -> None:
self.api.check_services()
self.log.info("Requested status check for all the services")
def get_logs(self, data: Dict[str, Any] = None) -> None:
logs = self.api.logs()
if logs["result"] is None or len(logs["result"]) == 0:
return self.log.info("No available logs")
for log in logs["result"]:
self.log.info(
"Time: %s | Action: %s"
% (self.__format_time(log["time"]), log["action"])
)
def handle_command(self, cmd: str, file: str = None) -> bool:
for command in self.commands.keys():
if command != cmd:
continue
data = None
try:
if file != "" and file is not None:
data = self.__load_json_file(file)
self.commands[cmd](data)
return True
except Exception as e:
self.log.error("Command failed: %s" % e)
return False
self.log.error("Invalid command: %s", cmd)
return False
if __name__ == "__main__":
script = AdminScript()
exit(script.run() if 1 else 0)

View File

@ -0,0 +1,9 @@
{
"name": "test",
"desc": {
"en": "A non-existent project used to test the API",
"tr": "API'ı test etmek için kullanılan varolmayan bir proje"
},
"url": "https://github.com/ngn13/test",
"license": "GPL-3.0"
}

View File

@ -24,8 +24,8 @@ 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())
if db.rows, err = db.sql.Query("SELECT * FROM " + TABLE_ADMIN_LOG); err != nil {
util.Fail("failed to query table: %s", err.Error())
goto fail
}
}
@ -35,7 +35,7 @@ func (db *Type) AdminLogNext(l *AdminLog) bool {
}
if err = l.Scan(db.rows); err != nil {
util.Fail("failed to scan the admin_log table: %s", err.Error())
util.Fail("failed to scan the table: %s", err.Error())
goto fail
}
@ -52,7 +52,7 @@ fail:
func (db *Type) AdminLogAdd(l *AdminLog) error {
_, err := db.sql.Exec(
`INSERT INTO admin_log(
"INSERT INTO "+TABLE_ADMIN_LOG+`(
action, time
) values(?, ?)`,
&l.Action, &l.Time,

View File

@ -2,76 +2,62 @@ package database
import (
"fmt"
"os"
"path"
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
const (
SQL_PATH = "sql"
TABLE_ADMIN_LOG = "admin_log" // stores administrator logs
TABLE_METRICS = "metrics" // stores API usage metrcis
TABLE_NEWS = "news" // stores news posts
TABLE_SERVICES = "services" // stores services
TABLE_PROJECTS = "projects" // stores projects
)
var tables []string = []string{
TABLE_ADMIN_LOG, TABLE_METRICS, TABLE_NEWS,
TABLE_SERVICES, TABLE_PROJECTS,
}
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())
func (db *Type) create_table(table string) error {
var (
err error
query []byte
)
query_path := path.Join(SQL_PATH, table+".sql")
if query, err = os.ReadFile(query_path); err != nil {
return fmt.Errorf("failed to read %s for table %s: %", query_path, table, err.Error())
}
// see database/visitor.go
_, err = db.sql.Exec(`
CREATE TABLE IF NOT EXISTS visitor_count(
id TEXT NOT NULL UNIQUE,
count INTEGER NOT NULL
);
`)
if err != nil {
return fmt.Errorf("failed to create the visitor_count table: %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())
if _, err = db.sql.Exec(string(query)); err != nil {
return fmt.Errorf("failed to create the %s table: %s", table, err.Error())
}
return nil
}
func (db *Type) Load() (err error) {
if db.sql, err = sql.Open("sqlite3", "data.db"); err != nil {
return fmt.Errorf("failed access the database: %s", err.Error())
}
for _, table := range tables {
if err = db.create_table(table); err != nil {
return err
}
}
return nil

57
api/database/metrics.go Normal file
View File

@ -0,0 +1,57 @@
package database
import (
"database/sql"
"github.com/ngn13/website/api/util"
)
func (db *Type) MetricsGet(key string) (uint64, error) {
var (
row *sql.Row
count uint64
err error
)
if row = db.sql.QueryRow("SELECT value FROM "+TABLE_METRICS+" WHERE key = ?", key); row == nil {
return 0, nil
}
if err = row.Scan(&count); err != nil && err != sql.ErrNoRows {
util.Fail("failed to scan the table: %s", err.Error())
return 0, err
}
if err == sql.ErrNoRows {
return 0, nil
}
return count, nil
}
func (db *Type) MetricsSet(key string, value uint64) error {
var (
err error
res sql.Result
)
if res, err = db.sql.Exec("UPDATE "+TABLE_METRICS+" SET value = ? WHERE key = ?", value, key); err != nil && err != sql.ErrNoRows {
util.Fail("failed to query table: %s", err.Error())
return err
}
if effected, err := res.RowsAffected(); err != nil {
return err
} else if effected < 1 {
_, err = db.sql.Exec(
`INSERT INTO "+TABLE_METRICS+"(
key, value
) values(?, ?)`,
key, value,
)
return err
}
return nil
}

View File

@ -64,8 +64,8 @@ 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())
if db.rows, err = db.sql.Query("SELECT * FROM " + TABLE_NEWS); err != nil {
util.Fail("failed to query table: %s", err.Error())
goto fail
}
}
@ -75,7 +75,7 @@ func (db *Type) NewsNext(n *News) bool {
}
if err = n.Scan(db.rows); err != nil {
util.Fail("failed to scan the news table: %s", err.Error())
util.Fail("failed to scan the table: %s", err.Error())
goto fail
}
@ -92,7 +92,7 @@ fail:
func (db *Type) NewsRemove(id string) error {
_, err := db.sql.Exec(
"DELETE FROM news WHERE id = ?",
"DELETE FROM "+TABLE_NEWS+" WHERE id = ?",
id,
)
@ -105,7 +105,7 @@ func (db *Type) NewsAdd(n *News) (err error) {
}
_, err = db.sql.Exec(
`INSERT OR REPLACE INTO news(
"INSERT OR REPLACE INTO "+TABLE_NEWS+`(
id, title, author, time, content
) values(?, ?, ?, ?, ?)`,
n.ID, n.title,

92
api/database/project.go Normal file
View File

@ -0,0 +1,92 @@
package database
import (
"database/sql"
"github.com/ngn13/website/api/util"
)
type Project struct {
Name string `json:"name"` // name of the project
desc string `json:"-"` // description of the project (string)
Desc Multilang `json:"desc"` // description of the project
URL string `json:"url"` // URL of the project's homepage/source
License string `json:"license"` // name of project's license
}
func (p *Project) Load() error {
return p.Desc.Load(p.desc)
}
func (p *Project) Dump() (err error) {
p.desc, err = p.Desc.Dump()
return
}
func (p *Project) Scan(rows *sql.Rows) (err error) {
if err = rows.Scan(
&p.Name, &p.desc,
&p.URL, &p.License); err != nil {
return err
}
return p.Load()
}
func (p *Project) IsValid() bool {
return p.Name != "" && p.URL != "" && !p.Desc.Empty()
}
func (db *Type) ProjectNext(p *Project) bool {
var err error
if nil == db.rows {
if db.rows, err = db.sql.Query("SELECT * FROM " + TABLE_PROJECTS); err != nil {
util.Fail("failed to query table: %s", err.Error())
goto fail
}
}
if !db.rows.Next() {
goto fail
}
if err = p.Scan(db.rows); err != nil {
util.Fail("failed to scan the table: %s", err.Error())
goto fail
}
return true
fail:
if db.rows != nil {
db.rows.Close()
}
db.rows = nil
return false
}
func (db *Type) ProjectRemove(name string) error {
_, err := db.sql.Exec(
"DELETE FROM "+TABLE_PROJECTS+" WHERE name = ?",
name,
)
return err
}
func (db *Type) ProjectAdd(p *Project) (err error) {
if err = p.Dump(); err != nil {
return err
}
_, err = db.sql.Exec(
"INSERT OR REPLACE INTO "+TABLE_PROJECTS+`(
name, desc, url, license
) values(?, ?, ?, ?)`,
p.Name, p.desc, p.URL, p.License,
)
return err
}

View File

@ -58,8 +58,8 @@ 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())
if db.rows, err = db.sql.Query("SELECT * FROM " + TABLE_SERVICES); err != nil {
util.Fail("failed to query table: %s", err.Error())
goto fail
}
}
@ -69,7 +69,7 @@ func (db *Type) ServiceNext(s *Service) bool {
}
if err = s.Scan(db.rows, nil); err != nil {
util.Fail("failed to scan the services table: %s", err.Error())
util.Fail("failed to scan the table: %s", err.Error())
goto fail
}
@ -91,7 +91,7 @@ func (db *Type) ServiceFind(name string) (*Service, error) {
err error
)
if row = db.sql.QueryRow("SELECT * FROM services WHERE name = ?", name); row == nil || row.Err() == sql.ErrNoRows {
if row = db.sql.QueryRow("SELECT * FROM "+TABLE_SERVICES+" WHERE name = ?", name); row == nil || row.Err() == sql.ErrNoRows {
return nil, nil
}
@ -104,7 +104,7 @@ func (db *Type) ServiceFind(name string) (*Service, error) {
func (db *Type) ServiceRemove(name string) error {
_, err := db.sql.Exec(
"DELETE FROM services WHERE name = ?",
"DELETE FROM "+TABLE_SERVICES+" WHERE name = ?",
name,
)
@ -117,7 +117,7 @@ func (db *Type) ServiceUpdate(s *Service) (err error) {
}
_, err = db.sql.Exec(
`INSERT OR REPLACE INTO services(
"INSERT OR REPLACE INTO "+TABLE_SERVICES+`(
name, desc, check_time, check_res, check_url, clear, onion, i2p
) values(?, ?, ?, ?, ?, ?, ?, ?)`,
s.Name, s.desc,

View File

@ -1,48 +0,0 @@
package database
import (
"database/sql"
)
func (db *Type) VisitorGet() (uint64, error) {
var (
row *sql.Row
count uint64
err error
)
if row = db.sql.QueryRow("SELECT count FROM visitor_count WHERE id = 0"); row == nil {
return 0, nil
}
if err = row.Scan(&count); err != nil && err != sql.ErrNoRows {
return 0, err
}
if err == sql.ErrNoRows {
return 0, nil
}
return count, nil
}
func (db *Type) VisitorIncrement() (err error) {
if _, err = db.sql.Exec("UPDATE visitor_count SET count = count + 1 WHERE id = 0"); err != nil && err != sql.ErrNoRows {
return err
}
// TODO: err is always nil even if there is no rows for some reason, check sql.Result instead
if err == sql.ErrNoRows {
_, err = db.sql.Exec(
`INSERT INTO visitor_count(
id, count
) values(?, ?)`,
0, 0,
)
return err
}
return nil
}

View File

@ -89,15 +89,21 @@ func main() {
// v1 user routes
v1.Get("/services", routes.GET_Services)
v1.Get("/visitor", routes.GET_Visitor)
v1.Get("/projects", routes.GET_Projects)
v1.Get("/metrics", routes.GET_Metrics)
v1.Get("/news/:lang", routes.GET_News)
// 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/project/add", routes.PUT_AddProject)
v1.Delete("/admin/project/del", routes.DEL_DelProject)
v1.Put("/admin/news/add", routes.PUT_AddNews)
v1.Delete("/admin/news/del", routes.DEL_DelNews)

View File

@ -103,6 +103,56 @@ func GET_CheckService(c *fiber.Ctx) error {
return util.JSON(c, 200, nil)
}
func PUT_AddProject(c *fiber.Ctx) error {
var (
project database.Project
err error
)
db := c.Locals("database").(*database.Type)
if c.BodyParser(&project) != nil {
return util.ErrBadJSON(c)
}
if !project.IsValid() {
return util.ErrBadReq(c)
}
if err = admin_log(c, fmt.Sprintf("Added project \"%s\"", project.Name)); err != nil {
return util.ErrInternal(c, err)
}
if err = db.ProjectAdd(&project); err != nil {
return util.ErrInternal(c, err)
}
return util.JSON(c, 200, nil)
}
func DEL_DelProject(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 err = admin_log(c, fmt.Sprintf("Removed project \"%s\"", name)); err != nil {
return util.ErrInternal(c, err)
}
if err = db.ProjectRemove(name); err != nil {
return util.ErrInternal(c, err)
}
return util.JSON(c, 200, nil)
}
func DEL_DelNews(c *fiber.Ctx) error {
var (
id string

77
api/routes/metrics.go Normal file
View File

@ -0,0 +1,77 @@
package routes
import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/ngn13/website/api/database"
"github.com/ngn13/website/api/util"
)
type visitor_cache_entry struct {
Addr string // SHA1 hash of visitor's IP
Number uint64 // number of the visitor
}
const VISITOR_CACHE_MAX = 30 // store 30 visitor data at most
var visitor_cache []visitor_cache_entry // in memory cache for the visitor
func GET_Metrics(c *fiber.Ctx) error {
var (
err error
result map[string]uint64 = map[string]uint64{
"number": 0, // visitor number of the current visitor
"total": 0, // total number of visitors
"since": 0, // metric collection start date (UNIX timestamp)
}
)
db := c.Locals("database").(*database.Type)
new_addr := util.GetSHA1(util.IP(c))
for i := range visitor_cache {
if new_addr == visitor_cache[i].Addr {
result["number"] = visitor_cache[i].Number
break
}
}
if result["total"], err = db.MetricsGet("visitor_count"); err != nil {
return util.ErrInternal(c, err)
}
if result["number"] == 0 {
result["total"]++
result["number"] = result["total"]
if len(visitor_cache) > VISITOR_CACHE_MAX {
util.Debg("visitor cache is full, removing the oldest entry")
visitor_cache = visitor_cache[1:]
}
visitor_cache = append(visitor_cache, visitor_cache_entry{
Addr: new_addr,
Number: result["number"],
})
if err = db.MetricsSet("visitor_count", result["total"]); err != nil {
return util.ErrInternal(c, err)
}
}
if result["since"], err = db.MetricsGet("start_date"); err != nil {
return util.ErrInternal(c, err)
}
if result["since"] == 0 {
result["since"] = uint64(time.Now().Truncate(24 * time.Hour).Unix())
if err = db.MetricsSet("since", result["since"]); err != nil {
return util.ErrInternal(c, err)
}
}
return util.JSON(c, 200, fiber.Map{
"result": result,
})
}

24
api/routes/projects.go Normal file
View File

@ -0,0 +1,24 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/ngn13/website/api/database"
"github.com/ngn13/website/api/util"
)
func GET_Projects(c *fiber.Ctx) error {
var (
projects []database.Project
project database.Project
)
db := c.Locals("database").(*database.Type)
for db.ProjectNext(&project) {
projects = append(projects, project)
}
return util.JSON(c, 200, fiber.Map{
"result": projects,
})
}

View File

@ -1,50 +0,0 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/ngn13/website/api/database"
"github.com/ngn13/website/api/util"
)
const LAST_ADDRS_MAX = 30
var last_addrs []string
func GET_Visitor(c *fiber.Ctx) error {
var (
err error
count uint64
)
db := c.Locals("database").(*database.Type)
new_addr := util.GetSHA1(util.IP(c))
for _, addr := range last_addrs {
if new_addr == addr {
if count, err = db.VisitorGet(); err != nil {
return util.ErrInternal(c, err)
}
return util.JSON(c, 200, fiber.Map{
"result": count,
})
}
}
if err = db.VisitorIncrement(); err != nil {
return util.ErrInternal(c, err)
}
if count, err = db.VisitorGet(); err != nil {
return util.ErrInternal(c, err)
}
if len(last_addrs) > LAST_ADDRS_MAX {
last_addrs = append(last_addrs[:0], last_addrs[1:]...)
last_addrs = append(last_addrs, new_addr)
}
return util.JSON(c, 200, fiber.Map{
"result": count,
})
}

4
api/sql/admin_log.sql Normal file
View File

@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS admin_log(
action TEXT NOT NULL,
time INTEGER NOT NULL
);

4
api/sql/metrics.sql Normal file
View File

@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS metrics(
key TEXT NOT NULL UNIQUE,
value INTEGER NOT NULL
);

7
api/sql/news.sql Normal file
View File

@ -0,0 +1,7 @@
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
);

6
api/sql/projects.sql Normal file
View File

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS projects(
name TEXT NOT NULL UNIQUE,
desc TEXT NOT NULL,
url TEXT NOT NULL,
license TEXT
);

10
api/sql/services.sql Normal file
View File

@ -0,0 +1,10 @@
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
);

View File

@ -97,6 +97,23 @@ a URL query named "name".
Returns a Atom feed of news for the given language. Supports languages that are supported
by Multilang.
### GET /v1/metrics
Returns metrics about the API usage. The metric data has the following format:
```
{
"number":8,
"since":1736294400,
"total":8
}
```
Where:
- `number`: Visitor number of the the current visitor (integer)
- `since`: Metric collection start date (integer, UNIX timestamp)
- `total`: Total number of visitors (integer)
Note that visitor number may change after a certain amount of requests by other clients,
if the client wants to preserve it's visitor number, it should save it somewhere.
### GET /v1/admin/logs
Returns a list of administrator logs. Each log has the following JSON format:
```

View File

@ -1,7 +0,0 @@
import { locale } from "svelte-i18n";
export const handle = async ({ event, resolve }) => {
const lang = event.request.headers.get("accept-language")?.split(",")[0];
if (lang) locale.set(lang);
return resolve(event);
};

View File

@ -22,12 +22,16 @@ async function GET(fetch, url) {
return json["result"];
}
async function visitor(fetch) {
return GET(fetch, api_url("/visitor"));
async function get_metrics(fetch) {
return GET(fetch, api_url("/metrics"));
}
async function services(fetch) {
async function get_services(fetch) {
return GET(fetch, api_url("/services"));
}
export { version, api_url, visitor, services };
async function get_projects(fetch) {
return GET(fetch, api_url("/projects"));
}
export { version, api_url, get_metrics, get_services, get_projects };

View File

@ -1,74 +0,0 @@
<script>
import { click } from "$lib/util.js";
export let title;
export let url;
let current = "";
let i = 0;
while (title.length > i) {
let c = title[i];
setTimeout(
() => {
current += c;
},
100 * (i + 1)
);
i += 1;
}
</script>
<a on:click={click} data-sveltekit-preload-data href={url}>
<div class="title">
{current}
</div>
<div class="content">
<slot></slot>
</div>
</a>
<style>
a {
display: flex;
flex-direction: column;
width: 100%;
background: var(--dark-three);
box-shadow: var(--box-shadow);
border-radius: var(--radius);
cursor: pointer;
transition: 0.4s;
text-decoration: none;
border: solid 1px var(--border-color);
}
a:hover > .title {
text-shadow: var(--text-shadow);
}
.title {
border: solid 1px var(--dark-two);
background: var(--dark-two);
padding: 25px;
border-radius: 7px 7px 0px 0px;
font-size: 20px;
font-family:
Consolas,
Monaco,
Lucida Console,
Liberation Mono,
DejaVu Sans Mono,
Bitstream Vera Sans Mono,
Courier New,
monospace;
color: white;
}
.content {
background: var(--dark-three);
padding: 30px;
padding-top: 30px;
color: white;
border-radius: 5px;
font-size: 25px;
}
</style>

View File

@ -1,14 +1,15 @@
<script>
import { color, date_from_ts } from "$lib/util.js";
import { get_metrics } from "$lib/api.js";
import Link from "$lib/link.svelte";
import { onMount } from "svelte";
import { visitor } from "$lib/api.js";
import { color } from "$lib/util.js";
import { _ } from "svelte-i18n";
let visitor_count = 0;
let data = {};
onMount(async () => {
visitor_count = await visitor(fetch);
data = await get_metrics(fetch);
});
</script>
@ -33,8 +34,13 @@
</div>
<div class="useless">
<span>
{$_("footer.number", { values: { count: visitor_count } })}
{#if visitor_count % 1000 == 0}
{$_("footer.number", {
values: {
number: data.number,
since: date_from_ts(data.since),
},
})}
{#if data.number % 1000 == 0}
<span style="color: var(--{color()})">({$_("footer.congrat")})</span>
{/if}
</span>

View File

@ -1,6 +1,7 @@
<script>
import { locale } from "svelte-i18n";
import { language, set_lang } from "$lib/util.js";
import languages from "$lib/lang.js";
let icon = "",
indx = 0,
len = languages.length;
@ -9,18 +10,20 @@
if (indx >= languages.length) indx = 0;
icon = languages[indx].icon;
locale.set(languages[indx++].code);
set_lang(languages[indx++].code);
}
for (indx = 0; indx < len; indx++) {
if (languages[indx].code == $locale.slice(0, 2)) {
if (languages[indx].code == $language) {
icon = languages[indx++].icon;
break;
}
}
</script>
<button on:click={next}>{icon}</button>
<button on:click={next}>
{icon}
</button>
<style>
button {

View File

@ -2,8 +2,8 @@
import Icon from "$lib/icon.svelte";
import Link from "$lib/link.svelte";
import { color, time_from_ts } from "$lib/util.js";
import { _, locale } from "svelte-i18n";
import { color, time_from_ts, language } from "$lib/util.js";
import { _ } from "svelte-i18n";
export let service = {};
let style = "";
@ -15,7 +15,7 @@
<div class="info">
<div class="title">
<h1>{service.name}</h1>
<p>{service.desc[$locale.slice(0, 2)]}</p>
<p>{service.desc[$language]}</p>
</div>
<div class="links">
<Link highlight={false} link={service.clear}><Icon icon="nf-oct-link" /></Link>

View File

@ -1,6 +1,9 @@
import { browser } from "$app/environment";
import { locale } from "svelte-i18n";
import languages from "$lib/lang.js";
import { writable, get } from "svelte/store";
const default_lang = "en";
const default_language = languages[0].code;
const colors = [
"yellow",
"cyan",
@ -10,8 +13,33 @@ const colors = [
// "blue" (looks kinda ass)
];
let language = writable(default_language);
let colors_pos = -1;
function browser_lang() {
if (browser) return window.navigator.language.slice(0, 2).toLowerCase();
else return get(language);
}
function set_lang(lang) {
language.set(default_language);
if (lang === null || lang === undefined) {
if (browser) set_lang(browser_lang());
return;
}
lang = lang.slice(0, 2);
for (let i = 0; i < languages.length; i++) {
if (lang === languages[i].code) {
language.set(lang);
locale.set(lang);
return;
}
}
}
function urljoin(url, path = null, query = {}) {
let url_len = url.length;
@ -42,13 +70,40 @@ function click() {
audio.play();
}
function browser_lang() {
if (browser) return window.navigator.language.slice(0, 2).toLowerCase();
return default_lang;
}
function time_from_ts(ts) {
return new Date(ts * 1000).toLocaleTimeString();
if (ts === 0 || ts === undefined) return;
let ts_date = new Date(ts * 1000);
let ts_zone = ts_date.toString().match(/([A-Z]+[\+-][0-9]+)/)[1];
return (
new Intl.DateTimeFormat(browser_lang(), {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(ts_date) + ` (${ts_zone})`
);
}
export { urljoin, frontend_url, browser_lang, click, color, time_from_ts };
function date_from_ts(ts) {
if (ts === 0 || ts === undefined) return;
return new Intl.DateTimeFormat(browser_lang(), {
month: "2-digit",
year: "2-digit",
day: "2-digit",
}).format(new Date(ts * 1000));
}
export {
default_language,
browser_lang,
language,
set_lang,
urljoin,
frontend_url,
click,
color,
time_from_ts,
date_from_ts,
};

View File

@ -18,6 +18,7 @@
"title": "work",
"desc": "I don't currently have a job, so I spend most of my time...",
"build": "building stupid shit",
"fix": "fixing stupid shit",
"ctf": "solving CTF challenges",
"contribute": "contributing to random projects",
"wiki": "expanding my wiki"
@ -25,16 +26,20 @@
"links": {
"title": "contact",
"desc": "Here are some useful links if you want to get in contact with me",
"prefer": "preferred"
"prefer": "I highly prefer email, you can send encrypted emails using my PGP key"
},
"info": {
"services": {
"title": "services",
"desc": "A part from working on stupid shit, I host free (as in freedom, and price) services available for all",
"speed": "All of these services are available over a 600 Mbit/s interface",
"security": "All use SSL encrypted connection and they are all privacy-respecting",
"privacy": "Accessible from clearnet, TOR and I2P, no region or network blocks",
"bullshit": "No CDNs, no cloudflare, no CAPTCHA, no analytics, no bullshit",
"link": "see all the services"
"link": "Check them out!"
},
"projects": {
"title": "projects",
"desc": "I mostly work on free software projects, here are some of projects that you might find interesting"
}
},
"services": {
@ -68,7 +73,7 @@
"license": "License",
"privacy": "Privacy",
"powered": "Powered by Svelte, Go, SQLite and donations",
"number": "You are the visitor number {count}",
"number": "You are the visitor number {number} since {since}",
"congrat": "congrats!!",
"version": "Using API version {api_version}, frontend version {frontend_version}"
}

View File

@ -39,7 +39,7 @@
"license": "Lisans",
"privacy": "Gizlilik",
"powered": "Svelte, Go, SQLite ve yemek param tarafından destekleniyor",
"number": "{count}. ziyaretçisiniz",
"number": "{since} tarihinden beri {number}. ziyaretçisiniz",
"congrat": "tebrikler!!",
"version": "Kullan API versiyonu {api_version}, arayüz versiyonu {frontend_version}"
}

View File

@ -1,15 +1,20 @@
import { init, register, waitLocale } from "svelte-i18n";
import { browser_lang } from "$lib/util.js";
import { services } from "$lib/api.js";
import { default_language, language, set_lang } from "$lib/util.js";
import { get_services, get_projects } from "$lib/api.js";
import languages from "$lib/lang.js";
import { init, register, waitLocale } from "svelte-i18n";
import { get } from "svelte/store";
// setup the locale
for (let i = 0; i < languages.length; i++)
register(languages[i].code, () => import(/* @vite-ignore */ languages[i].path));
// set the language
set_lang();
init({
fallbackLocale: languages[0].code,
initialLocale: browser_lang(),
fallbackLocale: default_language,
initialLocale: get(language),
});
// load locales & load data from the API
@ -18,7 +23,8 @@ export async function load({ fetch }) {
try {
return {
services: await services(fetch),
services: await get_services(fetch),
projects: await get_projects(fetch),
error: null,
};
} catch (err) {

View File

@ -4,8 +4,11 @@
import Card from "$lib/card.svelte";
import Link from "$lib/link.svelte";
import { color } from "$lib/util.js";
import { color, language } from "$lib/util.js";
import { _ } from "svelte-i18n";
const { data } = $props();
let projects = $state(data.projects);
</script>
<Head title="home" desc="home page of my personal website" />
@ -24,6 +27,7 @@
<span>{$_("home.work.desc")}</span>
<ul>
<li>⌨️ {$_("home.work.build")}</li>
<li>🤦 {$_("home.work.fix")}</li>
<li>🚩 {$_("home.work.ctf")}</li>
<li>👥 {$_("home.work.contribute")}</li>
<li>📑 {$_("home.work.wiki")}</li>
@ -37,44 +41,53 @@
PGP
</Link>
</li>
<li>
<Link icon="nf-md-email" link="mailto:ngn@ngn.tf">Email</Link>
</li>
<li>
<Link icon="nf-md-mastodon" link="https://defcon.social/@ngn">Mastodon</Link>
</li>
<li>
<Link icon="nf-cod-github" link="https://github.com/ngn13">Github</Link>
</li>
<li>
<Link icon="nf-md-email" link="mailto:ngn@ngn.tf">Email</Link>
<span class="prefer">({$_("home.links.prefer")})</span>
</li>
</ul>
</Card>
<Card title={$_("home.info.title")}>
<div class="services">
<div class="info">
<span>
{$_("home.info.desc")}
{$_("home.links.prefer")}
</span>
</Card>
<Card title={$_("home.services.title")}>
<span>
{$_("home.services.desc")}
</span>
<ul>
<li>
<i style="color: var(--{color()});" class="nf nf-md-speedometer_slow"></i>
{$_("home.info.speed")}
{$_("home.services.speed")}
</li>
<li>
<i style="color: var(--{color()});" class="nf nf-fa-lock"></i>
{$_("home.info.security")}
{$_("home.services.security")}
</li>
<li>
<i style="color: var(--{color()});" class="nf nf-fa-network_wired"></i>
{$_("home.info.privacy")}
{$_("home.services.privacy")}
</li>
<li>
<i style="color: var(--{color()});" class="nf nf-md-eye_off"></i>
{$_("home.info.bullshit")}
{$_("home.services.bullshit")}
</li>
</ul>
</div>
</div>
<Link linK="/services">{$_("home.services.link")}</Link>
</Card>
<Card title={$_("home.projects.title")}>
<span>
{$_("home.projects.desc")}:
</span>
<ul>
{#each projects as project}
<li>
<Link active={true} link={project.url}>{project.name}</Link>:
{project.desc[$language]}
</li>
{/each}
</ul>
</Card>
</main>
@ -89,25 +102,6 @@
gap: 28px;
}
.prefer {
color: var(--white-2);
font-style: italic;
}
.services {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 28px;
}
.services .info {
display: flex;
flex-direction: column;
}
@media only screen and (max-width: 900px) {
main {
flex-direction: column;

View File

@ -4,24 +4,25 @@
import Link from "$lib/link.svelte";
import Head from "$lib/head.svelte";
import { _, locale } from "svelte-i18n";
import { language } from "$lib/util.js";
import { api_url } from "$lib/api.js";
import { _ } from "svelte-i18n";
let { data } = $props();
let list = $state(data.services);
let services = $state(data.services);
function change(input) {
let value = input.target.value.toLowerCase();
list = [];
services = [];
if (value === "") {
list = data.services;
services = data.services;
return;
}
data.services.forEach((s) => {
if (s.name.toLowerCase().includes(value)) list.push(s);
else if (s.desc[$locale.slice(0, 2)].toLowerCase().includes(value)) list.push(s);
if (s.name.toLowerCase().includes(value)) services.push(s);
else if (s.desc[$language].toLowerCase().includes(value)) services.push(s);
});
}
</script>
@ -33,13 +34,11 @@
<div class="title">
<input oninput={change} type="text" placeholder={$_("services.search")} />
<div>
<Link icon="nf-fa-feed" link={api_url("/news/" + $locale.slice(0, 2))}
>{$_("services.feed")}</Link
>
<Link icon="nf-fa-feed" link={api_url("/news/" + $language)}>{$_("services.feed")}</Link>
</div>
</div>
<div class="services">
{#each list as service}
{#each services as service}
<Service {service} />
{/each}
</div>