Merge pull request #243 from ngn13/rework

Yet another rework
This commit is contained in:
ngn 2025-01-19 20:54:41 +00:00 committed by GitHub
commit 95986d170d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
127 changed files with 8507 additions and 3964 deletions

5
.gitignore vendored
View File

@ -1,2 +1,7 @@
data.db
*.yaml
*.yml
*.env
# don't ignore example deployment stuff
!deploy/*

13
Makefile Normal file
View File

@ -0,0 +1,13 @@
SERVERS = app api doc
all: $(SERVERS)
for server in $^ ; do \
make -C $$server ; \
done
format:
for server in $(SERVERS) ; do \
make -C $$server format ; \
done
.PHONY: format

102
README.md
View File

@ -14,80 +14,72 @@ and fonts from [NerdFonts](https://www.nerdfonts.com/)
### `api`
Contains the API server, written in Go. It uses the [Fiber](https://github.com/gofiber/fiber) web
framework which offers an [Express](https://expressjs.com/) like experience. I choose Fiber since I've used worked with express a lot in the past. However previously the I was using
[Gin](https://github.com/gin-gonic/gin) (see history section).
framework which offers an [Express](https://expressjs.com/) like experience. I choose Fiber since I've used
worked with express a lot in the past. However previously the I was using [Gin](https://github.com/gin-gonic/gin)
(see history section).
API stores all the data in a local sqlite(3) database. Go doesn't support sqlite3 out of the box so
API stores all the data in a local SQLite(3) database. Go doesn't support SQLite3 out of the box so
I'm using [mattn's sqlite3 driver](https://github.com/mattn/go-sqlite3).
### `doc`
Contains the documentation server, written in C. It uses the [ctorm](https://github.com/ngn13/ctorm) web
framework, which is a framework that I myself wrote. Unlike the frontend application or the API server, it's not
accessable by public, the frontend application gets the documentation content from this server and renders it using
SSR. The reason I don't use the API for hosting the documentation content is that I want a separate server for hosting
static content, API is only for hosting dynamic stuff.
### `admin`
The frontend application does not contain an admin interface, I do the administration stuff (such as
adding posts, adding services etc.) using the python script in this directory. This script can be
installed on to the PATH by running the `install.sh` script. After installation it can be used
by running `admin_script`.
The frontend application does not contain an admin interface, I do the administration stuff (such as adding news posts,
adding services etc.) using the python script in this directory. This script can be installed on to the PATH by running
the Makefile install script. After installation it can be used by running `admin_script`.
## Deployment
Easiest way to deploy is to use docker. I have created a `compose.yml` file so the API and the
frontend application can be deployed easily with just the `docker-compose up` command:
```yaml
version: "3"
services:
app:
build:
context: ./app
args:
API_URL: https://api.ngn.tf
ports:
- "127.0.0.1:7002:3000"
depends_on:
- api
api:
build:
context: ./api
environment:
- API_PASSWORD="securepassword"
- API_FRONTEND_URL="https://ngn.tf"
ports:
- "127.0.0.1:7001:7001"
volumes:
- ./api/data.db:/app/data.db
```
Easiest way to deploy is to use docker. There is `compose.yml` and a `run.sh` script in the [deploy](deploy/) directory
that can be used to startup all the docker containers. Configuration options are passed during build time for the frontend
application, and for others it's passed with environment variables.
## History
Some nostalgic history/changelog stuff (just for the major version numbers):
- **v0.1 (late 2020 - early 2021)**: First ever version of my website, it was just a simple HTML/CSS page,
I never published any of the source code and I wiped the local copy on my USB drive in early 2022
I never published any of the source code and I wiped the local copy on my USB drive in early 2022, I still
remember what it looked like though, it looked like I made entire website in microsoft paint... while blindfoled,
so yeah it was shit.
- **v1.0 (early 2021 - late 2022)**: This version was actualy hosted on my github.io page, and all the source code
was (and still is) avaliable, it was just a simple static site, [here is a screenshot](assets/githubio.png)
was (and still is) avaliable, it was just a simple static site, [here is a screenshot](assets/githubio.png).
- **vLOST (late 2022 - early 2023)**: As I learned more JS, I decided to rewrite (and rework)
my website with one of the fancy JS frameworks. I decided to go with Svelte. Not the kit version,
at the time svelte did not support SSR. I do not remember writting an API for it so I guess I just
updated it everytime I wanted to add content? It was pretty much like a static website and was hosted
on `ngn13.fun` as at this point I had my own hosting. The source code for this website was in a
deleted github repository of mine, I looked for a local copy on my old hard drive but I wasn't able
- **vLOST (late 2022 - early 2023)**: As I learned more JS, I decided to rewrite (and rework) my website with one
of the fancy JS frameworks. I decided to go with Svelte. Not the kit version, at the time svelte did not support SSR.
I do not remember writting an API for it so I guess I just updated it everytime I wanted to add content? It was pretty
much like a static website and was hosted on `ngn13.fun` as at this point I had my own hosting. The source code for
this website was in a deleted github repository of mine, I looked for a local copy on my old hard drive but I wasn't able
to find it. I also do not remember how it looked like, sooo this version is pretty much lost :(
- **v2.0 (early 2023 - late 2023)**: After I discovered what SSR is, I decided to rewrite and rework
my website one more time in NuxtJS. I had really "fun time" using vue stuff. As NuxtJS supported
server-side code, this website had its own built in API. This website was also hosted on `ngn13.fun`
- **v2.0 (early 2023 - late 2023)**: After I discovered what SSR is, I decided to rewrite and rework my website one more
time in NuxtJS. I had really "fun" time using vue stuff. As NuxtJS supported server-side code, this website had its own
built in API. This website was also hosted on `ngn13.fun`. This also the first version that lives on this git repository.
- **v3.0 (2023 august - 2023 november)**: In agust of 2023, I decided to rewrite and rework my website
again, this time I was going with SvelteKit as I haven't had the greatest experience with NuxtJS.
SvelteKit was really fun to work with and I got my new website done pretty quickly. (I don't wanna
brag or something but I really imporeved the CSS/styling stuff ya know). I also wrote a new API
with Go and Gin. I did not publish the source code for the API, its still on my local gitea
server tho. This website was hosted on `ngn13.fun` as well
- **v3.0 (2023 august - 2023 november)**: In agust of 2023, I decided to rewrite and rework my website again, this time
I was going with SvelteKit as I haven't had the greatest experience with NuxtJS. SvelteKit was really fun to work with
and I got my new website done pretty quickly. (I don't wanna brag or something but I really imporeved the CSS/styling
stuff ya know). I also wrote a new API with Go and Gin. I did not publish the source code for the API, the code lived
on my local git server until I deleted it when I was done with 6.0. This website was hosted on `ngn13.fun` as well.
- **v4.0 (2023 november - 2024 october)**: In this version the frontend was still similar to 3.0,
the big changes are in the API. I rewrote the API with Fiber. This version was the first version which is hosted on
`ngn.tf` which is my new domain name btw
- **v4.0 (2023 november - 2024 october)**: In this version the frontend was still similar to 3.0, the big changes are in
the API. I rewrote the API with Fiber. This version was the first version hosted on `ngn.tf` which is my new domain name.
- **v5.0 (2024 october - ...)**: The current major version of my website, has small UI and API tweaks when
compared to 4.0
- **v5.0 (2024 october - 2025 january)**: This version just had simple frontend UI changes compared to 4.0, at this
point I was thinking about doing a massive rework (which I did with 6.0), however I was working on some other shit at
the time, so I just did some small changes with the limited time I had for this project.
- **v6.0 (2025 january - ...)**: The current major version of my website, frontend had a massive rework, API has been
cleaned up and extended to do status checking for the services I host. The `doc` server has been added to the mix
so I can host static documentation. The most important thing about this version is that it adds multi-language support,
so literally everything on the website (including the API and documentation content) is localized for both English
and Turkish, which was something I wanted to do for the longest time ever.
Damn it has been 4 years since I wrote that shit HTML page huh? Time flies...
## Screenshots (from v4.0)
![](assets/4.0_index.png)

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,404 @@
#!/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 = ""
def join(pth: str) -> str:
if URL == None:
return ""
# logger used by the script
class Log:
def __init__(self) -> None:
self.reset = Fore.RESET + Style.RESET_ALL
if URL.endswith("/"):
return URL+pth
return URL+"/"+pth
def info(self, m: str) -> None:
print(Fore.BLUE + Style.BRIGHT + "[*]" + self.reset + " " + m)
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 error(self, m: str) -> None:
print(Fore.RED + 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 input(self, m: str) -> str:
return input(Fore.CYAN + Style.BRIGHT + "[?]" + self.reset + " " + m + ": ")
token = res["token"]
f = open("/tmp/wa", "w")
f.write(token)
f.close()
def password(self, m: str) -> str:
return getpass(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
remove("/tmp/wa")
print("[+] Logged out")
# 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
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): ")
def _title_to_id(self, title: str) -> str:
return title.lower().replace(" ", "_")
try:
f = open(content_file, "r")
content = f.read()
f.close()
except:
print("[-] Content file not found")
return
def _check_multilang_field(self, ml: Dict[str, str]) -> bool:
for lang in self.languages:
if lang in ml and ml[lang] != "":
return True
return False
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 _api_url_join(self, path: str) -> str:
api_has_slash = self.api_url.endswith("/")
path_has_slash = path.startswith("/")
if res["error"] != "":
print(f"[-] Error adding post: {res['error']}")
return
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
print("[+] Post has been added")
def _to_json(self, res: req.Response) -> dict:
if res.status_code == 403:
raise Exception("Authentication failed")
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()
json = res.json()
if res["error"] != "":
print(f"[-] Error removing post: {res['error']}")
return
if json["error"] != "":
raise Exception("API error: %s" % json["error"])
print("[-] Post has been removed")
return json
def add_service() -> None:
token = get_token()
name = input("[>] Serivce name: ")
desc = input("[>] Serivce desc: ")
link = input("[>] Serivce URL: ")
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},
)
)
res = req.put(join("admin/service/add"), json={
"name": name,
"desc": desc,
"url": link
}, headers={
"Authorization": token
}).json()
def DELETE(self, url: str) -> req.Response:
return self._to_json(
req.delete(
self._api_url_join(url), headers={"Authorization": self.password}
)
)
if res["error"] != "":
print(f"[-] Error adding service: {res['error']}")
return
def GET(self, url: str) -> req.Response:
return self._to_json(
req.get(self._api_url_join(url), headers={"Authorization": self.password})
)
print("[+] Service has been added")
def add_service(self, service: Dict[str, str]):
if "name" not in service or service["name"] == "":
raise Exception('Service structure is missing required "name" field')
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()
if "desc" not in service:
raise Exception('Service structure is missing required "desc" field')
if res["error"] != "":
print(f"[-] Error removing service: {res['error']}")
return
if (
("clear" not in service or service["clear"] == "")
and ("onion" not in service or service["onion"] == "")
and ("i2p" not in service or service["i2p"] == "")
):
raise Exception(
'Service structure is missing "clear", "onion" '
+ 'and "i2p" field, at least one needed'
)
print("[+] Serivce has been removed")
if not self._check_multilang_field(service["desc"]):
raise Exception(
'Service structure field "desc" needs at least '
+ "one supported language entry"
)
cmds = {
"login": login,
"logout": logout,
"add_post": add_post,
"remove_post": remove_post,
"add_service": add_service,
"remove_service": remove_service,
}
self.PUT("/v1/admin/service/add", service)
def main():
global URL
URL = getenv("API")
if URL == None or URL == "":
print("[-] API enviroment variable not set")
exit(1)
def del_service(self, name: str) -> None:
if name == "":
raise Exception("Service name cannot be empty")
if len(argv) != 2:
print(f"[-] Usage: admin_script <command>")
print(f"[+] Run \"admin_script help\" to get all commands")
exit(1)
self.DELETE("/v1/admin/service/del?name=%s" % quote_plus(name))
if argv[1] == "help":
print("Avaliable commands:")
for k in cmds.keys():
print(f" {k}")
exit()
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")
def add_news(self, news: Dict[str, str]):
if "id" not in news or news["id"] == "":
raise Exception('News structure is missing required "id" field')
if "author" not in news or news["author"] == "":
raise Exception('News structure is missing required "author" field')
if "title" not in news:
raise Exception('News structure is missing required "title" field')
if "content" not 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, id: str) -> None:
if id == "":
raise Exception("News ID cannot be empty")
self.DELETE("/v1/admin/news/del?id=%s" % quote_plus(id))
def logs(self) -> List[Dict[str, Any]]:
return self.GET("/v1/admin/logs")
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(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 run(self) -> bool:
if len(argv) < 2 or len(argv) > 3:
self.log.error("Usage: %s [command] <file>" % argv[0])
self.log.info("Here is a list of available commands:")
for command in self.commands.keys():
print("\t%s" % command)
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
for k in cmds.keys():
if k != argv[1]:
continue
try:
cmds[k]()
except KeyboardInterrupt:
pass
exit()
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"] = 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
print("[-] Command not found")
if __name__ == "__main__":
main()
script = AdminScript()
exit(script.run() if 1 else 0)

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

@ -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,26 @@
FROM golang:1.23.4
WORKDIR /app
WORKDIR /api
RUN useradd runner -r -u 1001 -d /api
RUN chown -R runner:runner /api
USER runner
COPY *.go ./
COPY *.mod ./
COPY *.sum ./
RUN go mod download
COPY *.go ./
COPY Makefile ./
COPY routes ./routes
COPY config ./config
COPY database ./database
COPY routes ./routes
COPY sql ./sql
COPY status ./status
COPY util ./util
COPY views ./views
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:
WEBSITE_DEBUG=true WEBSITE_PASSWORD=test ./api.elf
format:
gofmt -s -w .

View File

@ -2,59 +2,108 @@ 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: "app_url", Value: "http://localhost:7001/", 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:7002", 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("WEBSITE_%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_log.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 " + TABLE_ADMIN_LOG); err != nil {
util.Fail("failed to query 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 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 "+TABLE_ADMIN_LOG+`(
action, time
) values(?, ?)`,
&l.Action, &l.Time,
)
return err
}

View File

@ -1,44 +1,64 @@
package database
import (
"fmt"
"os"
"path"
"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
);
`)
const (
SQL_PATH = "sql"
if err != nil {
return err
}
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
)
_, 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
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) 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())
}
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
}

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).Type().Field(i).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.Time != 0 && 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 " + TABLE_NEWS); err != nil {
util.Fail("failed to query 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 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 "+TABLE_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 "+TABLE_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
}

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

@ -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 " + TABLE_SERVICES); err != nil {
util.Fail("failed to query 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 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 "+TABLE_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(
"DELETE FROM services WHERE name = ?",
s.Name,
func (db *Type) ServiceRemove(name string) error {
_, err := db.sql.Exec(
"DELETE FROM "+TABLE_SERVICES+" WHERE 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 "+TABLE_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

@ -4,7 +4,6 @@ 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
)

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,6 @@ 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/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,131 @@
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("/projects", routes.GET_Projects)
v1.Get("/metrics", routes.GET_Metrics)
v1.Get("/news/:lang", routes.GET_News)
// blog post routes
blog_routes.Get("/sum", routes.GET_PostSum)
blog_routes.Get("/get", routes.GET_Post)
// v1 admin routes
v1.Use("/admin", routes.AuthMiddleware)
v1.Get("/admin/logs", routes.GET_AdminLogs)
// blog post vote routes
blog_routes.Get("/vote/set", routes.GET_VoteSet)
blog_routes.Get("/vote/get", routes.GET_VoteGet)
v1.Get("/admin/service/check", routes.GET_CheckService)
v1.Put("/admin/service/add", routes.PUT_AddService)
v1.Delete("/admin/service/del", routes.DEL_DelService)
// service routes
service_routes := app.Group("services")
service_routes.Get("/all", routes.GET_Services)
v1.Put("/admin/project/add", routes.PUT_AddProject)
v1.Delete("/admin/project/del", routes.DEL_DelProject)
// 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.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,206 @@
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 {
var (
db *sql.DB
id string
found bool
err error
post database.Post
)
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 searching for a post (\"%s\"): %s", id, err.Error())
return util.ErrServer(c)
}
if !found {
return util.ErrEntryNotExists(c)
}
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)
func GET_CheckService(c *fiber.Ctx) error {
c.Locals("status").(*status.Type).Check()
return util.JSON(c, 200, nil)
}
func PUT_AddPost(c *fiber.Ctx) error {
func PUT_AddProject(c *fiber.Ctx) error {
var (
db *sql.DB
post database.Post
err error
project database.Project
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(&project) != nil {
return util.ErrBadJSON(c)
}
if post.Title == "" || post.Author == "" || post.Content == "" {
return util.ErrBadData(c)
if !project.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 project \"%s\"", project.Name)); 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.ProjectAdd(&project); err != nil {
return util.ErrInternal(c, err)
}
return util.NoError(c)
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
err error
)
db := c.Locals("database").(*database.Type)
if id = c.Query("id"); id == "" {
util.ErrBadReq(c)
}
if err = admin_log(c, fmt.Sprintf("Removed news \"%s\"", id)); err != nil {
return util.ErrInternal(c, err)
}
if err = db.NewsRemove(id); err != nil {
return util.ErrInternal(c, err)
}
return util.JSON(c, 200, nil)
}
func PUT_AddNews(c *fiber.Ctx) error {
var (
news database.News
err error
)
db := c.Locals("database").(*database.Type)
if c.BodyParser(&news) != nil {
return util.ErrBadJSON(c)
}
news.Time = uint64(time.Now().Unix())
if !news.IsValid() {
return util.ErrBadReq(c)
}
if err = admin_log(c, fmt.Sprintf("Added news \"%s\"", news.ID)); err != nil {
return util.ErrInternal(c, err)
}
if err = db.NewsAdd(&news); err != nil {
return util.ErrInternal(c, err)
}
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))
}

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

@ -0,0 +1,14 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/ngn13/website/api/config"
)
func GET_Index(c *fiber.Ctx) error {
conf := c.Locals("config").(*config.Type)
app := conf.GetURL("app_url")
// redirect to the API documentation
return c.Redirect(app.JoinPath("/doc/api").String())
}

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

@ -0,0 +1,67 @@
package routes
import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/ngn13/website/api/database"
"github.com/ngn13/website/api/util"
)
const VISITOR_CACHE_MAX = 30 // store 30 visitor data at most
var visitor_cache []string // in memory cache for the visitor addresses
func GET_Metrics(c *fiber.Ctx) error {
var (
err error
result map[string]uint64 = map[string]uint64{
"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))
is_in_cache := false
for _, cache := range visitor_cache {
if new_addr == cache {
is_in_cache = true
break
}
}
if result["total"], err = db.MetricsGet("visitor_count"); err != nil {
return util.ErrInternal(c, err)
}
if !is_in_cache {
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, new_addr)
result["total"]++
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("start_date", result["since"]); err != nil {
return util.ErrInternal(c, err)
}
}
return util.JSON(c, 200, fiber.Map{
"result": result,
})
}

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

@ -0,0 +1,78 @@
package routes
import (
"sort"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/ngn13/website/api/config"
"github.com/ngn13/website/api/database"
"github.com/ngn13/website/api/util"
)
// feed_entry is a temporary struct used to pass the news to the news.xml
type feed_entry struct {
Title string
Author string
Time time.Time
RFC3339 string
Content string
}
// convert UNIX timestamp to RFC3339 (format used by atom feeds)
func (e *feed_entry) From(news *database.News, lang string) {
e.Title = news.Title.Get(lang)
e.Author = news.Author
e.Time = time.Unix(int64(news.Time), 0)
e.RFC3339 = e.Time.Format(time.RFC3339)
e.Content = news.Content.Get(lang)
}
func GET_News(c *fiber.Ctx) error {
var (
entries []feed_entry
news database.News
indx uint64
feed []byte
err error
)
db := c.Locals("database").(*database.Type)
conf := c.Locals("config").(*config.Type)
app := conf.GetURL("app_url")
lang := c.Params("lang")
if lang == "" || len(lang) != 2 {
return util.ErrBadReq(c)
}
lang = strings.ToLower(lang)
indx = 0
for db.NewsNext(&news) {
if news.Supports(lang) {
entries = append(entries, feed_entry{})
entries[indx].From(&news, lang)
indx++
}
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Time.Before(entries[j].Time)
})
if feed, err = util.Render("views/news.xml", fiber.Map{
"updated": time.Now().Format(time.RFC3339),
"entries": entries,
"lang": lang,
"app": app,
}); err != nil {
return util.ErrInternal(c, err)
}
c.Set("Content-Disposition", "attachment; filename=\"news.atom\"")
c.Set("Content-Type", "application/atom+xml; charset=utf-8")
return c.Send(feed)
}

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

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

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
)

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

@ -0,0 +1,79 @@
package util
import (
"net/http"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/ngn13/website/api/config"
)
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 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(""))
}

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

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom">
<title>{{.app.Host}} news</title>
<updated>{{.updated}}</updated>
<subtitle>News and updates about my projects and self-hosted services</subtitle>
<link href="{{.app.JoinPath "/news"}}"></link>
{{ range .entries }}
<entry>
<title>{{.Title}}</title>
<updated>{{.RFC3339}}</updated>
<author>
<name>{{.Author}}</name>
</author>
<content>{{.Content}}</content>
</entry>
{{ end }}
</feed>

View File

@ -1,4 +1,4 @@
node_modules
.svelte-kit
build
public
build

9
app/.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"useTabs": false,
"tabWidth": 2,
"singleQuote": false,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@ -1,23 +1,39 @@
FROM node:23.5.0 as build
# build the application with node
FROM node:23.5.0 AS build
ARG WEBSITE_REPORT_URL
ARG WEBSITE_SOURCE_URL
ARG WEBSITE_APP_URL
ARG WEBSITE_API_URL
ARG WEBSITE_DOC_URL
ENV WEBSITE_REPORT_URL=$WEBSITE_REPORT_URL
ENV WEBSITE_SOURCE_URL=$WEBSITE_SOURCE_URL
ENV WEBSITE_APP_URL=$WEBSITE_APP_URL
ENV WEBSITE_API_URL=$WEBSITE_API_URL
ENV WEBSITE_DOC_URL=$WEBSITE_DOC_URL
WORKDIR /app
COPY . /app
ARG API_URL
ENV VITE_API_URL_DEV $API_URL
RUN npm install && npm run build
FROM oven/bun:1.1.20 as main
# run it with bun (a lot faster)
FROM oven/bun:latest AS main
WORKDIR /app
COPY --from=build /app/build ./build
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/build ./build
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/package-lock.json ./package-lock.json
EXPOSE 4173
RUN useradd runner -r -u 1001 -d /app
RUN chown -R runner:runner /app
USER runner
RUN bun install
EXPOSE 7001
ENV PORT=7001
CMD ["bun", "build/index.js"]

10
app/Makefile Normal file
View File

@ -0,0 +1,10 @@
all:
npm run build
format:
npm run format
run:
npm run dev
.PHONY: format

3806
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,28 @@
{
"name": "website",
"version": "5.0.0",
"private": true,
"scripts": {
"dev": "VITE_API_URL_DEV=http://127.0.0.1:7001 vite dev",
"build": "vite build",
"preview": "vite preview --host"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.1",
"@sveltejs/vite-plugin-svelte": "^4.0.3",
"svelte": "^5.16.0",
"vite": "^5.4.11"
},
"type": "module",
"dependencies": {
"@types/dompurify": "^3.2.0",
"dompurify": "^3.2.3",
"marked": "^15.0.4"
}
"name": "website",
"version": "6.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"lint": "prettier --check .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.1",
"@sveltejs/vite-plugin-svelte": "^4.0.3",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "^5.16.0",
"vite": "^5.4.11"
},
"type": "module",
"dependencies": {
"dompurify": "^3.2.3",
"marked": "^15.0.6",
"svelte-i18n": "^4.0.1"
}
}

View File

@ -1,12 +1,12 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1024">
<link rel="icon" href="data:;base64,=">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1024" />
<link rel="icon" href="data:;base64,=" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

37
app/src/lib/api.js Normal file
View File

@ -0,0 +1,37 @@
import { urljoin } from "$lib/util.js";
const api_version = "v1";
const api_url = urljoin(import.meta.env.WEBSITE_API_URL, api_version);
function api_urljoin(path = null, query = {}) {
return urljoin(api_url, path, query);
}
function api_check_err(json) {
if (!("error" in json)) throw new Error('API response is missing the "error" key');
if (json["error"] != "") throw new Error(`API returned an error: ${json["error"]}`);
if (!("result" in json)) throw new Error('API response is missing the "result" key');
}
async function api_http_get(fetch, url) {
const res = await fetch(url);
const json = await res.json();
api_check_err(json);
return json["result"];
}
async function api_get_metrics(fetch) {
return await api_http_get(fetch, api_urljoin("/metrics"));
}
async function api_get_services(fetch) {
return await api_http_get(fetch, api_urljoin("/services"));
}
async function api_get_projects(fetch) {
return await api_http_get(fetch, api_urljoin("/projects"));
}
export { api_version, api_urljoin, api_get_metrics, api_get_services, api_get_projects };

View File

@ -1,52 +1,50 @@
<script>
export let title
let current = ""
let i = 0
while (title.length > i) {
let c = title[i]
setTimeout(()=>{
current += c
}, 100*(i+1))
i += 1
}
export let title;
</script>
<div class="main">
<div class="title">
root@ngn.tf:~# {current}
</div>
<div class="content">
<main>
<h1 class="title">{title}</h1>
<div>
<slot></slot>
</div>
</div>
</main>
<style>
.main {
display: flex;
flex-direction: column;
width: 100%;
background: var(--dark-three);
box-shadow: var(--box-shadow);
border-radius: var(--radius);
border: solid 1px var(--border-color);
}
main {
flex: 1;
flex-basis: 30%;
.title {
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;
}
display: flex;
flex-direction: column;
}
.content {
background: var(--dark-three);
padding: 30px;
color: white;
border-radius: 5px;
font-size: 25px;
}
main .title {
font-family:
Consolas,
Monaco,
Lucida Console,
Liberation Mono,
DejaVu Sans Mono,
Bitstream Vera Sans Mono,
Courier New,
monospace;
color: var(--white-1);
}
main .title::before {
content: "#";
margin: 0 10px 0 0;
color: var(--white-3);
}
main div {
border-left: solid 1px var(--black-4);
padding: 25px 25px 10px 25px;
font-size: var(--size-4);
color: var(--white-1);
word-wrap: break-word;
align-items: center;
margin-left: 7px;
flex: 1;
}
</style>

View File

@ -1,70 +0,0 @@
<script>
export let title
export let url
let audio
let current = ""
let i = 0
while (title.length > i) {
let c = title[i]
setTimeout(()=>{
current += c
}, 100*(i+1))
i += 1
}
function epicSound() {
audio.play()
}
</script>
<a on:click={epicSound} data-sveltekit-preload-data href={url}>
<audio bind:this={audio} preload="auto">
<source src="/click.wav" type="audio/mpeg" />
</audio>
<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: .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>

28
app/src/lib/doc.js Normal file
View File

@ -0,0 +1,28 @@
import { urljoin } from "$lib/util.js";
function doc_urljoin(path = null, query = {}) {
return urljoin(import.meta.env.WEBSITE_DOC_URL, path, query);
}
function doc_check_err(json) {
if ("error" in json) throw new Error(`Documentation server returned an error: ${json["error"]}`);
}
async function doc_http_get(fetch, url) {
const res = await fetch(url);
const json = await res.json();
doc_check_err(json);
return json;
}
async function doc_get_list(fetch) {
return await doc_http_get(fetch, doc_urljoin("/list"));
}
async function doc_get(fetch, name) {
let url = doc_urljoin("/get");
url = urljoin(url, name);
return await doc_http_get(fetch, url);
}
export { doc_urljoin, doc_get, doc_get_list };

62
app/src/lib/error.svelte Normal file
View File

@ -0,0 +1,62 @@
<script>
import Link from "$lib/link.svelte";
import { color } from "$lib/util.js";
import { _ } from "svelte-i18n";
export let error = "";
</script>
<main>
<h1 style="color: var(--{color()})">{$_("error.title")}</h1>
<code>
{#if error === ""}
Unknown error
{:else}
{error}
{/if}
</code>
<Link link={import.meta.env.WEBSITE_REPORT_URL}>
{$_("error.report")}
</Link>
<img src="/profile/sad.png" alt="" />
</main>
<style>
main {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: end;
align-items: flex-start;
gap: 10px;
padding: 50px;
font-size: var(--size-4);
background: var(--background);
background-size: 50%;
}
main h1 {
font-size: var(--size-6);
}
main code {
font-size: var(--size-4);
color: var(--white-2);
}
main img {
width: var(--profile-size);
position: absolute;
right: 0;
bottom: 0;
}
</style>

89
app/src/lib/footer.svelte Normal file
View File

@ -0,0 +1,89 @@
<script>
import { urljoin, color, date_from_ts } from "$lib/util.js";
import { api_get_metrics } from "$lib/api.js";
import Link from "$lib/link.svelte";
import { onMount } from "svelte";
import { _ } from "svelte-i18n";
let data = {};
onMount(async () => {
data = await api_get_metrics(fetch);
});
</script>
<footer style="border-top: solid 2px var(--{color()});">
<div class="info">
<div class="links">
<span>
<Link link={import.meta.env.WEBSITE_SOURCE_URL} bold={true}>{$_("footer.source")}</Link>
</span>
<span>/</span>
<span>
<Link link={urljoin(import.meta.env.WEBSITE_APP_URL, "doc/license")} bold={true}
>{$_("footer.license")}</Link
>
</span>
<span>/</span>
<span>
<Link link={urljoin(import.meta.env.WEBSITE_APP_URL, "doc/privacy")} bold={true}
>{$_("footer.privacy")}</Link
>
</span>
</div>
<span>
{$_("footer.powered")}
</span>
</div>
<div class="useless">
<span>
{$_("footer.number", {
values: {
total: data.total,
since: date_from_ts(data.since),
},
})}
{#if data.number % 1000 == 0}
<span style="color: var(--{color()})">({$_("footer.wow")})</span>
{/if}
</span>
<span>
{$_("footer.version", { values: { api_version: "v1", frontend_version: pkg.version } })}
</span>
</div>
</footer>
<style>
footer {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background: var(--black-1);
}
div {
display: flex;
color: var(--white-2);
font-size: var(--size-2);
flex-direction: column;
gap: 5px;
}
.useless {
margin: 25px 50px 25px 0;
text-align: right;
}
.info {
margin: 25px 0 25px 50px;
text-align: left;
}
.info .links {
display: flex;
flex-direction: row;
gap: 5px;
}
</style>

22
app/src/lib/head.svelte Normal file
View File

@ -0,0 +1,22 @@
<script>
import { api_urljoin } from "$lib/api.js";
import { app_url } from "$lib/util.js";
export let desc, title;
</script>
<svelte:head>
<title>[ngn.tf] | {title}</title>
<meta content="[ngn.tf] | {title}" property="og:title" />
<meta content={desc} property="og:description" />
<meta content={app_url()} property="og:url" />
<meta content="#000000" data-react-helmet="true" name="theme-color" />
<link
rel="alternate"
type="application/atom+xml"
href={api_urljoin("/news/en")}
title="Atom Feed"
/>
</svelte:head>

View File

@ -1,41 +1,80 @@
<script>
export let subtitle = ""
import { color } from "$lib/util.js";
import { _ } from "svelte-i18n";
export let picture = "";
export let title = "";
</script>
<header>
<h1>
<slot></slot>
</h1>
<h4><c>{subtitle}</c></h4>
<div>
<h1 class="title" style="color: var(--{color()})">{title.toLowerCase()}</h1>
<h1 class="cursor" style="color: var(--{color()})">_</h1>
</div>
<img src="/profile/{picture}.png" alt="" />
</header>
<style>
header {
background:
linear-gradient(rgba(11, 11, 11, 0.808), rgba(1, 1, 1, 0.96)),
url("https://files.ngn.tf/banner.png");
background-size: 50%;
width: 100%;
height: 100%;
}
header {
background: var(--background);
background-size: 50%;
h1 {
font-weight: 900;
font-size: 500%;
padding: 120px;
padding-bottom: 0;
text-align: center;
color: white;
text-shadow: var(--text-shadow);
text-size-adjust: 80%;
}
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: end;
}
h4 {
padding-bottom: 120px;
font-weight: 600;
font-size: 200%;
text-align: center;
color: white;
text-size-adjust: 80%;
}
header div {
display: flex;
flex-direction: row;
align-items: end;
padding: 50px 50px 30px 50px;
font-size: var(--size-6);
font-family:
Consolas,
Monaco,
Lucida Console,
Liberation Mono,
DejaVu Sans Mono,
Bitstream Vera Sans Mono,
Courier New,
monospace;
white-space: nowrap;
justify-content: start;
width: min-content;
}
header div .title {
text-shadow: var(--text-shadow);
overflow: hidden;
width: 0;
animation: typing 1s steps(20, end) forwards;
animation-delay: 0.3s;
}
header div .cursor {
content: "_";
display: inline-block;
animation: blink 1.5s steps(2) infinite;
}
header img {
padding: 50px 50px 0 50px;
width: var(--profile-size);
bottom: 0;
left: 0;
}
@media only screen and (max-width: 900px) {
header {
display: block;
}
header img {
display: none;
}
}
</style>

6
app/src/lib/icon.svelte Normal file
View File

@ -0,0 +1,6 @@
<script>
import { color } from "$lib/util.js";
export let icon = "";
</script>
<i style="color: var(--{color()});" class="nf {icon}"></i>

37
app/src/lib/link.svelte Normal file
View File

@ -0,0 +1,37 @@
<script>
import Icon from "$lib/icon.svelte";
import { color } from "$lib/util.js";
const default_color = "white-1";
export let active = false;
export let highlight = true;
export let link = "";
export let icon = "";
let style = "";
if (highlight) style = `text-decoration-color: var(--${color()});`;
if (active) style += `color: var(--${color()});`;
else style += `color: var(--${default_color});`;
</script>
{#if icon != ""}
<Icon {icon} />
{/if}
{#if highlight}
<a data-sveltekit-preload-data {style} href={link}>
<slot></slot>
</a>
{:else}
<a data-sveltekit-preload-data {style} class="no-highlight" href={link}>
<slot></slot>
</a>
{/if}
<style>
.no-highlight:hover {
text-decoration: none;
}
</style>

66
app/src/lib/locale.js Normal file
View File

@ -0,0 +1,66 @@
import { init, locale, register, waitLocale } from "svelte-i18n";
import { browser } from "$app/environment";
import { get, writable } from "svelte/store";
const locale_default = "en";
let locale_index = writable(0);
let locale_list = [];
function locale_setup() {
// english
register("en", () => import("../locales/en.json"));
locale_list.push({ code: "en", name: "English", icon: "🇬🇧" });
// turkish
register("tr", () => import("../locales/tr.json"));
locale_list.push({ code: "tr", name: "Turkish", icon: "🇹🇷" });
init({
fallbackLocale: locale_default,
initialLocale: get(locale),
});
}
function locale_from_browser() {
if (browser) return window.navigator.language.slice(0, 2).toLowerCase();
else return locale_default;
}
function locale_select(l = null) {
if (l === null) {
if (browser && null !== (l = localStorage.getItem("locale"))) locale_select(l);
else locale_select(locale_from_browser());
return;
}
l = l.slice(0, 2);
for (let i = 0; i < locale_list.length; i++) {
if (l !== locale_list[i].code) continue;
if (browser) localStorage.setItem("locale", l);
locale.set(l);
locale_index.set(i);
return;
}
locale.set(locale_default);
locale_index.set(0);
}
async function locale_wait() {
await waitLocale();
}
export {
locale,
locale_list,
locale_index,
locale_default,
locale_setup,
locale_wait,
locale_select,
locale_from_browser,
};

View File

@ -1,51 +1,42 @@
<script>
import NavbarLink from "./navbar_link.svelte";
import NavbarSwitch from "./navbar_switch.svelte";
import { color } from "$lib/util.js";
import { _ } from "svelte-i18n";
</script>
<nav>
<nav style="border-bottom: solid 2px var(--{color()});">
<h3 style="color: var(--{color()})">[ngn.tf]</h3>
<div>
<h3>[ngn.tf]</h3>
</div>
<div>
<NavbarLink link="/">home</NavbarLink>
<NavbarLink link="/services">services</NavbarLink>
<NavbarLink link="/blog">blog</NavbarLink>
<!-- <NavbarLink link="/donate">donate</NavbarLink> -->
<NavbarLink link="https://stats.ngn.tf">status</NavbarLink>
<NavbarLink link="/">{$_("navbar.home")}</NavbarLink>
<NavbarLink link="/services">{$_("navbar.services")}</NavbarLink>
<NavbarLink link="/donate">{$_("navbar.donate")}</NavbarLink>
<NavbarSwitch />
</div>
</nav>
<style>
nav {
background: var(--dark-one);
padding: 20px 26px 22px 20px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
border-bottom: solid 1.5px black;
animation-name: borderAnimation;
animation-duration: 10s;
animation-iteration-count: infinite;
box-shadow: var(--def-shadow);
}
nav {
box-shadow: var(--box-shadow-1);
background: var(--black-1);
padding: 20px 30px 20px 20px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
div {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: right;
gap: 15px;
}
div {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: right;
gap: 15px;
}
h3 {
font-weight: 900;
font-size: 25px;
color: red;
animation-name: colorAnimation;
animation-iteration-count: infinite;
animation-duration: 10s;
}
h3 {
font-weight: 900;
font-size: var(--size-4);
}
</style>

View File

@ -1,47 +1,27 @@
<script>
import { page } from "$app/stores"
export let link
export let type
let audio
import { color, click } from "$lib/util.js";
import { page } from "$app/stores";
function epicSound() {
audio.play()
export let link;
function is_active() {
return $page.url.pathname == link;
}
</script>
<div>
<audio bind:this={audio} preload="auto">
<source src="/click.wav" type="audio/mpeg" />
</audio>
{#if type==="icon"}
<a class="icon" data-sveltekit-preload-data on:click={epicSound} href="{link}"><slot></slot></a>
{:else}
<a data-sveltekit-preload-data on:click={epicSound} href="{link}"><slot></slot></a>
{/if}
</div>
<a
style="text-decoration-color: var(--{color()}); {is_active() ? `color: var(--${color()})` : ''}"
data-sveltekit-preload-data
on:click={click}
href={link}
>
<slot></slot>
</a>
<style>
a {
font-weight: 700;
font-size: 22px;
text-decoration: none;
color: white;
cursor: pointer;
}
a:hover {
text-decoration: underline;
text-shadow: 3px 4px 7px rgba(81, 67, 21, 0.8);
animation-name: underlineAnimation;
animation-duration: 5s;
animation-iteration-count: infinite;
}
.icon:hover {
text-decoration: none;
text-shadow: 3px 4px 7px rgba(81, 67, 21, 0.8);
animation-name: colorAnimation;
animation-duration: 5s;
animation-iteration-count: infinite;
}
a {
font-weight: 900;
font-size: var(--size-4);
color: var(--white-1);
}
</style>

View File

@ -0,0 +1,37 @@
<script>
import { locale_list, locale_select, locale_index } from "$lib/locale.js";
let len = locale_list.length;
function get_next(indx) {
let new_indx = 0;
if (indx + 1 >= len) indx = 0;
else new_indx = indx + 1;
return locale_list[new_indx];
}
function next() {
locale_select(get_next($locale_index).code);
}
</script>
<button on:click={next}>
{get_next($locale_index).icon}
</button>
<style>
button {
background: var(--black-2);
color: var(--white-1);
font-size: var(--size-4);
outline: none;
border: none;
transition: 0.4s;
}
button:hover {
background: var(--black-1);
}
</style>

View File

@ -1,78 +1,114 @@
<script>
export let desc
export let url
import Icon from "$lib/icon.svelte";
import Link from "$lib/link.svelte";
let icon = "<i class='nf nf-md-clipboard_multiple'></i>"
let audio
import { color, time_from_ts } from "$lib/util.js";
import { locale, _ } from "svelte-i18n";
function copy() {
audio.play()
navigator.clipboard.writeText(url)
icon = "<i class='nf nf-md-clipboard_check'></i>"
setTimeout(()=>{
icon = "<i class='nf nf-md-clipboard_multiple'></i>"
}, 500)
}
export let service = {};
</script>
<main>
<audio bind:this={audio} preload="auto">
<source src="/click.wav" type="audio/mpeg" />
</audio>
<div>
<h1><slot></slot></h1>
<p>{desc}</p>
<div class="info">
<div class="title">
<h1>{service.name}</h1>
<p>{service.desc[$locale]}</p>
</div>
<div class="links">
<Link highlight={false} link={service.clear}><Icon icon="nf-oct-link" /></Link>
{#if service.onion != ""}
<Link highlight={false} link={service.onion}><Icon icon="nf-linux-tor" /></Link>
{/if}
{#if service.i2p != ""}
<Link highlight={false} link={service.i2p}
><span style="color: var(--{color()})">I2P</span></Link
>
{/if}
</div>
</div>
<div>
<button on:click={copy}>{@html icon}</button>
<a href="{url}"><i class="nf nf-oct-link_external"></i></a>
<div class="check">
<h1>
{$_("services.last", {
values: { time: time_from_ts(service.check_time) },
})}
</h1>
{#if service.check_res == 0}
<span style="background: var(--white-2)">
{$_("services.status.down")}
</span>
{:else if service.check_res == 1}
<span style="background: var(--{color()})">
{$_("services.status.up")}
</span>
{:else if service.check_res == 2}
<span style="background: var(--{color()}); filter: brightness(50%);">
{$_("services.status.slow")}
</span>
{/if}
</div>
</main>
<style>
main {
display: flex;
flex-direction: row;
padding: 30px 30px 30px 30px;
background: var(--dark-two);
border-radius: var(--radius);
box-shadow: var(--box-shadow);
border: solid 1px var(--border-color);
justify-content: space-between;
align-items: center;
color: white;
gap: 100px;
transition: .4s;
flex-grow: 1;
flex: 1 1 0px;
}
main {
display: flex;
flex-direction: column;
background: var(--black-3);
border: solid 1px var(--black-4);
text-align: left;
div h1 {
animation-name: colorAnimation;
animation-duration: 10s;
animation-iteration-count: infinite;
font-size: 30px;
}
flex: 1;
flex-basis: 40%;
}
div p {
margin-top: 10px;
font-size: 20px;
}
main .info {
padding: 25px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
color: var(--white-1);
flex: 1;
}
a, button {
text-align: center;
font-size: 30px;
text-decoration: none;
color: white;
border: none;
background: none;
outline: none;
cursor: pointer;
}
main .info .title h1 {
font-size: var(--size-5);
margin-bottom: 8px;
font-weight: 900;
}
a:hover, button:hover{
animation-name: colorAnimation;
animation-duration: 5s;
animation-iteration-count: infinite;
}
main .info .title p {
font-size: var(--size-4);
color: var(--white-2);
font-weight: 100;
}
main .info .links {
display: flex;
flex-direction: row;
gap: 10px;
font-size: var(--size-6);
}
main .check {
border-top: solid 1px var(--black-4);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
color: var(--white-1);
}
main .check h1 {
padding: 15px 25px 15px 25px;
font-size: var(--size-4);
font-weight: 100;
}
main .check span {
padding: 15px 25px 15px 25px;
font-size: var(--size-5);
text-transform: uppercase;
color: var(--black-1);
font-weight: 1000;
}
</style>

71
app/src/lib/util.js Normal file
View File

@ -0,0 +1,71 @@
import { locale_from_browser } from "$lib/locale.js";
const colors = [
"yellow",
"cyan",
"green",
"pinkish",
"red",
// "blue" (looks kinda ass)
];
let colors_pos = -1;
function color() {
if (colors_pos < 0) colors_pos = Math.floor(Math.random() * colors.length);
else if (colors_pos >= colors.length) colors_pos = 0;
return colors[colors_pos];
}
function click() {
let audio = new Audio("/click.wav");
audio.play();
}
function urljoin(url, path = null, query = {}) {
if (undefined === url || null === url) return;
let url_len = url.length;
if (url[url_len - 1] != "/") url += "/";
if (null === path || "" === path) url = new URL(url);
else if (path[0] === "/") url = new URL(path.slice(1), url);
else url = new URL(path, url);
for (let k in query) url.searchParams.append(k, query[k]);
return url.href;
}
function app_url(path = null, query = {}) {
return urljoin(import.meta.env.WEBSITE_APP_URL, path, query);
}
function time_from_ts(ts) {
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(locale_from_browser(), {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(ts_date) + ` (${ts_zone})`
);
}
function date_from_ts(ts) {
if (ts === 0 || ts === undefined) return;
return new Intl.DateTimeFormat(locale_from_browser(), {
month: "2-digit",
year: "2-digit",
day: "2-digit",
}).format(new Date(ts * 1000));
}
export { color, click, urljoin, app_url, time_from_ts, date_from_ts };

83
app/src/locales/en.json Normal file
View File

@ -0,0 +1,83 @@
{
"navbar": {
"home": "home",
"services": "services",
"donate": "donate"
},
"home": {
"title": "Hello world!",
"welcome": {
"title": "about",
"desc": "Welcome to my website, I'm ngn",
"whoami": "I'm a security, privacy and freedom advocate high-schooler from Turkey",
"interest": "I'm interested in system security and software development",
"support": "I love and support Free/Libre and Open Source Software (FLOSS)"
},
"work": {
"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"
},
"links": {
"title": "contact",
"desc": "Here are some useful links if you want to get in contact with me",
"prefer": "I highly prefer email, you can send encrypted emails using my PGP key"
},
"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!"
},
"projects": {
"title": "projects",
"desc": "I mostly work on free software projects, here are some of projects that you might find interesting"
}
},
"services": {
"title": "Service Status",
"none": "No services found",
"search": "Search for a service",
"feed": "News and updates",
"last": "Last checked at {time}",
"status": {
"up": "Up",
"down": "Down",
"slow": "Slow"
}
},
"donate": {
"title": "Donate Money!",
"info": "I spend a lot of time and money on different projects and maintaining different services.",
"price": "I mostly pay for hosting and electricity. Which when added up costs around 550₺ per month (~$15 at the time of writing).",
"details": "So even a small donation would be useful. And it would help me keep everything up and running.",
"thanks": "Also huge thanks to all of you who have donated so far!",
"table": {
"platform": "Platform",
"address": "Adress/Link"
}
},
"doc": {
"title": "Documentation"
},
"error": {
"title": "Something went wrong!",
"report": "Report this issue"
},
"footer": {
"source": "Source",
"license": "License",
"privacy": "Privacy",
"powered": "Powered by Svelte, Go, SQLite and donations",
"number": "Visited {total} times since {since}",
"wow": "wow!!",
"version": "Using API version {api_version}, frontend version {frontend_version}"
}
}

84
app/src/locales/tr.json Normal file
View File

@ -0,0 +1,84 @@
{
"navbar": {
"home": "anasayfa",
"services": "servisler",
"donate": "bağış"
},
"home": {
"title": "Merhaba Dünya!",
"welcome": {
"title": "hakkımda",
"desc": "Websiteme hoşgeldiniz, ben ngn",
"whoami": "Türkiye'den, güvenlik, gizlik ve özgürlük savunucusu bir liseliyim",
"interest": "Sistem güvenliği ve yazılım geliştirmek ile ilgileniyorum",
"support": "Özgür/Libre ve Açık Kaynaklı Yazılımı (FLOSS) seviyorum ve destekliyorum"
},
"work": {
"title": "iş",
"desc": "Şuan bir işim yok, o yüzden zamanımın çoğunu şunlarla geçiriyorum:",
"build": "salak şeyler inşa etmek",
"fix": "salak şeyleri düzeltmek",
"ctf": "CTF challenge'ları çözmek",
"contribute": "rastgele projelere katkıda bulunmak",
"wiki": "wikimi genişletmek"
},
"links": {
"title": "iletişim",
"desc": "Eğer benim ile iletişime geçmek istiyorsanız, işte bazı faydalı linkler",
"prefer": "Email'i fazlasıyla tercih ediyorum, PGP anahtarım ile şifreli email'ler gönderebilirsiniz"
},
"services": {
"title": "servisler",
"desc": "Salak şeyler inşa etmenin yanı sıra, herkes için kullanıma açık özgür ve ücretsiz servisler host ediyorum",
"speed": "Tüm servisler 600 Mbit/s ağ arayüzü üzerinden erişilebilir",
"security": "Hepsi SSL şifreli bağlantı kullanıyor ve hepsi gizliğinize önem veriyor",
"privacy": "Accessible from clearnet, TOR and I2P, no region or network blocks",
"privacy": "Açık ağdan, TOR ve I2P'den erişilebilirler, bölge ya da ağ blokları yok",
"bullshit": "CDN yok, cloudflare yok, CAPTCHA yok, analitikler yok, boktan saçmalıklar yok",
"link": "Tüm servisleri incele!"
},
"projects": {
"title": "projeler",
"desc": "Çoğunlukla özgür yazılım projeleri üzerinde çalışıyorum, işte ilginç bulabileceğiniz bazı projelerim"
}
},
"services": {
"title": "Servis Durumu",
"none": "Servis bulunamadı",
"search": "Bir servisi ara",
"feed": "Yenilikler ve güncellemeler",
"last": "Son kontrol zamanı {time}",
"status": {
"up": "Çalışıyor",
"down": "Kapalı",
"slow": "Yavaş"
}
},
"donate": {
"title": "Para Bağışla!",
"info": "Farklı projeler ve farklı servisleri yönetmek için oldukça zaman ve para harcıyorum.",
"price": "Çoğunlukla hosting ve elektrik için ödeme yapıyorum. Bunlar eklendiği zaman aylık 550₺ civarı bir miktar oluyor (yazdığım sırada ~15$).",
"details": "Bu sebepten küçük bir bağış bile oldukça faydalı olacaktır. Ve herşeyi açık ve çalışmakta tutmama yardımcı olacaktır.",
"thanks": "Ayrıca şuana kadar bağışta bulunan herkese çok teşekkür ederim!",
"table": {
"platform": "Platform",
"address": "Adres/Bağlantı"
}
},
"doc": {
"title": "Dökümantasyon"
},
"error": {
"title": "Birşeyler yanlış gitti!",
"report": "Bu sorunu raporlayın"
},
"footer": {
"source": "Kaynak",
"license": "Lisans",
"privacy": "Gizlilik",
"powered": "Svelte, Go, SQLite ve bağışlar tarafından destekleniyor",
"number": "{since} tarihinden beri {total} kez ziyaret edildi",
"wow": "vay be!!",
"version": "Kullan API versiyonu {api_version}, arayüz versiyonu {frontend_version}"
}
}

View File

@ -1,8 +1,8 @@
<script>
import { onMount } from "svelte"
import { goto } from "$app/navigation"
import { onMount } from "svelte";
import { goto } from "$app/navigation";
onMount(()=>{
goto("/")
})
onMount(() => {
goto("/");
});
</script>

View File

@ -0,0 +1,6 @@
import { locale_setup, locale_wait } from "$lib/locale.js";
export async function load() {
locale_setup();
await locale_wait();
}

View File

@ -1,12 +1,36 @@
<script>
import Navbar from "../lib/navbar.svelte";
</script>
import Navbar from "$lib/navbar.svelte";
import Footer from "$lib/footer.svelte";
import { locale_select } from "$lib/locale.js";
import { onMount } from "svelte";
let { children } = $props();
onMount(() => {
locale_select();
});
</script>
<main>
<Navbar />
<slot></slot>
<div class="content">
{@render children()}
</div>
<Footer />
</main>
<style>
@import "../../static/global.css";
@import "/global.css";
main {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.content {
background: var(--black-1);
flex-grow: 1;
}
</style>

15
app/src/routes/+page.js Normal file
View File

@ -0,0 +1,15 @@
import { api_get_projects } from "$lib/api.js";
export async function load({ fetch }) {
try {
let projects = await api_get_projects(fetch);
return {
projects: null === projects ? [] : projects,
error: "",
};
} catch (err) {
return {
error: err.toString(),
};
}
}

View File

@ -1,172 +1,121 @@
<script>
import Header from "../lib/header.svelte";
import Card from "../lib/card.svelte";
import Header from "$lib/header.svelte";
import Error from "$lib/error.svelte";
import Head from "$lib/head.svelte";
import Card from "$lib/card.svelte";
import Link from "$lib/link.svelte";
import { _, locale } from "svelte-i18n";
import { color } from "$lib/util.js";
let { data } = $props();
</script>
<svelte:head>
<title>[ngn.tf] | homepage</title>
<meta content="[ngn] | homepage" property="og:title" />
<meta content="Homepage of my personal website" property="og:description" />
<meta content="https://ngn.tf" property="og:url" />
<meta content="#000000" data-react-helmet="true" name="theme-color" />
<link rel="alternate" type="application/atom+xml" href="{import.meta.env.VITE_API_URL_DEV+'/blog/feed.atom'}" title="Atom Feed">
<link rel="alternate" type="application/rss+xml" href="{import.meta.env.VITE_API_URL_DEV+'/blog/feed.rss'}" title="RSS Feed">
</svelte:head>
<Head title="home" desc="home page of my personal website" />
<Header picture="tired" title={$_("home.title")} />
<Header>
<c>echo</c>
hello world!
</Header>
<main>
<div class="flexbox">
<Card title="whoami">
<div class="whoami-box">
<div class="whoami-pic">
<img alt="My profile" src="https://files.ngn.tf/pplow.png">
<a href="https://keyoxide.org/F9E70878C2FB389AEC2BA34CA3654DF5AD9F641D">
<c><i class="nf nf-oct-key"></i> Keyoxide</c>
</a>
</div>
<div class="whoami-text">
👋 Hello! I'm ngn!
<ul>
<li>🇹🇷 I'm a high school student from Turkey</li>
<li>🖥️ I'm interested in cyber security and programming.</li>
<li>❤️ I love and support Free/Libre and Open Source Software (FLOSS)</li>
<li>🐧 My GNU/Linux distribution of choice is Artix, however I am currently running Arch</li>
</ul>
</div>
</div>
</Card>
</div>
<div class="flexbox">
<Card title="ps -eaf">
I usually spend my time...
{#if data.error.length !== 0}
<Error error={data.error} />
{:else}
<main>
<Card title={$_("home.welcome.title")}>
<span> 👋 {$_("home.welcome.desc")}</span>
<ul>
<li><c>⌨️</c> building random projects</li>
<li><c>👥</c> contributing stuff that I like</li>
<li><c>🚩</c> solving CTFs</li>
<li><c>🖥️</c> customizing my desktop</li>
<li><c>📑</c> posting random stuff on my blog, you should definitely check it out btw (it's very active)</li>
<li>🇹🇷 {$_("home.welcome.whoami")}</li>
<li>🖥️ {$_("home.welcome.interest")}</li>
<li>❤️ {$_("home.welcome.support")}</li>
</ul>
</Card>
<Card title="wall">
Here are some links if you want to get in contact with me, I highly
prefer email and I usually respond to emails in 1 or 2 days, just make
sure to check your spam folder (turns out running a TOR relay gets your IP into multiple blacklists)
<Card title={$_("home.work.title")}>
<span>{$_("home.work.desc")}</span>
<ul>
<li>
<c><i class="nf nf-cod-github"></i></c>
<a href="https://github.com/ngn13">Github</a>
</li>
<li>
<c><i class="nf nf-md-mastodon"></i></c>
<a href="https://defcon.social/@ngn" rel="me">Mastodon</a>
</li>
<li>
<c><i class="nf nf-md-email"></i></c>
<a href="mailto:ngn@ngn.tf">Email</a>
</li>
<li>
<c><i class="nf nf-md-xmpp"></i></c>
<a href="xmpp:ngn@chat.ngn.tf">XMPP</a>
</li>
<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>
</ul>
</Card>
</div>
</main>
<div class="version">
<p>v5.0</p>
</div>
<Card title={$_("home.links.title")}>
<span>{$_("home.links.desc")}:</span>
<ul>
<li>
<Link
icon="nf-fa-key"
link="https://keyoxide.org/F9E70878C2FB389AEC2BA34CA3654DF5AD9F641D"
>
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>
</ul>
<span>
{$_("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.services.speed")}
</li>
<li>
<i style="color: var(--{color()});" class="nf nf-fa-lock"></i>
{$_("home.services.security")}
</li>
<li>
<i style="color: var(--{color()});" class="nf nf-fa-network_wired"></i>
{$_("home.services.privacy")}
</li>
<li>
<i style="color: var(--{color()});" class="nf nf-md-eye_off"></i>
{$_("home.services.bullshit")}
</li>
</ul>
<Link link="/services">{$_("home.services.link")}</Link>
</Card>
<Card title={$_("home.projects.title")}>
<span>
{$_("home.projects.desc")}:
</span>
{#if data.error === undefined}
<ul>
{#each data.projects.filter((p) => {
return p.desc[$locale] !== "" && p.desc[$locale] !== null && p.desc[$locale] !== undefined;
}) as project}
<li>
<Link active={true} link={project.url}>{project.name}</Link>:
{project.desc[$locale]}
</li>
{/each}
</ul>
{/if}
</Card>
</main>
{/if}
<style>
main{
display: flex;
flex-direction: column;
gap: 28px;
padding: 50px;
}
main {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: stretch;
.flexbox {
display: flex;
gap: 28px;
}
.whoami-box {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 35px;
}
.whoami-pic {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border-right: solid 1px var(--dark-fife);
padding: 0 35px 0 10px;
}
.whoami-pic img {
width: 200px;
border-radius: 20px;
border: solid 1px var(--border-color);
animation-name: fullBorderAnimation;
animation-duration: 10s;
animation-iteration-count: infinite;
box-shadow: rgba(50, 50, 93, 1) 0px 30px 60px -12px inset, rgba(0, 0, 0, 1) 0px 18px 36px -18px inset;
}
ul {
list-style: inside;
}
li {
padding-top: 15px;
}
a {
color: white;
text-decoration: none;
}
a:hover {
font-weight: 900;
}
.version {
color: var(--dark-fife);
position: fixed;
bottom: 10px;
right: 10px;
font-size: 15px;
}
@media only screen and (max-width: 1200px) {
.flexbox {
flex-direction: column;
padding: 50px;
gap: 28px;
}
}
@media only screen and (max-width: 900px) {
.whoami-box {
flex-direction: column;
gap: 25px;
@media only screen and (max-width: 900px) {
main {
flex-direction: column;
}
}
.whoami-pic {
border-right: none;
padding: 0;
}
}
</style>

View File

@ -1,9 +0,0 @@
export async function load({ fetch }) {
const api = import.meta.env.VITE_API_URL_DEV
const res = await fetch(api+"/blog/sum")
const data = await res.json()
return {
posts: data["result"]
}
}

View File

@ -1,105 +0,0 @@
<script>
import Header from "../../lib/header.svelte";
import CardLink from "../../lib/card_link.svelte";
export let data
let posts = data.posts
</script>
<svelte:head>
<title>[ngn.tf] | blog</title>
<meta content="[ngn] | blog" property="og:title" />
<meta content="View my blog posts" property="og:description" />
<meta content="https://ngn.tf" property="og:url" />
<meta content="#000000" data-react-helmet="true" name="theme-color" />
<link rel="alternate" type="application/atom+xml" href="{import.meta.env.VITE_API_URL_DEV+'/blog/feed.atom'}" title="Atom Feed">
<link rel="alternate" type="application/rss+xml" href="{import.meta.env.VITE_API_URL_DEV+'/blog/feed.rss'}" title="RSS Feed">
</svelte:head>
<Header>
<c>/dev/</c>blog
</Header>
<main>
<div class="feed-list">
<a href="{import.meta.env.VITE_API_URL_DEV+'/blog/feed.rss'}">
<c><i class="nf nf-fa-rss_square"></i></c> <p>RSS</p>
</a>
<a href="{import.meta.env.VITE_API_URL_DEV+'/blog/feed.atom'}">
<c><i class="nf nf-fae-atom"></i></c> <p>Atom</p>
</a>
<a href="{import.meta.env.VITE_API_URL_DEV+'/blog/feed.json'}">
<c><i class="nf nf-seti-json"></i></c> <p>JSON</p>
</a>
</div>
<div class="post-list">
{#each posts as post}
<CardLink url="/blog/{post.id}" title="{post.title}">
<p>{post.author} | {post.date}</p>
<br>
{post.content}...
</CardLink>
{/each}
</div>
</main>
<style>
.post-list{
display: flex;
flex-direction: column;
gap: 28px;
}
main {
padding: 15%;
padding-top: 50px;
display: flex;
flex-direction: column;
gap: 20px;
}
p {
font-size: 20px;
}
.feed-list{
text-align: right;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10px;
}
.feed-list a {
text-decoration: none;
padding: 10px 15px 10px 15px;
background: var(--dark-three);
border-radius: var(--radius);
border: solid 1px var(--border-color);
color: var(--white);
font-size: 20px;
font-weight: 900;
display: flex;
flex-direction: row;
align-items: center;
gap: 7px;
transition: .2s;
}
.feed-list a:hover {
box-shadow: var(--box-shadow);
}
.feed-list a i{
font-size: 21px;
}
@media only screen and (max-width: 1316px) {
main {
padding: 50px;
}
}
</style>

View File

@ -1,14 +0,0 @@
export async function load({ fetch, params }) {
const id = params.id
const api = import.meta.env.VITE_API_URL_DEV
const res = await fetch(api+"/blog/get?id="+id)
const data = await res.json()
if (data["error"] != "") {
return {
error: data["error"]
}
}
return data["result"]
}

View File

@ -1,191 +0,0 @@
<script>
import Header from "../../../lib/header.svelte"
import { goto } from "$app/navigation"
import { onMount } from "svelte"
import DOMPurify from "dompurify"
import { marked } from "marked"
export let data
let sanitized
const api = import.meta.env.VITE_API_URL_DEV
let upvote_status = "inactive"
let downvote_status = "inactive"
let voted = false
let audio
async function get_status() {
const res = await fetch(api+"/blog/vote/get?id="+data.id)
const json = await res.json()
if(json["error"]!= ""){
return
}
if (json["result"] == "upvote") {
upvote_status = "active"
downvote_status = "inactive"
}
else {
downvote_status = "active"
upvote_status = "inactive"
}
voted = true
}
onMount(async ()=>{
if (data.title == undefined)
goto("/blog")
sanitized = DOMPurify.sanitize(
marked.parse(data.content, { breaks: true }),
{
ADD_TAGS: ["iframe"],
ADD_ATTR: ["allow", "allowfullscreen", "frameborder", "scrolling"]
}
)
await get_status()
})
async function upvote(){
audio.play()
const res = await fetch(api+"/blog/vote/set?id="+data.id+"&to=upvote")
const json = await res.json()
if(json["error"] != ""){
return
}
if (voted){
data.vote += 2
}
else {
voted = true
data.vote += 1
}
await get_status()
}
async function downvote(){
audio.play()
const res = await fetch(api+"/blog/vote/set?id="+data.id+"&to=downvote")
const json = await res.json()
if(json["error"] != ""){
return
}
if (voted){
data.vote -= 2
}
else {
voted = true
data.vote -= 1
}
await get_status()
}
</script>
<svelte:head>
<title>[ngn.tf] | {data.title}</title>
<meta content="[ngn] | blog" property="og:title" />
<meta content="{data.content.substring(0, 100)}..." property="og:description" />
<meta content="https://ngn.tf" property="og:url" />
<meta content="#000000" data-react-helmet="true" name="theme-color" />
<link href="/markdown.css" rel="stylesheet">
</svelte:head>
<Header subtitle="{data.author} | {data.date}">
{data.title}
</Header>
<main>
<audio bind:this={audio} preload="auto">
<source src="/click.wav" type="audio/mpeg" />
</audio>
<div class="content markdown-body">
{@html sanitized}
</div>
<div class="votes">
<button on:click={async ()=>{upvote()}} class="{upvote_status}">
<i class="nf nf-md-arrow_up_bold"></i>
</button>
<p>{data.vote}</p>
<button on:click={async ()=>{downvote()}} class="{downvote_status}">
<i class="nf nf-md-arrow_down_bold"></i>
</button>
</div>
</main>
<style>
main {
padding: 50px 10% 50px 10%;
color: white;
display: flex;
flex-direction: row;
justify-content: center;
align-items: start;
}
@media only screen and (max-width: 816px) {
main {
padding: 50px 20% 50px 20%;
}
}
.content {
padding: 30px;
background: var(--dark-four);
border-radius: var(--radius);
border: solid 1px var(--border-color);
box-shadow: var(--box-shadow);
width: auto;
width: 100%;
}
.votes {
display: flex;
flex-direction: column;
text-align: center;
text-shadow: var(--text-shadow);
gap: 10px;
padding: 15px 5px 15px 5px;
margin-left: 10px;
}
.votes p {
font-size: 25px;
color: var(--dark-six);
}
.votes button{
display: flex;
flex-direction: row;
gap: 10px;
background: none;
outline: none;
border: none;
font-size: 30px;
cursor: pointer;
color: var(--dark-six);
}
.votes button:hover {
animation-name: colorAnimation;
animation-iteration-count: infinite;
animation-duration: 10s;
}
.active {
animation-name: colorAnimation;
animation-iteration-count: infinite;
animation-duration: 10s;
}
</style>

View File

@ -0,0 +1,13 @@
import { doc_get_list, doc_get } from "$lib/doc";
export async function load({ fetch, params }) {
try {
return {
docs: await doc_get_list(fetch),
doc: await doc_get(fetch, params.name),
error: "",
};
} catch (err) {
return { error: err.toString() };
}
}

View File

@ -0,0 +1,123 @@
<script>
import Header from "$lib/header.svelte";
import Error from "$lib/error.svelte";
import Head from "$lib/head.svelte";
import { locale, _ } from "svelte-i18n";
import { goto } from "$app/navigation";
import { color } from "$lib/util.js";
import DOMPurify from "dompurify";
import { onMount } from "svelte";
import { marked } from "marked";
let { data } = $props();
marked.use({ breaks: true });
onMount(async () => {
for (let key in data.doc)
data.doc[key]["content"] = DOMPurify.sanitize(data.doc[key]["content"]);
if (undefined !== data.error && data.error.includes("not found")) goto("/");
});
</script>
<Head title="documentation" desc="website and API documentation" />
<Header picture="reader" title={$_("doc.title")} />
{#if data.error.length !== 0}
{#if !data.error.includes("not found")}
<Error error={data.error} />
{/if}
{:else}
<main>
{#if data.doc !== undefined}
<div class="markdown-body" style="--link-color: var(--{color()})">
{@html marked.parse(data.doc[$locale].content)}
</div>
<div class="docs">
{#each data.docs[$locale] as doc}
{#if doc.title == data.doc[$locale].title}
<a href="/doc/{doc.name}" style="border-color: var(--{color()})">
<h1>{doc.title}</h1>
<h3>{doc.desc}</h3>
</a>
{:else}
<a href="/doc/{doc.name}" style="border-color: var(--white-3)">
<h1>{doc.title}</h1>
<h3>{doc.desc}</h3>
</a>
{/if}
{/each}
</div>
{/if}
</main>
{/if}
<style>
@import "/markdown.css";
main {
padding: 50px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: start;
gap: 30px;
}
main .docs {
display: flex;
flex-direction: column;
align-items: end;
gap: 6px;
}
main .docs a {
display: flex;
flex-direction: column;
background: var(--black-3);
text-decoration: none;
box-sizing: border-box;
border-right-style: solid;
padding: 15px;
width: 100%;
gap: 4px;
}
main .docs a:hover {
box-shadow: var(--box-shadow-2);
}
main .docs a h1 {
font-size: var(--size-3);
color: var(--white-1);
font-weight: 900;
}
main .docs a h3 {
font-size: var(--size-2);
color: var(--white-3);
font-weight: 100;
text-decoration: none;
}
main .markdown-body :global(a) {
color: var(--link-color);
}
@media only screen and (max-width: 900px) {
main {
flex-direction: column-reverse;
}
main .docs {
width: 100%;
}
main .docs a {
border-right-style: none;
border-left-style: solid;
width: 100%;
}
}
</style>

View File

@ -1,88 +1,98 @@
<script>
import Header from "../../lib/header.svelte";
import Card from "../../lib/card.svelte";
import Header from "$lib/header.svelte";
import Head from "$lib/head.svelte";
import Icon from "$lib/icon.svelte";
import { color } from "$lib/util.js";
import { _ } from "svelte-i18n";
</script>
<svelte:head>
<title>[ngn.tf] | donate</title>
<meta content="[ngn] | donate" property="og:title" />
<meta content="Give me all of your life savings" property="og:description" />
<meta content="https://ngn.tf" property="og:url" />
<meta content="#000000" data-react-helmet="true" name="theme-color" />
</svelte:head>
<Header>
<c>bash</c>
donate.sh
</Header>
<Head title="donate" desc="give me all of your life savings" />
<Header picture="money" title={$_("donate.title")} />
<main>
<Card title="bash donate.sh">
I work on free/libre and open source software and offer free services. General hosting
and stuff costs around 550₺ (~$17) per month, so feel free to donate in order to help me keep
everything up and running!
<table>
<thead>
<tr>
<th>Platform</th>
<th>Address/Link</th>
</tr>
</thead>
<tbody>
<tr>
<td>Monero (XMR)</td>
<td>
<code>
<span> </span>
<span>
{$_("donate.info")}
{$_("donate.price")}
</span>
<br />
<br />
<span>
{$_("donate.details")}
</span>
<table>
<thead>
<tr>
<th style="color: var(--{color()})">{$_("donate.table.platform")}</th>
<th style="color: var(--{color()})">{$_("donate.table.address")}</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<Icon icon="nf-fa-monero" />
Monero (XMR)
</td>
<td>
<code>
46q7G7u7cmASvJm7AmrhmNg6ctS77mYMmDAy1QxpDn5w57xV3GUY5za4ZPZHAjqaXdfS5YRWm4AVj5UArLDA1retRkJp47F
</code>
</td>
</tr>
</tbody>
</table>
Also huge thanks to all of you who has donated so far, even if it's a small amount, I highly
appreciate it. Thank you!
</Card>
</code>
</td>
</tr>
</tbody>
</table>
<span>
{$_("donate.thanks")}
</span>
</main>
<style>
main{
display: flex;
flex-direction: column;
gap: 35px;
padding: 50px;
}
main {
padding: 50px;
}
table {
border-collapse: collapse;
border: none;
color: white;
font-size: 20px;
width: 100%;
margin: 30px 0 30px 0;
box-shadow: var(--box-shadow);
}
main span {
font-size: var(--size-4);
color: var(--white-1);
}
table {
box-shadow: var(--box-shadow);
background: var(--black-3);
border-collapse: collapse;
font-size: var(--size-3);
margin: 30px 0 30px 0;
width: 100%;
}
tr,th,td{
color: white;
background: var(--dark-two);
}
tr,
th,
td {
color: white;
background: var(--dark-two);
text-align: left;
}
td,th{
border: solid 1px var(--dark-fife);
padding: 16px;
}
td,
th {
font-size: var(--size-4);
border: solid 1px var(--black-4);
padding: 16px;
}
th {
animation-name: colorAnimation;
animation-duration: 10s;
animation-iteration-count: infinite;
background: var(--dark-two);
}
th {
font-weight: 1000;
}
code {
word-wrap: break-word;
white-space: pre-wrap;
word-break: break-word;
}
td {
color: var(--white-2);
font-weight: 400;
}
code {
word-wrap: break-word;
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@ -1,43 +1,15 @@
import { api_get_services } from "$lib/api.js";
export async function load({ fetch }) {
const api = import.meta.env.VITE_API_URL_DEV
const res = await fetch(api+"/services/all")
const data = await res.json()
if (data["error"] != ""){
try {
let services = await api_get_services(fetch);
return {
error: data["error"]
}
}
// Some really bad code to convert
// [service1, service2, service3...]
// to
// [[service1, service2], [service4, service5], [service4...]...]
// so i can render it in the UI easily
let all = data["result"]
let counter = 0
let currentlist = []
let services = []
for (let i = 0; i < all.length; i++){
currentlist.push(all[i])
counter += 1
if(i == all.length-1 && counter != 2){
services.push(currentlist)
}
if (counter == 2) {
services.push(currentlist)
currentlist = []
counter = 0
}
}
return {
services
services: null === services ? [] : services,
error: "",
};
} catch (err) {
return {
error: err.toString(),
};
}
}

View File

@ -1,100 +1,92 @@
<script>
import Header from "../../lib/header.svelte";
import Service from "../../lib/service.svelte";
import Card from "../../lib/card.svelte";
import Service from "$lib/service.svelte";
import Header from "$lib/header.svelte";
import Error from "$lib/error.svelte";
import Link from "$lib/link.svelte";
import Head from "$lib/head.svelte";
export let data
import { api_urljoin } from "$lib/api.js";
import { locale, _ } from "svelte-i18n";
let { data } = $props();
let services = $state(data.services);
function change(input) {
let value = input.target.value.toLowerCase();
services = [];
if (value === "") {
services = data.services;
return;
}
data.services.forEach((s) => {
if (s.name.toLowerCase().includes(value)) services.push(s);
else if (s.desc[$locale].toLowerCase().includes(value)) services.push(s);
});
}
function get_services() {
return services.filter((s) => {
return s.desc[$locale] !== "" && s.desc[$locale] !== null && s.desc[$locale] !== undefined;
});
}
</script>
<svelte:head>
<title>[ngn.tf] | services</title>
<meta content="[ngn] | services" property="og:title" />
<meta content="Stuff that I host" property="og:description" />
<meta content="https://ngn.tf" property="og:url" />
<meta content="#000000" data-react-helmet="true" name="theme-color" />
</svelte:head>
<Header><c>ls</c> services</Header>
<main>
<Card title="cat services/*/info.txt">
<div class="flexcol">
{#each data.services as services_list}
<div class="flexrow">
{#each services_list as service}
<Service url="{service.url}" desc="{service.desc}">{service.name}</Service>
{/each}
<Head title="services" desc="my self-hosted services and projects" />
<Header picture="cool" title={$_("services.title")} />
{#if data.error.length !== 0}
<Error error={data.error} />
{:else}
<main>
<div class="title">
<input oninput={change} type="text" placeholder={$_("services.search")} />
<div>
<Link icon="nf-fa-feed" link={api_urljoin("/news/" + $locale)}>{$_("services.feed")}</Link>
</div>
{/each}
</div>
</Card>
<Card title="cat services/details.txt">
Here some details for all the services I offer:
<ul>
<li>
<c><i class="nf nf-cod-account"></i> Registration:</c> All the services are offered for free, and all of them
are accessiable to public. And registrations are open for the all services that support account registrations.
</li>
<li>
<c><i class="nf nf-fa-eye_slash"></i> Privacy:</c> To protect user privacy, all the web proxy logs are cleared regularly.
I also do not use any kind of CDN, and provide SSL encrypted connection for all the services. You can also connect all the
services over TOR, as I do not block any traffic from TOR.
</li>
<li>
<c><i class="nf nf-oct-graph"></i> Uptime:</c> Some services get restarted regularly, also sometimes I have
issues with the hosting, so I cannot provide or guarantee %100 uptime for any of the services. I also cannot guarantee
the that there won't be any data loss. I do take any regular backups, but your data may be lost in case of something
like a SSD failure.
</li>
<li>
<c><i class="nf nf-md-speedometer"></i> Speed:</c> All the services are located in Turkey, and avaliable
over an 400 Mbit/s interface. If you are close to Turkey you should have a fairly good experience.
If you are not, then you should probably use another provider for the service.
</li>
</ul>
</Card>
</main>
</div>
<div class="services">
{#if get_services().length == 0}
<h3 class="none">{$_("services.none")}</h3>
{:else}
{#each get_services() as service}
<Service {service} />
{/each}
{/if}
</div>
</main>
{/if}
<style>
main {
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
padding: 50px;
gap: 28px;
}
.flexcol {
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
gap: 13px;
}
.flexrow {
display: flex;
flex-direction: row;
align-content: center;
justify-content: center;
width: 100%;
gap: 13px;
}
ul {
list-style: inside;
margin-bottom: 20px;
}
li {
padding-top: 30px;
line-height: 35px;
}
@media only screen and (max-width: 1316px) {
.flexrow {
flex-direction: column;
main {
padding: 50px;
text-align: right;
}
main .title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
main .none {
color: var(--white-3);
}
main .services {
margin-top: 20px;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: stretch;
gap: 28px;
}
@media only screen and (max-width: 1200px) {
main .services {
flex-direction: column;
}
}
}
</style>

21
app/static/animations.css Normal file
View File

@ -0,0 +1,21 @@
@keyframes blink {
0% {
opacity: 0;
}
}
@keyframes cursor {
to {
border-color: transparent;
}
}
@keyframes typing {
from {
width: 0%;
}
to {
width: 100%;
}
}

BIN
app/static/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -1,23 +1,38 @@
@import "./animations.css";
@import "./font.css";
:root {
--white: white;
--dark-one: black;
--dark-two: #050505;
--dark-three: #121212;
--dark-four: #101010;
--dark-fife: #3a3b3c;
--dark-six: #C0C0C0;
--radius: 8px;
/*
old shadow animation
--def-shadow: rgba(0, 0, 0, 0.25) 0px 54px 55px,
rgba(0, 0, 0, 0.12) 0px -12px 30px, rgba(0, 0, 0, 0.12) 0px 4px 6px,
rgba(0, 0, 0, 0.17) 0px 12px 13px, rgba(0, 0, 0, 0.09) 0px -3px 5px;
*/
--text-shadow: 0px 10px 20px rgba(90, 90, 90, 0.8);
--box-shadow: rgba(20, 20, 20, 0.19) 0px 10px 20px, rgba(30, 30, 30, 0.23) 0px 6px 6px;
--border-color: #2f2f2f;
--yellow: #d3b910;
--cyan: #0dd2e8;
--green: #06e00a;
--pinkish: #d506e0;
--red: #e8180d;
--blue: #2036f9;
--white-1: #ffffff;
--white-2: #bfbfbf;
--white-3: #5f5f5f;
--white-4: #0f0f0f;
--black-1: #000000;
--black-2: #050505;
--black-3: #111111;
--black-4: #3a3b3c;
--size-1: 8px;
--size-2: 16px;
--size-3: 18px;
--size-4: 20px;
--size-5: 24px;
--size-6: 30px;
--box-shadow-1: rgba(20, 20, 20, 0.19) 0px 10px 20px, rgba(30, 30, 30, 0.23) 0px 6px 6px;
--box-shadow-2: rgba(0, 0, 0, 0.35) 0px 30px 60px -12px inset,
rgba(20, 20, 20, 0.3) 0px 18px 36px -18px inset;
--text-shadow: 3px 2px 8px rgba(50, 50, 50, 0.8);
--background: linear-gradient(rgba(11, 11, 11, 0.808), rgba(1, 1, 1, 0.96)), url("/banner.png");
--profile-size: 220px;
}
* {
@ -25,8 +40,12 @@
margin: 0;
}
html {
box-sizing: border-box;
}
body {
background: var(--dark-one);
background: var(--black-1);
font-family: "Ubuntu", sans-serif;
overflow-x: hidden;
height: 100vh;
@ -35,7 +54,6 @@ body {
::selection {
background: rgba(100, 100, 100, 0.5);
text-decoration: underline;
}
::-webkit-scrollbar {
@ -45,261 +63,45 @@ body {
::-webkit-scrollbar-track {
border-radius: 10px;
background: #181818;
background: var(--black-1);
}
::-webkit-scrollbar-thumb {
border-radius: 10px;
background: #282828;
background: var(--black-2);
}
@keyframes colorAnimation {
100%,
0% {
color: rgb(255, 0, 0);
}
8% {
color: rgb(255, 127, 0);
}
16% {
color: rgb(255, 255, 0);
}
25% {
color: rgb(127, 255, 0);
}
33% {
color: rgb(0, 255, 0);
}
41% {
color: rgb(0, 255, 127);
}
50% {
color: rgb(0, 255, 255);
}
58% {
color: rgb(0, 127, 255);
}
66% {
color: rgb(0, 0, 255);
}
75% {
color: rgb(127, 0, 255);
}
83% {
color: rgb(255, 0, 255);
}
91% {
color: rgb(255, 0, 127);
}
a {
font-weight: 900;
color: var(--white-1);
text-decoration: none;
cursor: pointer;
}
@keyframes borderAnimation {
100%,
0% {
border-bottom-color: rgb(255, 0, 0);
}
8% {
border-bottom-color: rgb(255, 127, 0);
}
16% {
border-bottom-color: rgb(255, 255, 0);
}
25% {
border-bottom-color: rgb(127, 255, 0);
}
33% {
border-bottom-color: rgb(0, 255, 0);
}
41% {
border-bottom-color: rgb(0, 255, 127);
}
50% {
border-bottom-color: rgb(0, 255, 255);
}
58% {
border-bottom-color: rgb(0, 127, 255);
}
66% {
border-bottom-color: rgb(0, 0, 255);
}
75% {
border-bottom-color: rgb(127, 0, 255);
}
83% {
border-bottom-color: rgb(255, 0, 255);
}
91% {
border-bottom-color: rgb(255, 0, 127);
}
a:hover {
text-decoration: underline;
text-shadow: var(--text-shadow);
}
@keyframes fullBorderAnimation {
100%,
0% {
border-color: rgb(255, 0, 0);
}
8% {
border-color: rgb(255, 127, 0);
}
16% {
border-color: rgb(255, 255, 0);
}
25% {
border-color: rgb(127, 255, 0);
}
33% {
border-color: rgb(0, 255, 0);
}
41% {
border-color: rgb(0, 255, 127);
}
50% {
border-color: rgb(0, 255, 255);
}
58% {
border-color: rgb(0, 127, 255);
}
66% {
border-color: rgb(0, 0, 255);
}
75% {
border-color: rgb(127, 0, 255);
}
83% {
border-color: rgb(255, 0, 255);
}
91% {
border-color: rgb(255, 0, 127);
}
i .nf {
font-weight: 900;
}
@keyframes gayShadowAnimation {
100%,
0% {
box-shadow: rgba(255, 0, 0, 0.07) 0px 1px 2px,
rgba(255, 0, 0, 0.07) 0px 2px 4px, rgba(255, 0, 0, 0.07) 0px 4px 8px,
rgba(255, 0, 0, 0.07) 0px 8px 16px, rgba(255, 0, 0, 0.07) 0px 16px 32px,
rgba(255, 0, 0, 0.07) 0px 32px 64px;
}
8% {
box-shadow: rgba(255, 127, 0, 0.07) 0px 1px 2px,
rgba(255, 127, 0, 0.07) 0px 2px 4px, rgba(255, 127, 0, 0.07) 0px 4px 8px,
rgba(255, 127, 0, 0.07) 0px 8px 16px,
rgba(255, 127, 0, 0.07) 0px 16px 32px,
rgba(255, 127, 0, 0.07) 0px 32px 64px;
}
16% {
box-shadow: rgba(255, 255, 0, 0.07) 0px 1px 2px,
rgba(255, 255, 0, 0.07) 0px 2px 4px, rgba(255, 255, 0, 0.07) 0px 4px 8px,
rgba(255, 255, 0, 0.07) 0px 8px 16px,
rgba(255, 255, 0, 0.07) 0px 16px 32px,
rgba(255, 255, 0, 0.07) 0px 32px 64px;
}
25% {
box-shadow: rgba(127, 255, 0, 0.07) 0px 1px 2px,
rgba(127, 255, 0, 0.07) 0px 2px 4px, rgba(127, 255, 0, 0.07) 0px 4px 8px,
rgba(127, 255, 0, 0.07) 0px 8px 16px,
rgba(127, 255, 0, 0.07) 0px 16px 32px,
rgba(127, 255, 0, 0.07) 0px 32px 64px;
}
33% {
box-shadow: rgba(0, 255, 0, 0.07) 0px 1px 2px,
rgba(0, 255, 0, 0.07) 0px 2px 4px, rgba(0, 255, 0, 0.07) 0px 4px 8px,
rgba(0, 255, 0, 0.07) 0px 8px 16px, rgba(0, 255, 0, 0.07) 0px 16px 32px,
rgba(0, 255, 0, 0.07) 0px 32px 64px;
}
41% {
box-shadow: rgba(0, 255, 127, 0.07) 0px 1px 2px,
rgba(0, 255, 127, 0.07) 0px 2px 4px, rgba(0, 255, 127, 0.07) 0px 4px 8px,
rgba(0, 255, 127, 0.07) 0px 8px 16px,
rgba(0, 255, 127, 0.07) 0px 16px 32px,
rgba(0, 255, 127, 0.07) 0px 32px 64px;
}
50% {
box-shadow: rgba(0, 255, 255, 0.07) 0px 1px 2px,
rgba(0, 255, 255, 0.07) 0px 2px 4px, rgba(0, 255, 255, 0.07) 0px 4px 8px,
rgba(0, 255, 255, 0.07) 0px 8px 16px,
rgba(0, 255, 255, 0.07) 0px 16px 32px,
rgba(0, 255, 255, 0.07) 0px 32px 64px;
}
58% {
box-shadow: rgba(0, 127, 255, 0.07) 0px 1px 2px,
rgba(0, 127, 255, 0.07) 0px 2px 4px, rgba(0, 127, 255, 0.07) 0px 4px 8px,
rgba(0, 127, 255, 0.07) 0px 8px 16px,
rgba(0, 127, 255, 0.07) 0px 16px 32px,
rgba(0, 127, 255, 0.07) 0px 32px 64px;
}
66% {
box-shadow: rgba(0, 0, 255, 0.07) 0px 1px 2px,
rgba(0, 0, 255, 0.07) 0px 2px 4px, rgba(0, 0, 255, 0.07) 0px 4px 8px,
rgba(0, 0, 255, 0.07) 0px 8px 16px, rgba(0, 0, 255, 0.07) 0px 16px 32px,
rgba(0, 0, 255, 0.07) 0px 32px 64px;
}
75% {
box-shadow: rgba(127, 0, 255, 0.07) 0px 1px 2px,
rgba(127, 0, 255, 0.07) 0px 2px 4px, rgba(127, 0, 255, 0.07) 0px 4px 8px,
rgba(127, 0, 255, 0.07) 0px 8px 16px,
rgba(127, 0, 255, 0.07) 0px 16px 32px,
rgba(127, 0, 255, 0.07) 0px 32px 64px;
}
83% {
box-shadow: rgba(255, 0, 255, 0.07) 0px 1px 2px,
rgba(255, 0, 255, 0.07) 0px 2px 4px, rgba(255, 0, 255, 0.07) 0px 4px 8px,
rgba(255, 0, 255, 0.07) 0px 8px 16px,
rgba(255, 0, 255, 0.07) 0px 16px 32px,
rgba(255, 0, 255, 0.07) 0px 32px 64px;
}
91% {
box-shadow: rgba(255, 0, 127, 0.07) 0px 1px 2px,
rgba(255, 0, 127, 0.07) 0px 2px 4px, rgba(255, 0, 127, 0.07) 0px 4px 8px,
rgba(255, 0, 127, 0.07) 0px 8px 16px,
rgba(255, 0, 127, 0.07) 0px 16px 32px,
rgba(255, 0, 127, 0.07) 0px 32px 64px;
}
ul {
list-style: inside;
margin: 12px 0 12px 0;
}
@keyframes underlineAnimation {
100%,
0% {
text-decoration-color: rgb(255, 0, 0);
}
8% {
text-decoration-color: rgb(255, 127, 0);
}
16% {
text-decoration-color: rgb(255, 255, 0);
}
25% {
text-decoration-color: rgb(127, 255, 0);
}
33% {
text-decoration-color: rgb(0, 255, 0);
}
41% {
text-decoration-color: rgb(0, 255, 127);
}
50% {
text-decoration-color: rgb(0, 255, 255);
}
58% {
text-decoration-color: rgb(0, 127, 255);
}
66% {
text-decoration-color: rgb(0, 0, 255);
}
75% {
text-decoration-color: rgb(127, 0, 255);
}
83% {
text-decoration-color: rgb(255, 0, 255);
}
91% {
text-decoration-color: rgb(255, 0, 127);
}
li + li {
margin-top: 10px;
}
.c, c {
animation-name: colorAnimation;
animation-iteration-count: infinite;
animation-duration: 10s;
input {
background: var(--black-3);
border: none;
outline: none;
font-size: var(--size-4);
padding: 10px;
border: solid 1px var(--black-4);
color: var(--white-1);
}

View File

@ -5,7 +5,8 @@
margin: 0;
color: #c9d1d9;
background-color: #000;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
@ -38,7 +39,7 @@
.markdown-body h6:hover .anchor .octicon-link:before {
width: 16px;
height: 16px;
content: ' ';
content: " ";
display: inline-block;
background-color: currentColor;
-webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>");
@ -60,11 +61,6 @@
}
.markdown-body a {
animation-name: colorAnimation;
animation-iteration-count: infinite;
animation-duration: 10s;
background-color: transparent;
color: #58a6ff;
text-decoration: none;
}
@ -83,15 +79,15 @@
}
.markdown-body h1 {
margin: .67em 0;
margin: 0.67em 0;
font-weight: 600;
padding-bottom: .3em;
padding-bottom: 0.3em;
font-size: 2em;
border-bottom: 1px solid #21262d;
}
.markdown-body mark {
background-color: rgba(187,128,9,0.15);
background-color: rgba(187, 128, 9, 0.15);
color: #c9d1d9;
}
@ -139,7 +135,7 @@
overflow: hidden;
background: transparent;
border-bottom: 1px solid #21262d;
height: .25em;
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #30363d;
@ -155,31 +151,31 @@
line-height: inherit;
}
.markdown-body [type=button],
.markdown-body [type=reset],
.markdown-body [type=submit] {
.markdown-body [type="button"],
.markdown-body [type="reset"],
.markdown-body [type="submit"] {
-webkit-appearance: button;
}
.markdown-body [type=checkbox],
.markdown-body [type=radio] {
.markdown-body [type="checkbox"],
.markdown-body [type="radio"] {
box-sizing: border-box;
padding: 0;
}
.markdown-body [type=number]::-webkit-inner-spin-button,
.markdown-body [type=number]::-webkit-outer-spin-button {
.markdown-body [type="number"]::-webkit-inner-spin-button,
.markdown-body [type="number"]::-webkit-outer-spin-button {
height: auto;
}
.markdown-body [type=search]::-webkit-search-cancel-button,
.markdown-body [type=search]::-webkit-search-decoration {
.markdown-body [type="search"]::-webkit-search-cancel-button,
.markdown-body [type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
.markdown-body ::-webkit-input-placeholder {
color: inherit;
opacity: .54;
opacity: 0.54;
}
.markdown-body ::-webkit-file-upload-button {
@ -225,30 +221,30 @@
cursor: pointer;
}
.markdown-body details:not([open])>*:not(summary) {
.markdown-body details:not([open]) > *:not(summary) {
display: none !important;
}
.markdown-body a:focus,
.markdown-body [role=button]:focus,
.markdown-body input[type=radio]:focus,
.markdown-body input[type=checkbox]:focus {
.markdown-body [role="button"]:focus,
.markdown-body input[type="radio"]:focus,
.markdown-body input[type="checkbox"]:focus {
outline: 2px solid #58a6ff;
outline-offset: -2px;
box-shadow: none;
}
.markdown-body a:focus:not(:focus-visible),
.markdown-body [role=button]:focus:not(:focus-visible),
.markdown-body input[type=radio]:focus:not(:focus-visible),
.markdown-body input[type=checkbox]:focus:not(:focus-visible) {
.markdown-body [role="button"]:focus:not(:focus-visible),
.markdown-body input[type="radio"]:focus:not(:focus-visible),
.markdown-body input[type="checkbox"]:focus:not(:focus-visible) {
outline: solid 1px transparent;
}
.markdown-body a:focus-visible,
.markdown-body [role=button]:focus-visible,
.markdown-body input[type=radio]:focus-visible,
.markdown-body input[type=checkbox]:focus-visible {
.markdown-body [role="button"]:focus-visible,
.markdown-body input[type="radio"]:focus-visible,
.markdown-body input[type="checkbox"]:focus-visible {
outline: 2px solid #58a6ff;
outline-offset: -2px;
box-shadow: none;
@ -256,25 +252,32 @@
.markdown-body a:not([class]):focus,
.markdown-body a:not([class]):focus-visible,
.markdown-body input[type=radio]:focus,
.markdown-body input[type=radio]:focus-visible,
.markdown-body input[type=checkbox]:focus,
.markdown-body input[type=checkbox]:focus-visible {
.markdown-body input[type="radio"]:focus,
.markdown-body input[type="radio"]:focus-visible,
.markdown-body input[type="checkbox"]:focus,
.markdown-body input[type="checkbox"]:focus-visible {
outline-offset: 0;
}
.markdown-body kbd {
display: inline-block;
padding: 3px 5px;
font: 11px ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;
font:
11px ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
line-height: 10px;
color: #c9d1d9;
vertical-align: middle;
background-color: #161b22;
border: solid 1px rgba(110,118,129,0.4);
border-bottom-color: rgba(110,118,129,0.4);
border: solid 1px rgba(110, 118, 129, 0.4);
border-bottom-color: rgba(110, 118, 129, 0.4);
border-radius: 6px;
box-shadow: inset 0 -1px 0 rgba(110,118,129,0.4);
box-shadow: inset 0 -1px 0 rgba(110, 118, 129, 0.4);
}
.markdown-body h1,
@ -291,7 +294,7 @@
.markdown-body h2 {
font-weight: 600;
padding-bottom: .3em;
padding-bottom: 0.3em;
font-size: 1.5em;
border-bottom: 1px solid #21262d;
}
@ -308,12 +311,12 @@
.markdown-body h5 {
font-weight: 600;
font-size: .875em;
font-size: 0.875em;
}
.markdown-body h6 {
font-weight: 600;
font-size: .85em;
font-size: 0.85em;
color: #8b949e;
}
@ -326,7 +329,7 @@
margin: 0;
padding: 0 1em;
color: #8b949e;
border-left: .25em solid #30363d;
border-left: 0.25em solid #30363d;
}
.markdown-body ul,
@ -355,14 +358,28 @@
.markdown-body tt,
.markdown-body code,
.markdown-body samp {
font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 12px;
}
.markdown-body pre {
margin-top: 0;
margin-bottom: 0;
font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 12px;
word-wrap: normal;
}
@ -392,11 +409,11 @@
content: "";
}
.markdown-body>*:first-child {
.markdown-body > *:first-child {
margin-top: 0 !important;
}
.markdown-body>*:last-child {
.markdown-body > *:last-child {
margin-bottom: 0 !important;
}
@ -432,11 +449,11 @@
margin-bottom: 16px;
}
.markdown-body blockquote>:first-child {
.markdown-body blockquote > :first-child {
margin-top: 0;
}
.markdown-body blockquote>:last-child {
.markdown-body blockquote > :last-child {
margin-bottom: 0;
}
@ -481,7 +498,7 @@
.markdown-body h5 code,
.markdown-body h6 tt,
.markdown-body h6 code {
padding: 0 .2em;
padding: 0 0.2em;
font-size: inherit;
}
@ -515,19 +532,19 @@
list-style-type: none;
}
.markdown-body ol[type=a] {
.markdown-body ol[type="a"] {
list-style-type: lower-alpha;
}
.markdown-body ol[type=A] {
.markdown-body ol[type="A"] {
list-style-type: upper-alpha;
}
.markdown-body ol[type=i] {
.markdown-body ol[type="i"] {
list-style-type: lower-roman;
}
.markdown-body ol[type=I] {
.markdown-body ol[type="I"] {
list-style-type: upper-roman;
}
@ -535,7 +552,7 @@
list-style-type: decimal;
}
.markdown-body div>ol:not([type]) {
.markdown-body div > ol:not([type]) {
list-style-type: decimal;
}
@ -547,12 +564,12 @@
margin-bottom: 0;
}
.markdown-body li>p {
.markdown-body li > p {
margin-top: 16px;
}
.markdown-body li+li {
margin-top: .25em;
.markdown-body li + li {
margin-top: 0.25em;
}
.markdown-body dl {
@ -595,11 +612,11 @@
background-color: transparent;
}
.markdown-body img[align=right] {
.markdown-body img[align="right"] {
padding-left: 20px;
}
.markdown-body img[align=left] {
.markdown-body img[align="left"] {
padding-right: 20px;
}
@ -614,7 +631,7 @@
overflow: hidden;
}
.markdown-body span.frame>span {
.markdown-body span.frame > span {
display: block;
float: left;
width: auto;
@ -642,7 +659,7 @@
clear: both;
}
.markdown-body span.align-center>span {
.markdown-body span.align-center > span {
display: block;
margin: 13px auto 0;
overflow: hidden;
@ -660,7 +677,7 @@
clear: both;
}
.markdown-body span.align-right>span {
.markdown-body span.align-right > span {
display: block;
margin: 13px 0 0;
overflow: hidden;
@ -690,7 +707,7 @@
overflow: hidden;
}
.markdown-body span.float-right>span {
.markdown-body span.float-right > span {
display: block;
margin: 13px auto 0;
overflow: hidden;
@ -699,11 +716,11 @@
.markdown-body code,
.markdown-body tt {
padding: .2em .4em;
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
white-space: break-spaces;
background: var(--dark-two);
background: var(--black-3);
border-radius: 6px;
}
@ -724,7 +741,7 @@
font-size: 100%;
}
.markdown-body pre>code {
.markdown-body pre > code {
padding: 0;
margin: 0;
word-break: normal;
@ -748,8 +765,7 @@
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: var(--dark-two);
border-radius: 6px;
background-color: var(--black-3);
}
.markdown-body pre code,
@ -963,7 +979,7 @@
.markdown-body g-emoji {
display: inline-block;
min-width: 1ch;
font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 1em;
font-style: normal !important;
font-weight: 400;
@ -988,7 +1004,7 @@
cursor: pointer;
}
.markdown-body .task-list-item+.task-list-item {
.markdown-body .task-list-item + .task-list-item {
margin-top: 4px;
}
@ -997,12 +1013,12 @@
}
.markdown-body .task-list-item-checkbox {
margin: 0 .2em .25em -1.4em;
margin: 0 0.2em 0.25em -1.4em;
vertical-align: middle;
}
.markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox {
margin: 0 -1.6em .25em .2em;
margin: 0 -1.6em 0.25em 0.2em;
}
.markdown-body .contains-task-list {

BIN
app/static/profile/cool.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
app/static/profile/sad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,14 +1,14 @@
//import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import adapter from "@sveltejs/adapter-node";
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
},
kit: {
adapter: adapter(),
},
onwarn: (warning, handler) => {
if (warning.code === "a11y-click-events-have-key-events") return
handler(warning)
if (warning.code === "a11y-click-events-have-key-events") return;
handler(warning);
},
};

View File

@ -1,6 +1,36 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { fileURLToPath } from "url";
import { readFileSync } from "fs";
const default_env = {
REPORT_URL: "https://github.com/ngn13/website/issues",
SOURCE_URL: "https://github.com/ngn13/website",
APP_URL: "http://localhost:7001",
API_URL: "http://localhost:7002",
DOC_URL: "http://localhost:7003",
};
const file = fileURLToPath(new URL("package.json", import.meta.url));
const json = readFileSync(file, "utf8");
const pkg = JSON.parse(json);
for (let env in default_env) {
if (process.env["WEBSITE_" + env] === undefined) process.env["WEBSITE_" + env] = default_env[env];
}
export default defineConfig({
plugins: [sveltekit()]
plugins: [sveltekit()],
envPrefix: "WEBSITE",
preview: {
port: 7001,
strictPort: true,
},
server: {
port: 7001,
strictPort: true,
},
define: {
pkg: pkg,
},
});

61
deploy/compose.yml Normal file
View File

@ -0,0 +1,61 @@
services:
app:
container_name: "website_app"
image: website_app
build:
context: ./app
args:
WEBSITE_SOURCE_URL: "http://github.com/ngn13/website"
WEBSITE_REPORT_URL: "http://github.com/ngn13/website/issues"
WEBSITE_APP_URL: "http://localhost:7001"
WEBSITE_API_URL: "http://localhost:7002"
WEBSITE_DOC_URL: "http://doc:7003"
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
ports:
- "127.0.0.1:7001:7001"
restart: unless-stopped
depends_on:
- api
- doc
api:
container_name: "website_api"
image: website_api
build:
context: ./api
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
ports:
- "127.0.0.1:7002:7002"
volumes:
- ./data.db:/api/data.db:rw
restart: unless-stopped
environment:
WEBSITE_DEBUG: "false"
WEBSITE_APP_URL: "http://localhost:7001/"
WEBSITE_PASSWORD: "change_me"
WEBSITE_HOST: "0.0.0.0:7002"
WEBSITE_IP_HEADER: "X-Real-IP"
WEBSITE_INTERVAL: "1h"
WEBSITE_TIMEOUT: "15s"
WEBSITE_LIMIT: "5s"
doc:
container_name: "website_doc"
image: website_doc
read_only: true
build:
context: ./doc
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
restart: unless-stopped
environment:
WEBSITE_HOST: "0.0.0.0:7003"
WEBSITE_DOCS_DIR: "./docs"

9
deploy/run.sh Normal file
View File

@ -0,0 +1,9 @@
#!/bin/bash
if [ ! -f data.db ]; then
touch data.db
sudo chmod 1001:1001 data.db
fi
docker-compose build
docker-compose up -d

225
doc/.clang-format Normal file
View File

@ -0,0 +1,225 @@
---
Language: Cpp
# BasedOnStyle: LLVM
AccessModifierOffset: -2
AlignAfterOpenBracket: DontAlign
AlignArrayOfStructures: Left
AlignConsecutiveAssignments:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignCompound: false
PadOperators: true
AlignConsecutiveBitFields:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignCompound: false
PadOperators: false
AlignConsecutiveDeclarations:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignCompound: false
PadOperators: false
AlignConsecutiveMacros:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignCompound: false
PadOperators: false
AlignEscapedNewlines: Right
AlignOperands: Align
AlignTrailingComments:
Kind: Always
OverEmptyLines: 0
AllowAllArgumentsOnNextLine: true
AllowAllParametersOfDeclarationOnNextLine: true
AllowShortBlocksOnASingleLine: Never
AllowShortCaseLabelsOnASingleLine: false
AllowShortEnumsOnASingleLine: true
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: Never
AllowShortLambdasOnASingleLine: All
AllowShortLoopsOnASingleLine: false
AlwaysBreakAfterDefinitionReturnType: None
AlwaysBreakAfterReturnType: None
AlwaysBreakBeforeMultilineStrings: false
AlwaysBreakTemplateDeclarations: MultiLine
AttributeMacros:
- __capability
BinPackArguments: false
BinPackParameters: true
BitFieldColonSpacing: Both
BraceWrapping:
AfterCaseLabel: false
AfterClass: false
AfterControlStatement: Never
AfterEnum: false
AfterExternBlock: false
AfterFunction: false
AfterNamespace: false
AfterObjCDeclaration: false
AfterStruct: false
AfterUnion: false
BeforeCatch: false
BeforeElse: false
BeforeLambdaBody: false
BeforeWhile: false
IndentBraces: false
SplitEmptyFunction: true
SplitEmptyRecord: true
SplitEmptyNamespace: true
BreakAfterAttributes: Never
BreakAfterJavaFieldAnnotations: false
BreakArrays: true
BreakBeforeBinaryOperators: None
BreakBeforeConceptDeclarations: Always
BreakBeforeBraces: Attach
BreakBeforeInlineASMColon: OnlyMultiline
BreakBeforeTernaryOperators: true
BreakConstructorInitializers: BeforeColon
BreakInheritanceList: BeforeColon
BreakStringLiterals: true
ColumnLimit: 120
CommentPragmas: '^ IWYU pragma:'
CompactNamespaces: false
ConstructorInitializerIndentWidth: 4
ContinuationIndentWidth: 4
Cpp11BracedListStyle: true
DerivePointerAlignment: false
DisableFormat: false
EmptyLineAfterAccessModifier: Never
EmptyLineBeforeAccessModifier: LogicalBlock
ExperimentalAutoDetectBinPacking: false
FixNamespaceComments: true
ForEachMacros:
- foreach
- Q_FOREACH
- BOOST_FOREACH
IfMacros:
- KJ_IF_MAYBE
IncludeBlocks: Preserve
IncludeCategories:
- Regex: '^"(llvm|llvm-c|clang|clang-c)/'
Priority: 2
SortPriority: 0
CaseSensitive: false
- Regex: '^(<|"(gtest|gmock|isl|json)/)'
Priority: 3
SortPriority: 0
CaseSensitive: false
- Regex: '.*'
Priority: 1
SortPriority: 0
CaseSensitive: false
IncludeIsMainRegex: '(Test)?$'
IncludeIsMainSourceRegex: ''
IndentAccessModifiers: false
IndentCaseBlocks: false
IndentCaseLabels: false
IndentExternBlock: AfterExternBlock
IndentGotoLabels: true
IndentPPDirectives: None
IndentRequiresClause: true
IndentWidth: 2
IndentWrappedFunctionNames: false
InsertBraces: false
InsertNewlineAtEOF: false
InsertTrailingCommas: None
IntegerLiteralSeparator:
Binary: 0
BinaryMinDigits: 0
Decimal: 0
DecimalMinDigits: 0
Hex: 0
HexMinDigits: 0
JavaScriptQuotes: Leave
JavaScriptWrapImports: true
KeepEmptyLinesAtTheStartOfBlocks: true
LambdaBodyIndentation: Signature
LineEnding: DeriveLF
MacroBlockBegin: ''
MacroBlockEnd: ''
MaxEmptyLinesToKeep: 1
NamespaceIndentation: None
ObjCBinPackProtocolList: Auto
ObjCBlockIndentWidth: 2
ObjCBreakBeforeNestedBlockParam: true
ObjCSpaceAfterProperty: false
ObjCSpaceBeforeProtocolList: true
PackConstructorInitializers: BinPack
PenaltyBreakAssignment: 2
PenaltyBreakBeforeFirstCallParameter: 19
PenaltyBreakComment: 300
PenaltyBreakFirstLessLess: 120
PenaltyBreakOpenParenthesis: 0
PenaltyBreakString: 1000
PenaltyBreakTemplateDeclaration: 10
PenaltyExcessCharacter: 1000000
PenaltyIndentedWhitespace: 0
PenaltyReturnTypeOnItsOwnLine: 60
PointerAlignment: Right
PPIndentWidth: -1
QualifierAlignment: Leave
ReferenceAlignment: Pointer
ReflowComments: true
RemoveBracesLLVM: false
RemoveSemicolon: false
RequiresClausePosition: OwnLine
RequiresExpressionIndentation: OuterScope
SeparateDefinitionBlocks: Leave
ShortNamespaceLines: 1
SortIncludes: false
SortJavaStaticImport: Before
SortUsingDeclarations: LexicographicNumeric
SpaceAfterCStyleCast: false
SpaceAfterLogicalNot: false
SpaceAfterTemplateKeyword: true
SpaceAroundPointerQualifiers: Default
SpaceBeforeAssignmentOperators: true
SpaceBeforeCaseColon: false
SpaceBeforeCpp11BracedList: false
SpaceBeforeCtorInitializerColon: true
SpaceBeforeInheritanceColon: true
SpaceBeforeParens: ControlStatements
SpaceBeforeParensOptions:
AfterControlStatements: true
AfterForeachMacros: true
AfterFunctionDefinitionName: false
AfterFunctionDeclarationName: false
AfterIfMacros: true
AfterOverloadedOperator: false
AfterRequiresInClause: false
AfterRequiresInExpression: false
BeforeNonEmptyParentheses: false
SpaceBeforeRangeBasedForLoopColon: true
SpaceBeforeSquareBrackets: false
SpaceInEmptyBlock: false
SpaceInEmptyParentheses: false
SpacesBeforeTrailingComments: 1
SpacesInAngles: Never
SpacesInConditionalStatement: false
SpacesInContainerLiterals: true
SpacesInCStyleCastParentheses: false
SpacesInLineCommentPrefix:
Minimum: 1
Maximum: -1
SpacesInParentheses: false
SpacesInSquareBrackets: false
Standard: Latest
StatementAttributeLikeMacros:
- Q_EMIT
StatementMacros:
- Q_UNUSED
- QT_REQUIRE_VERSION
TabWidth: 8
UseTab: Never
WhitespaceSensitiveMacros:
- BOOST_PP_STRINGIZE
- CF_SWIFT_NAME
- NS_SWIFT_NAME
- PP_STRINGIZE
- STRINGIZE
...

4
doc/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
compile_commands.json
.cache
*.elf
dist

Some files were not shown because too many files have changed in this diff Show More