From ac307de76c3194e42faca01df59297cfa1b093cb Mon Sep 17 00:00:00 2001 From: ngn Date: Thu, 9 Jan 2025 00:30:59 +0300 Subject: [PATCH] add projects and metrics routes --- admin/admin.py | 335 ++++++++++++++---------- admin/tests/test_project.json | 9 + api/database/{admin.go => admin_log.go} | 8 +- api/database/database.go | 102 ++++---- api/database/metrics.go | 57 ++++ api/database/news.go | 10 +- api/database/project.go | 92 +++++++ api/database/service.go | 12 +- api/database/visitor.go | 48 ---- api/main.go | 8 +- api/routes/admin.go | 50 ++++ api/routes/metrics.go | 77 ++++++ api/routes/projects.go | 24 ++ api/routes/visitor.go | 50 ---- api/sql/admin_log.sql | 4 + api/sql/metrics.sql | 4 + api/sql/news.sql | 7 + api/sql/projects.sql | 6 + api/sql/services.sql | 10 + api/views/index.md | 17 ++ app/src/hooks.server.js | 7 - app/src/lib/api.js | 12 +- app/src/lib/card_link.svelte | 74 ------ app/src/lib/footer.svelte | 18 +- app/src/lib/navbar_switch.svelte | 11 +- app/src/lib/service.svelte | 6 +- app/src/lib/util.js | 71 ++++- app/src/locales/en.json | 13 +- app/src/locales/tr.json | 2 +- app/src/routes/+layout.js | 18 +- app/src/routes/+page.svelte | 92 +++---- app/src/routes/services/+page.svelte | 19 +- 32 files changed, 781 insertions(+), 492 deletions(-) create mode 100644 admin/tests/test_project.json rename api/database/{admin.go => admin_log.go} (79%) create mode 100644 api/database/metrics.go create mode 100644 api/database/project.go delete mode 100644 api/database/visitor.go create mode 100644 api/routes/metrics.go create mode 100644 api/routes/projects.go delete mode 100644 api/routes/visitor.go create mode 100644 api/sql/admin_log.sql create mode 100644 api/sql/metrics.sql create mode 100644 api/sql/news.sql create mode 100644 api/sql/projects.sql create mode 100644 api/sql/services.sql delete mode 100644 app/src/hooks.server.js delete mode 100644 app/src/lib/card_link.svelte diff --git a/admin/admin.py b/admin/admin.py index d5f82be..925a6e3 100644 --- a/admin/admin.py +++ b/admin/admin.py @@ -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: - return datetime.fromtimestamp(ts, UTC).strftime("%H:%M:%S %d/%m/%Y") +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]: - with open(file, "r") as f: - data = loads(f.read()) - return data + 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(self, data: Dict[str, Any], file: str) -> None: + with open(file, "w") as f: + data = dumps(data, indent=2) + f.write(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) + def run(self) -> bool: + if len(argv) < 2 or len(argv) > 3: + self.log.error("Usage: %s [command] " % argv[0]) + self.log.info("Here is a list of available commands:") + for command in self.commands.keys(): + print("\t%s" % command) -# command handlers -def __handle_command(log: Log, api: AdminAPI, cmd: str) -> None: - match cmd: - case "add_service": + return False + + url = getenv(self.api_url_env) + valid_cmd = False + + 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: + self.log.error( + "Invalid command, run the script with no commands to list the available commands" + ) + return False + + try: + password = self.log.password("Please enter the admin password") + self.api = AdminAPI(url, password) + + if len(argv) == 2: + self.handle_command(argv[1]) + + elif len(argv) == 3: + self.handle_command(argv[1], argv[2]) + + except KeyboardInterrupt: + self.log.error("Command cancelled") + return False + + except Exception as e: + 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"] = 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") + data["name"] = self.log.input("Serivce name") - api.add_service(data) - log.info("Service has been added") + for lang in self.api.languages: + data["desc"][lang] = self.log.input("Serivce desc (%s)" % lang) - case "del_service": - api.del_service(log.input("Serivce name")) - log.info("Service has been deleted") + 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") - case "check_services": - api.check_services() - log.info("Requested status check for all the services") + self.api.add_service(data) + self.log.info("Service has been added") - case "add_news": + 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"] = 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) + data["id"] = self.log.input("News ID") - api.add_news(data) - log.info("News has been added") + for lang in self.api.languages: + data["title"][lang] = self.log.input("News title (%s)" % lang) - case "del_news": - api.del_news(log.input("News ID")) - log.info("News has been deleted") + data["author"] = self.log.input("News author") - case "logs": - logs = api.logs() + for lang in self.api.languages: + data["content"][lang] = self.log.input("News content (%s)" % lang) - if logs["result"] is None or len(logs["result"]) == 0: - return log.info("No available logs") + self.api.add_news(data) + self.log.info("News has been added") - for log in logs["result"]: - log.info( - "Time: %s | Action: %s" - % (__format_time(log["time"]), log["action"]) - ) + 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 __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") + def check_services(self, data: Dict[str, Any] = None) -> None: + self.api.check_services() + self.log.info("Requested status check for all the services") - case "del_service": - data = __load_json_file(file) - api.del_service(data["name"]) - log.info("Service has been deleted") + def get_logs(self, data: Dict[str, Any] = None) -> None: + logs = self.api.logs() - case "check_services": - api.check_services() - log.info("Requested status check for all the services") + if logs["result"] is None or len(logs["result"]) == 0: + return self.log.info("No available logs") - case "add_news": - data = __load_json_file(file) - api.add_news(data) - log.info("News has been added") + for log in logs["result"]: + self.log.info( + "Time: %s | Action: %s" + % (self.__format_time(log["time"]), log["action"]) + ) - case "del_news": - data = __load_json_file(file) - api.del_news(data["id"]) - log.info("News has been deleted") + def handle_command(self, cmd: str, file: str = None) -> bool: + for command in self.commands.keys(): + if command != cmd: + continue - case "logs": - logs = api.logs() + data = None - if logs["result"] is None or len(logs["result"]) == 0: - return log.info("No available logs") + try: + if file != "" and file is not None: + data = self.__load_json_file(file) - __dump_json_file(logs["result"], file) - log.info("Logs has been saved") + 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 -commands = [ - "add_service", - "del_service", - "check_services", - "add_news", - "del_news", - "logs", -] if __name__ == "__main__": - log = Log() - - if len(argv) < 2 or len(argv) > 3: - log.error("Usage: %s [command] " % argv[0]) - log.info("Here is a list of available commands:") - print("\tadd_service") - print("\tdel_service") - print("\tcheck_services") - print("\tadd_news") - print("\tdel_news") - print("\tlogs") - exit(1) - - url = getenv(API_URL_ENV) - valid_cmd = False - - for cmd in commands: - if argv[1] == cmd: - valid_cmd = True - break - - if not valid_cmd: - 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) - - 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) + script = AdminScript() + exit(script.run() if 1 else 0) diff --git a/admin/tests/test_project.json b/admin/tests/test_project.json new file mode 100644 index 0000000..bc7d519 --- /dev/null +++ b/admin/tests/test_project.json @@ -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" +} diff --git a/api/database/admin.go b/api/database/admin_log.go similarity index 79% rename from api/database/admin.go rename to api/database/admin_log.go index 1da515f..c80f99b 100644 --- a/api/database/admin.go +++ b/api/database/admin_log.go @@ -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, diff --git a/api/database/database.go b/api/database/database.go index 7607f44..f35f32f 100644 --- a/api/database/database.go +++ b/api/database/database.go @@ -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 diff --git a/api/database/metrics.go b/api/database/metrics.go new file mode 100644 index 0000000..959d4dc --- /dev/null +++ b/api/database/metrics.go @@ -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 +} diff --git a/api/database/news.go b/api/database/news.go index 8385116..10f766f 100644 --- a/api/database/news.go +++ b/api/database/news.go @@ -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, diff --git a/api/database/project.go b/api/database/project.go new file mode 100644 index 0000000..0949a8a --- /dev/null +++ b/api/database/project.go @@ -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 +} diff --git a/api/database/service.go b/api/database/service.go index 34d3b8f..596bc10 100644 --- a/api/database/service.go +++ b/api/database/service.go @@ -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, diff --git a/api/database/visitor.go b/api/database/visitor.go deleted file mode 100644 index 13a937b..0000000 --- a/api/database/visitor.go +++ /dev/null @@ -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 -} diff --git a/api/main.go b/api/main.go index 3c8caff..0d4af96 100644 --- a/api/main.go +++ b/api/main.go @@ -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) diff --git a/api/routes/admin.go b/api/routes/admin.go index c71ecdf..142b516 100644 --- a/api/routes/admin.go +++ b/api/routes/admin.go @@ -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 diff --git a/api/routes/metrics.go b/api/routes/metrics.go new file mode 100644 index 0000000..e7fb3ab --- /dev/null +++ b/api/routes/metrics.go @@ -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, + }) +} diff --git a/api/routes/projects.go b/api/routes/projects.go new file mode 100644 index 0000000..af919ac --- /dev/null +++ b/api/routes/projects.go @@ -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, + }) +} diff --git a/api/routes/visitor.go b/api/routes/visitor.go deleted file mode 100644 index 67865a9..0000000 --- a/api/routes/visitor.go +++ /dev/null @@ -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, - }) -} diff --git a/api/sql/admin_log.sql b/api/sql/admin_log.sql new file mode 100644 index 0000000..6089dbb --- /dev/null +++ b/api/sql/admin_log.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS admin_log( + action TEXT NOT NULL, + time INTEGER NOT NULL +); diff --git a/api/sql/metrics.sql b/api/sql/metrics.sql new file mode 100644 index 0000000..e23a6ef --- /dev/null +++ b/api/sql/metrics.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS metrics( + key TEXT NOT NULL UNIQUE, + value INTEGER NOT NULL +); diff --git a/api/sql/news.sql b/api/sql/news.sql new file mode 100644 index 0000000..dd5a58d --- /dev/null +++ b/api/sql/news.sql @@ -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 +); diff --git a/api/sql/projects.sql b/api/sql/projects.sql new file mode 100644 index 0000000..951dc1b --- /dev/null +++ b/api/sql/projects.sql @@ -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 +); diff --git a/api/sql/services.sql b/api/sql/services.sql new file mode 100644 index 0000000..9823da0 --- /dev/null +++ b/api/sql/services.sql @@ -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 +); diff --git a/api/views/index.md b/api/views/index.md index 2a97bbd..3d52b6a 100644 --- a/api/views/index.md +++ b/api/views/index.md @@ -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: ``` diff --git a/app/src/hooks.server.js b/app/src/hooks.server.js deleted file mode 100644 index 3987d75..0000000 --- a/app/src/hooks.server.js +++ /dev/null @@ -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); -}; diff --git a/app/src/lib/api.js b/app/src/lib/api.js index ed51572..2b9797a 100644 --- a/app/src/lib/api.js +++ b/app/src/lib/api.js @@ -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 }; diff --git a/app/src/lib/card_link.svelte b/app/src/lib/card_link.svelte deleted file mode 100644 index 36a40e3..0000000 --- a/app/src/lib/card_link.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - - -
- {current} -
-
- -
-
- - diff --git a/app/src/lib/footer.svelte b/app/src/lib/footer.svelte index d737d9a..128b17a 100644 --- a/app/src/lib/footer.svelte +++ b/app/src/lib/footer.svelte @@ -1,14 +1,15 @@ @@ -33,8 +34,13 @@
- {$_("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} ({$_("footer.congrat")}) {/if} diff --git a/app/src/lib/navbar_switch.svelte b/app/src/lib/navbar_switch.svelte index 979856d..9eef536 100644 --- a/app/src/lib/navbar_switch.svelte +++ b/app/src/lib/navbar_switch.svelte @@ -1,6 +1,7 @@ - +