initial commit by simponic-infra
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Elizabeth Hunt 2025-01-05 16:39:13 -08:00
commit d25ec27fb1
Signed by: simponic
GPG Key ID: 2909B9A7FF6213EE
81 changed files with 2206 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
.env
whois
Dockerfile
*.db
.drone.yml

49
.drone.yml Normal file
View File

@ -0,0 +1,49 @@
---
kind: pipeline
type: docker
name: build
steps:
- name: run tests
image: golang
commands:
- go get
- go test -p 1 -v ./...
trigger:
event:
- pull_request
---
kind: pipeline
type: docker
name: cicd
steps:
- name: ci
image: plugins/docker
settings:
username:
from_secret: gitea_packpub_username
password:
from_secret: gitea_packpub_password
registry: git.simponic.xyz
repo: git.simponic.xyz/simponic/whois
- name: ssh
image: appleboy/drone-ssh
settings:
host: ryo.simponic.xyz
username: root
key:
from_secret: cd_ssh_key
port: 22
command_timeout: 2m
script:
- systemctl restart docker-compose@whois
trigger:
branch:
- main
event:
- push

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.env
whois
*.db

1
.tool-versions Normal file
View File

@ -0,0 +1 @@
golang 1.23.4

13
Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM golang:1.23
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/whois
EXPOSE 8080
CMD ["/app/whois", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/whois.db", "--static-path", "/app/static", "--scheduler", "--ntfy-topics", "whois", "--ntfy-endpoint", "https://ntfy.simponic.hatecomputers.club"]

3
README.md Normal file
View File

@ -0,0 +1,3 @@
## whois simponic.
this is a simponic service for whois

96
api/api.go Normal file
View File

@ -0,0 +1,96 @@
package api
import (
"database/sql"
"fmt"
"log"
"net/http"
"time"
"git.simponic.xyz/simponic/whois/api/template"
"git.simponic.xyz/simponic/whois/api/types"
"git.simponic.xyz/simponic/whois/api/whois"
"git.simponic.xyz/simponic/whois/args"
"git.simponic.xyz/simponic/whois/utils"
)
func LogRequestContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
context.Start = time.Now()
context.Id = utils.RandomId()
log.Println(req.Method, req.URL.Path, req.RemoteAddr, context.Id)
return success(context, req, resp)
}
}
func LogExecutionTimeContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
end := time.Now()
log.Println(context.Id, "took", end.Sub(context.Start))
return success(context, req, resp)
}
}
func HealthCheckContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
resp.WriteHeader(200)
resp.Write([]byte("healthy"))
return success(context, req, resp)
}
}
func FailurePassingContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(_success types.Continuation, failure types.Continuation) types.ContinuationChain {
return failure(context, req, resp)
}
}
func IdContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
return success(context, req, resp)
}
}
func CacheControlMiddleware(next http.Handler, maxAge int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := fmt.Sprintf("public, max-age=%d", maxAge)
w.Header().Set("Cache-Control", header)
next.ServeHTTP(w, r)
})
}
func MakeMux(argv *args.Arguments, dbConn *sql.DB) *http.ServeMux {
mux := http.NewServeMux()
staticFileServer := http.FileServer(http.Dir(argv.StaticPath))
mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(staticFileServer, 3600)))
makeRequestContext := func() *types.RequestContext {
return &types.RequestContext{
DBConn: dbConn,
Args: argv,
TemplateData: &map[string]interface{}{},
}
}
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
LogRequestContinuation(requestContext, r, w)(HealthCheckContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
LogRequestContinuation(requestContext, r, w)(whois.FetchLatestName, FailurePassingContinuation)(template.TemplateContinuation("home.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
mux.HandleFunc("GET /name", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
LogRequestContinuation(requestContext, r, w)(whois.FetchLatestName, FailurePassingContinuation)(template.TemplateContinuation("name.tmpl", false), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
return mux
}

79
api/api_test.go Normal file
View File

@ -0,0 +1,79 @@
package api_test
import (
"database/sql"
"io"
"net/http/httptest"
"os"
"strings"
"testing"
"git.simponic.xyz/simponic/whois/api"
"git.simponic.xyz/simponic/whois/args"
"git.simponic.xyz/simponic/whois/database"
"git.simponic.xyz/simponic/whois/utils"
)
func setup(t *testing.T) (*sql.DB, *httptest.Server) {
randomDb := utils.RandomId()
testDb := database.MakeConn(&randomDb)
database.Migrate(testDb)
arguments := &args.Arguments{
TemplatePath: "../templates",
StaticPath: "../static",
}
mux := api.MakeMux(arguments, testDb)
testServer := httptest.NewServer(mux)
t.Cleanup(func() {
testServer.Close()
testDb.Close()
os.Remove(randomDb)
})
return testDb, testServer
}
func assertResponseCode(t *testing.T, resp *httptest.ResponseRecorder, statusCode int) {
if resp.Code != statusCode {
t.Errorf("code is unexpected: %d, expected %d", resp.Code, statusCode)
}
}
func assertResponseBody(t *testing.T, resp *httptest.ResponseRecorder, body string) {
buf := new(strings.Builder)
_, err := io.Copy(buf, resp.Body)
if err != nil {
panic("could not read response body")
}
bodyStr := buf.String()
if bodyStr != body {
t.Errorf("body is unexpected: %s, expected %s", bodyStr, body)
}
}
func TestHealthcheck(t *testing.T) {
_, testServer := setup(t)
req := httptest.NewRequest("GET", "/health", nil)
resp := httptest.NewRecorder()
testServer.Config.Handler.ServeHTTP(resp, req)
assertResponseCode(t, resp, 200)
assertResponseBody(t, resp, "healthy")
}
func TestCachingStaticFiles(t *testing.T) {
_, testServer := setup(t)
req := httptest.NewRequest("GET", "/static/css/styles.css", nil)
resp := httptest.NewRecorder()
testServer.Config.Handler.ServeHTTP(resp, req)
assertResponseCode(t, resp, 200)
if resp.Header().Get("Cache-Control") != "public, max-age=3600" {
t.Errorf("client cache will live indefinitely for static files, which is probably not great! %s", resp.Header().Get("Cache-Control"))
}
}

73
api/template/template.go Normal file
View File

@ -0,0 +1,73 @@
package template
import (
"bytes"
"errors"
"html/template"
"log"
"net/http"
"os"
"git.simponic.xyz/simponic/whois/api/types"
)
func renderTemplate(context *types.RequestContext, templateName string, showBaseHtml bool) (bytes.Buffer, error) {
templatePath := context.Args.TemplatePath
basePath := templatePath + "/base_empty.html"
if showBaseHtml {
basePath = templatePath + "/base.html"
}
templateLocation := templatePath + "/" + templateName
tmpl, err := template.New("").ParseFiles(templateLocation, basePath)
if err != nil {
return bytes.Buffer{}, err
}
dataPtr := context.TemplateData
if dataPtr == nil {
dataPtr = &map[string]interface{}{}
}
data := *dataPtr
var buffer bytes.Buffer
err = tmpl.ExecuteTemplate(&buffer, "base", data)
if err != nil {
return bytes.Buffer{}, err
}
return buffer, nil
}
func TemplateContinuation(path string, showBase bool) types.Continuation {
return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
html, err := renderTemplate(context, path, showBase)
if errors.Is(err, os.ErrNotExist) {
resp.WriteHeader(404)
html, err = renderTemplate(context, "404.html", true)
if err != nil {
log.Println("error rendering 404 template", err)
resp.WriteHeader(500)
return failure(context, req, resp)
}
resp.Header().Set("Content-Type", "text/html")
resp.Write(html.Bytes())
return failure(context, req, resp)
}
if err != nil {
log.Println("error rendering template", err)
resp.WriteHeader(500)
resp.Write([]byte("error rendering template"))
return failure(context, req, resp)
}
resp.Header().Set("Content-Type", "text/html")
resp.Write(html.Bytes())
return success(context, req, resp)
}
}
}

26
api/types/types.go Normal file
View File

@ -0,0 +1,26 @@
package types
import (
"database/sql"
"net/http"
"time"
"git.simponic.xyz/simponic/whois/args"
)
type RequestContext struct {
DBConn *sql.DB
Args *args.Arguments
Id string
Start time.Time
TemplateData *map[string]interface{}
}
type BannerMessages struct {
Messages []string
}
type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain
type ContinuationChain func(Continuation, Continuation) ContinuationChain

94
args/args.go Normal file
View File

@ -0,0 +1,94 @@
package args
import (
"flag"
"fmt"
"os"
"strings"
"sync"
)
type Arguments struct {
DatabasePath string
TemplatePath string
StaticPath string
Migrate bool
Scheduler bool
NtfyEndpoint string
NtfyTopics []string
NtfyListener bool
Port int
Server bool
}
func isDirectory(path string) (bool, error) {
fileInfo, err := os.Stat(path)
if err != nil {
return false, err
}
return fileInfo.IsDir(), err
}
func validateArgs(args *Arguments) error {
templateIsDir, err := isDirectory(args.TemplatePath)
if err != nil || !templateIsDir {
return fmt.Errorf("template path is not an accessible directory %s", err)
}
staticPathIsDir, err := isDirectory(args.StaticPath)
if err != nil || !staticPathIsDir {
return fmt.Errorf("static path is not an accessible directory %s", err)
}
return nil
}
var lock = &sync.Mutex{}
var args *Arguments
func GetArgs() (*Arguments, error) {
lock.Lock()
defer lock.Unlock()
if args != nil {
return args, nil
}
databasePath := flag.String("database-path", "./whois.db", "Path to the SQLite database")
templatePath := flag.String("template-path", "./templates", "Path to the template directory")
staticPath := flag.String("static-path", "./static", "Path to the static directory")
ntfyEndpoint := flag.String("ntfy-endpoint", "https://ntfy.simponic.hatecomputers.club", "NTFY Endpoint")
ntfyTopics := flag.String("ntfy-topics", "testtopic", "Comma-separated NTFY Topics")
ntfyListener := flag.Bool("ntfy-listener", false, "Listen to NTFY Topic and propagate messages")
scheduler := flag.Bool("scheduler", false, "Run scheduled jobs via cron")
migrate := flag.Bool("migrate", false, "Run the migrations")
port := flag.Int("port", 8080, "Port to listen on")
server := flag.Bool("server", false, "Run the server")
flag.Parse()
args = &Arguments{
DatabasePath: *databasePath,
TemplatePath: *templatePath,
StaticPath: *staticPath,
Port: *port,
Server: *server,
Migrate: *migrate,
Scheduler: *scheduler,
NtfyEndpoint: *ntfyEndpoint,
NtfyTopics: strings.Split(*ntfyTopics, ","),
NtfyListener: *ntfyListener,
}
err := validateArgs(args)
if err != nil {
return nil, err
}
return args, nil
}

17
database/conn.go Normal file
View File

@ -0,0 +1,17 @@
package database
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
"log"
)
func MakeConn(databasePath *string) *sql.DB {
log.Println("opening database at", *databasePath, "with foreign keys enabled")
dbConn, err := sql.Open("sqlite3", *databasePath+"?_foreign_keys=on")
if err != nil {
panic(err)
}
return dbConn
}

46
database/migrate.go Normal file
View File

@ -0,0 +1,46 @@
package database
import (
"log"
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
type Migrator func(*sql.DB) (*sql.DB, error)
func AddWhoisUpdates(dbConn *sql.DB) (*sql.DB, error) {
_, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS updates (
name TEXT,
time TIMESTAMP
);`)
if err != nil {
return dbConn, err
}
log.Println("creating time index on updates table")
_, err = dbConn.Exec(`CREATE INDEX IF NOT EXISTS idx_update_timestamp ON updates (time);`)
if err != nil {
return dbConn, err
}
return dbConn, nil
}
func Migrate(dbConn *sql.DB) (*sql.DB, error) {
log.Println("migrating database")
migrations := []Migrator{
AddWhoisUpdates,
}
for _, migration := range migrations {
dbConn, err := migration(dbConn)
if err != nil {
return dbConn, err
}
}
return dbConn, nil
}

44
database/updates.go Normal file
View File

@ -0,0 +1,44 @@
package database
import (
"database/sql"
"time"
)
type Update struct {
Name string
Time time.Time
}
type ListUpdatesQuery struct {
Limit int
}
func ListUpdates(dbConn *sql.DB, query ListUpdatesQuery) ([]Update, error) {
rows, err := dbConn.Query(`SELECT name, time FROM updates ORDER BY time DESC LIMIT ?;`, query.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
updates := []Update{}
for rows.Next() {
var update Update
err := rows.Scan(&update.Name, &update.Time)
if err != nil {
return nil, err
}
updates = append(updates, update)
}
return updates, nil
}
func SaveUpdate(db *sql.DB, update *Update) (*Update, error) {
_, err := db.Exec("INSERT INTO updates (time, name) VALUES (?, ?)", update.Time, update.Name)
if err != nil {
return nil, err
}
return update, nil
}

16
docker-compose.yml Normal file
View File

@ -0,0 +1,16 @@
version: "3"
services:
api:
restart: always
image: git.simponic.xyz/simponic/whois
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:8080/api/health"]
interval: 5s
timeout: 10s
retries: 5
env_file: .env
volumes:
- ./db:/app/db
ports:
- "127.0.0.1:8466:8080"

16
go.mod Normal file
View File

@ -0,0 +1,16 @@
module git.simponic.xyz/simponic/whois
go 1.23.4
require (
github.com/go-co-op/gocron/v2 v2.14.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
)
require (
github.com/google/uuid v1.6.0 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
)

24
go.sum Normal file
View File

@ -0,0 +1,24 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-co-op/gocron/v2 v2.14.0 h1:bWPJeIdd4ioqiEpLLD1BVSTrtae7WABhX/WaVJbKVqg=
github.com/go-co-op/gocron/v2 v2.14.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

82
main.go Normal file
View File

@ -0,0 +1,82 @@
package main
import (
"fmt"
"log"
"net/http"
"time"
"git.simponic.xyz/simponic/whois/api"
"git.simponic.xyz/simponic/whois/args"
"git.simponic.xyz/simponic/whois/database"
"git.simponic.xyz/simponic/whois/ntfy"
"git.simponic.xyz/simponic/whois/scheduler"
"github.com/joho/godotenv"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
err := godotenv.Load()
if err != nil {
log.Println("could not load .env file:", err)
}
argv, err := args.GetArgs()
if err != nil {
log.Fatal(err)
}
dbConn := database.MakeConn(&argv.DatabasePath)
defer dbConn.Close()
if argv.Migrate {
_, err = database.Migrate(dbConn)
if err != nil {
log.Fatal(err)
}
log.Println("database migrated successfully")
}
if argv.NtfyListener {
ntfy := ntfy.MakeNtfyWatcher(argv.NtfyEndpoint, argv.NtfyTopics)
notifications := ntfy.Watch()
go func() {
for notification := range notifications {
message := notification.Message
update := database.Update{
Time: time.Now().UTC(),
Name: message,
}
database.SaveUpdate(dbConn, &update)
log.Println("saved update", update)
}
}()
}
if argv.Scheduler {
go func() {
scheduler.StartScheduler(dbConn, argv)
}()
}
if argv.Server {
mux := api.MakeMux(argv, dbConn)
log.Println("🚀🚀 whois API listening on port", argv.Port)
go func() {
server := &http.Server{
Addr: ":" + fmt.Sprint(argv.Port),
Handler: mux,
}
err = server.ListenAndServe()
if err != nil {
log.Fatal(err)
}
}()
}
if argv.Server || argv.Scheduler || argv.NtfyListener {
select {} // block forever
}
}

16
ntfy/publisher.go Normal file
View File

@ -0,0 +1,16 @@
package ntfy
import (
"net/http"
"strings"
)
func SendMessage(message string, endpoint string, topics []string) error {
for _, topic := range topics {
_, err := http.Post(endpoint+"/"+topic, "text/plain", strings.NewReader(message))
if err != nil {
return err
}
}
return nil
}

96
ntfy/watcher.go Normal file
View File

@ -0,0 +1,96 @@
package ntfy
import (
"bufio"
"encoding/json"
"log"
"net/http"
"net/url"
"path"
"time"
)
type Message struct {
Id string `json:"id"`
Time int `json:"time"`
Message string `json:"message"`
Event string `json:"event"`
}
type NtfyWatcher struct {
Endpoint string
Topics []string
}
func (w *NtfyWatcher) Watch() chan Message {
notifications := make(chan Message)
for _, topic := range w.Topics {
log.Println("subscribing to topic:", topic)
go func() {
retryCount := 5
retryTime := 5 * time.Second
retries := retryCount
sleepAndDecrementRetry := func() {
log.Println("waiting 5 seconds before reconnecting. retries left:", retries, "topic:", topic, "endpoint:", w.Endpoint)
time.Sleep(retryTime)
retries--
}
for true {
if retries == 0 {
log.Fatal("too many retries, exiting")
}
endpoint, _ := url.JoinPath(w.Endpoint, path.Join(topic, "json"))
resp, err := http.Get(endpoint)
if err != nil {
log.Println("error connecting to endpoint:", err)
sleepAndDecrementRetry()
continue
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
bytes := scanner.Bytes()
var msg Message
err := json.Unmarshal(bytes, &msg)
if err != nil {
log.Println("could not unmarshal message:", err)
continue
}
if msg.Event == "keepalive" {
log.Println("received keepalive message")
continue
}
if msg.Event != "message" {
log.Println("received unknown event:", msg.Event)
continue
}
log.Println("received notification:", msg)
notifications <- msg
retries = retryCount // reset retries
}
if err := scanner.Err(); err != nil {
log.Println("error reading response body:", err)
sleepAndDecrementRetry()
}
}
}()
}
return notifications
}
func MakeNtfyWatcher(endpoint string, topics []string) *NtfyWatcher {
return &NtfyWatcher{
Endpoint: endpoint,
Topics: topics,
}
}

34
scheduler/scheduler.go Normal file
View File

@ -0,0 +1,34 @@
package scheduler
import (
"database/sql"
"log"
"time"
"git.simponic.xyz/simponic/whois/args"
"github.com/go-co-op/gocron/v2"
)
func StartScheduler(_dbConn *sql.DB, argv *args.Arguments) {
scheduler, err := gocron.NewScheduler()
if err != nil {
panic("could not create scheduler")
}
_, err = scheduler.NewJob(
gocron.DurationJob(
24*time.Hour,
),
gocron.NewTask(
func(msg string) {
log.Println(msg)
},
"it's a beautiful new day!",
),
)
if err != nil {
panic("could not create job")
}
scheduler.Start()
}

BIN
static/.DS_Store vendored Normal file

Binary file not shown.

55
static/css/colors.css Normal file
View File

@ -0,0 +1,55 @@
/* Colors inspired by "day/night-fox" schemes */
:root {
/* Light mode colors */
--background-color-light: #f6f2ee; /* base00 */
--background-color-light-2: #dbd1dd; /* base01 */
--text-color-light: #3d2b5a; /* base05 */
--confirm-color-light: #396847; /* base0B */
--link-color-light: #6e33ce; /* base0E */
--container-bg-light: #f4ece6; /* base07 */
--border-color-light: #2848a9; /* base0D */
--error-color-light: #a5222f; /* base08 */
/* Dark mode colors */
--background-color-dark: #192330; /* base00 */
--background-color-dark-2: #212e3f; /* base01 */
--text-color-dark: #cdcecf; /* base05 */
--confirm-color-dark: #81b29a; /* base0B */
--link-color-dark: #9d79d6; /* base0E */
--container-bg-dark: #29394f; /* base02 */
--border-color-dark: #719cd6; /* base0D */
--error-color-dark: #c94f6d; /* base08 */
}
[data-theme="DARK"] {
--background-color: var(--background-color-dark);
--background-color-2: var(--background-color-dark-2);
--text-color: var(--text-color-dark);
--link-color: var(--link-color-dark);
--container-bg: var(--container-bg-dark);
--border-color: var(--border-color-dark);
--error-color: var(--error-color-dark);
--confirm-color: var(--confirm-color-dark);
}
[data-theme="LIGHT"] {
--background-color: var(--background-color-light);
--background-color-2: var(--background-color-light-2);
--text-color: var(--text-color-light);
--link-color: var(--link-color-light);
--container-bg: var(--container-bg-light);
--border-color: var(--border-color-light);
--error-color: var(--error-color-light);
--confirm-color: var(--confirm-color-light);
}
.error {
background-color: var(--error-color);
padding: 1rem;
}
.success {
background-color: var(--confirm-color);
padding: 1rem;
}

42
static/css/form.css Normal file
View File

@ -0,0 +1,42 @@
.form {
max-width: 600px;
padding: 1em;
background: var(--background-color-2);
border: 1px solid #ccc;
}
label {
display: block;
margin: 0 0 1em;
font-weight: bold;
}
input {
display: block;
width: 100%;
padding: 0.5em;
margin: 0 0 1em;
border: 1px solid var(--border-color);
background: var(--container-bg);
}
button,
input[type="submit"] {
padding: 0.5em 1em;
background: var(--link-color);
color: var(--text-color);
border: 0;
cursor: pointer;
}
textarea {
display: block;
width: 100%;
padding: 0.5em;
margin: 0 0 1em;
border: 1px solid var(--border-color);
background: var(--container-bg);
resize: vertical;
min-height: 100px;
}

60
static/css/styles.css Normal file
View File

@ -0,0 +1,60 @@
@import "/static/css/colors.css";
@import "/static/css/form.css";
@import "/static/css/table.css";
@import "/static/css/chat.css";
@font-face {
font-family: 'GeistMono';
src: url('/static/fonts/GeistMono-Medium.ttf') format('truetype');
}
* {
box-sizing: border-box;
font-family: GeistMono;
}
html {
margin: 0;
padding: 0;
color: var(--text-color);
}
body {
background-color: var(--background-color);
min-height: 100vh;
}
hr {
border: 0;
border-top: 1px solid var(--text-color);
margin: 20px 0;
}
.container {
max-width: 1600px;
margin: auto;
background-color: var(--container-bg);
padding: 1rem;
}
a {
color: var(--link-color);
text-decoration: none;
font-weight: bold;
}
a:hover {
text-decoration: underline;
}
.info {
margin-bottom: 1rem;
max-width: 600px;
transition: opacity 0.3s;
}
.info:hover {
opacity: 0.8;
}

28
static/css/table.css Normal file
View File

@ -0,0 +1,28 @@
@import "/static/css/colors.css";
table {
width: auto;
border-collapse: collapse;
border: 1px solid var(--border-color);
}
th,
td {
padding: 12px 20px;
text-align: center;
border-bottom: 1px solid var(--border-color);
}
th,
thead {
background-color: var(--background-color-2);
}
tbody tr:nth-child(odd) {
background-color: var(--background-color);
color: var(--text-color);
}
tbody tr {
transition: background-color 0.3s ease;
}

BIN
static/fonts/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
static/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

View File

@ -0,0 +1,7 @@
const timeElements = document.querySelectorAll(".time");
timeElements.forEach((timeElement) => {
const dateStr = timeElement.textContent.split(" ").slice(0, 3).join(" ");
const date = new Date(dateStr);
timeElement.textContent = date.toLocaleString();
});

View File

@ -0,0 +1,6 @@
const infoBanners = document.querySelectorAll(".info");
Array.from(infoBanners).forEach((infoBanner) => {
infoBanner.addEventListener("click", () => {
infoBanner.remove();
});
});

View File

@ -0,0 +1,27 @@
const THEMES = {
DARK: "DARK",
LIGHT: "LIGHT",
};
const flipFlopTheme = (theme) =>
THEMES[theme] === THEMES.DARK ? THEMES.LIGHT : THEMES.DARK;
const themePickerText = {
DARK: "light mode.",
LIGHT: "dark mode.",
};
const themeSwitcher = document.getElementById("theme-switcher");
const setTheme = (theme) => {
themeSwitcher.textContent = `${themePickerText[theme]}`;
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
};
themeSwitcher.addEventListener("click", () =>
setTheme(flipFlopTheme(document.documentElement.getAttribute("data-theme"))),
);
setTheme(localStorage.getItem("theme") || THEMES.LIGHT);

5
static/js/require.js Normal file

File diff suppressed because one or more lines are too long

6
static/js/script.js Normal file
View File

@ -0,0 +1,6 @@
const scripts = [
"/static/js/components/themeSwitcher.js",
"/static/js/components/formatDate.js",
"/static/js/components/infoBanners.js",
];
requirejs(scripts);

View File

@ -0,0 +1,11 @@
const preferredMode = window.matchMedia("(prefers-color-scheme: dark)").matches
? "DARK"
: "LIGHT";
// sets theme before rendering & jquery loaded to prevent flashing of uninitialized theme
// (ugly white background)
localStorage.setItem("theme", localStorage.getItem("theme") || preferredMode);
document.documentElement.setAttribute(
"data-theme",
localStorage.getItem("theme"),
);

BIN
template/.DS_Store vendored Normal file

Binary file not shown.

5
template/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
.env
whois
Dockerfile
*.db
.drone.yml

49
template/.drone.yml Normal file
View File

@ -0,0 +1,49 @@
---
kind: pipeline
type: docker
name: build
steps:
- name: run tests
image: golang
commands:
- go get
- go test -p 1 -v ./...
trigger:
event:
- pull_request
---
kind: pipeline
type: docker
name: cicd
steps:
- name: ci
image: plugins/docker
settings:
username:
from_secret: gitea_packpub_username
password:
from_secret: gitea_packpub_password
registry: git.simponic.xyz
repo: git.simponic.xyz/simponic/whois
- name: ssh
image: appleboy/drone-ssh
settings:
host: ryo.simponic.xyz
username: root
key:
from_secret: cd_ssh_key
port: 22
command_timeout: 2m
script:
- systemctl restart docker-compose@whois
trigger:
branch:
- main
event:
- push

3
template/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.env
whois
*.db

1
template/.tool-versions Normal file
View File

@ -0,0 +1 @@
golang 1.23.4

13
template/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM golang:1.23
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/whois
EXPOSE 8080
CMD ["/app/whois", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/whois.db", "--static-path", "/app/static", "--scheduler", "--ntfy-topics", "whois", "--ntfy-endpoint", "https://ntfy.simponic.hatecomputers.club"]

3
template/README.md Normal file
View File

@ -0,0 +1,3 @@
## whois simponic.
this is a simponic service for whois

91
template/api/api.go Normal file
View File

@ -0,0 +1,91 @@
package api
import (
"database/sql"
"fmt"
"log"
"net/http"
"time"
"git.simponic.xyz/simponic/whois/api/template"
"git.simponic.xyz/simponic/whois/api/types"
"git.simponic.xyz/simponic/whois/args"
"git.simponic.xyz/simponic/whois/utils"
)
func LogRequestContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
context.Start = time.Now()
context.Id = utils.RandomId()
log.Println(req.Method, req.URL.Path, req.RemoteAddr, context.Id)
return success(context, req, resp)
}
}
func LogExecutionTimeContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
end := time.Now()
log.Println(context.Id, "took", end.Sub(context.Start))
return success(context, req, resp)
}
}
func HealthCheckContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
resp.WriteHeader(200)
resp.Write([]byte("healthy"))
return success(context, req, resp)
}
}
func FailurePassingContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(_success types.Continuation, failure types.Continuation) types.ContinuationChain {
return failure(context, req, resp)
}
}
func IdContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
return success(context, req, resp)
}
}
func CacheControlMiddleware(next http.Handler, maxAge int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := fmt.Sprintf("public, max-age=%d", maxAge)
w.Header().Set("Cache-Control", header)
next.ServeHTTP(w, r)
})
}
func MakeMux(argv *args.Arguments, dbConn *sql.DB) *http.ServeMux {
mux := http.NewServeMux()
staticFileServer := http.FileServer(http.Dir(argv.StaticPath))
mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(staticFileServer, 3600)))
makeRequestContext := func() *types.RequestContext {
return &types.RequestContext{
DBConn: dbConn,
Args: argv,
TemplateData: &map[string]interface{}{},
}
}
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
LogRequestContinuation(requestContext, r, w)(HealthCheckContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
requestContext := makeRequestContext()
(*requestContext.TemplateData)["Service"] = "whois"
templateFile := "hello.html"
LogRequestContinuation(requestContext, r, w)(template.TemplateContinuation(templateFile, true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
})
return mux
}

89
template/api/api_test.go Normal file
View File

@ -0,0 +1,89 @@
package api_test
import (
"database/sql"
"io"
"net/http/httptest"
"os"
"strings"
"testing"
"git.simponic.xyz/simponic/whois/api"
"git.simponic.xyz/simponic/whois/args"
"git.simponic.xyz/simponic/whois/database"
"git.simponic.xyz/simponic/whois/utils"
)
func setup(t *testing.T) (*sql.DB, *httptest.Server) {
randomDb := utils.RandomId()
testDb := database.MakeConn(&randomDb)
database.Migrate(testDb)
arguments := &args.Arguments{
TemplatePath: "../templates",
StaticPath: "../static",
}
mux := api.MakeMux(arguments, testDb)
testServer := httptest.NewServer(mux)
t.Cleanup(func() {
testServer.Close()
testDb.Close()
os.Remove(randomDb)
})
return testDb, testServer
}
func assertResponseCode(t *testing.T, resp *httptest.ResponseRecorder, statusCode int) {
if resp.Code != statusCode {
t.Errorf("code is unexpected: %d, expected %d", resp.Code, statusCode)
}
}
func assertResponseBody(t *testing.T, resp *httptest.ResponseRecorder, body string) {
buf := new(strings.Builder)
_, err := io.Copy(buf, resp.Body)
if err != nil {
panic("could not read response body")
}
bodyStr := buf.String()
if bodyStr != body {
t.Errorf("body is unexpected: %s, expected %s", bodyStr, body)
}
}
func TestHealthcheck(t *testing.T) {
_, testServer := setup(t)
req := httptest.NewRequest("GET", "/health", nil)
resp := httptest.NewRecorder()
testServer.Config.Handler.ServeHTTP(resp, req)
assertResponseCode(t, resp, 200)
assertResponseBody(t, resp, "healthy")
}
func TestHello(t *testing.T) {
_, testServer := setup(t)
req := httptest.NewRequest("GET", "/", nil)
resp := httptest.NewRecorder()
testServer.Config.Handler.ServeHTTP(resp, req)
assertResponseCode(t, resp, 200)
}
func TestCachingStaticFiles(t *testing.T) {
_, testServer := setup(t)
req := httptest.NewRequest("GET", "/static/css/styles.css", nil)
resp := httptest.NewRecorder()
testServer.Config.Handler.ServeHTTP(resp, req)
assertResponseCode(t, resp, 200)
if resp.Header().Get("Cache-Control") != "public, max-age=3600" {
t.Errorf("client cache will live indefinitely for static files, which is probably not great! %s", resp.Header().Get("Cache-Control"))
}
}

View File

@ -0,0 +1,73 @@
package template
import (
"bytes"
"errors"
"html/template"
"log"
"net/http"
"os"
"git.simponic.xyz/simponic/whois/api/types"
)
func renderTemplate(context *types.RequestContext, templateName string, showBaseHtml bool) (bytes.Buffer, error) {
templatePath := context.Args.TemplatePath
basePath := templatePath + "/base_empty.html"
if showBaseHtml {
basePath = templatePath + "/base.html"
}
templateLocation := templatePath + "/" + templateName
tmpl, err := template.New("").ParseFiles(templateLocation, basePath)
if err != nil {
return bytes.Buffer{}, err
}
dataPtr := context.TemplateData
if dataPtr == nil {
dataPtr = &map[string]interface{}{}
}
data := *dataPtr
var buffer bytes.Buffer
err = tmpl.ExecuteTemplate(&buffer, "base", data)
if err != nil {
return bytes.Buffer{}, err
}
return buffer, nil
}
func TemplateContinuation(path string, showBase bool) types.Continuation {
return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
html, err := renderTemplate(context, path, showBase)
if errors.Is(err, os.ErrNotExist) {
resp.WriteHeader(404)
html, err = renderTemplate(context, "404.html", true)
if err != nil {
log.Println("error rendering 404 template", err)
resp.WriteHeader(500)
return failure(context, req, resp)
}
resp.Header().Set("Content-Type", "text/html")
resp.Write(html.Bytes())
return failure(context, req, resp)
}
if err != nil {
log.Println("error rendering template", err)
resp.WriteHeader(500)
resp.Write([]byte("error rendering template"))
return failure(context, req, resp)
}
resp.Header().Set("Content-Type", "text/html")
resp.Write(html.Bytes())
return success(context, req, resp)
}
}
}

View File

@ -0,0 +1,26 @@
package types
import (
"database/sql"
"net/http"
"time"
"git.simponic.xyz/simponic/whois/args"
)
type RequestContext struct {
DBConn *sql.DB
Args *args.Arguments
Id string
Start time.Time
TemplateData *map[string]interface{}
}
type BannerMessages struct {
Messages []string
}
type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain
type ContinuationChain func(Continuation, Continuation) ContinuationChain

94
template/args/args.go Normal file
View File

@ -0,0 +1,94 @@
package args
import (
"flag"
"fmt"
"os"
"strings"
"sync"
)
type Arguments struct {
DatabasePath string
TemplatePath string
StaticPath string
Migrate bool
Scheduler bool
NtfyEndpoint string
NtfyTopics []string
NtfyListener bool
Port int
Server bool
}
func isDirectory(path string) (bool, error) {
fileInfo, err := os.Stat(path)
if err != nil {
return false, err
}
return fileInfo.IsDir(), err
}
func validateArgs(args *Arguments) error {
templateIsDir, err := isDirectory(args.TemplatePath)
if err != nil || !templateIsDir {
return fmt.Errorf("template path is not an accessible directory %s", err)
}
staticPathIsDir, err := isDirectory(args.StaticPath)
if err != nil || !staticPathIsDir {
return fmt.Errorf("static path is not an accessible directory %s", err)
}
return nil
}
var lock = &sync.Mutex{}
var args *Arguments
func GetArgs() (*Arguments, error) {
lock.Lock()
defer lock.Unlock()
if args != nil {
return args, nil
}
databasePath := flag.String("database-path", "./whois.db", "Path to the SQLite database")
templatePath := flag.String("template-path", "./templates", "Path to the template directory")
staticPath := flag.String("static-path", "./static", "Path to the static directory")
ntfyEndpoint := flag.String("ntfy-endpoint", "https://ntfy.simponic.hatecomputers.club", "NTFY Endpoint")
ntfyTopics := flag.String("ntfy-topics", "testtopic", "Comma-separated NTFY Topics")
ntfyListener := flag.Bool("ntfy-listener", false, "Listen to NTFY Topic and propagate messages")
scheduler := flag.Bool("scheduler", false, "Run scheduled jobs via cron")
migrate := flag.Bool("migrate", false, "Run the migrations")
port := flag.Int("port", 8080, "Port to listen on")
server := flag.Bool("server", false, "Run the server")
flag.Parse()
args = &Arguments{
DatabasePath: *databasePath,
TemplatePath: *templatePath,
StaticPath: *staticPath,
Port: *port,
Server: *server,
Migrate: *migrate,
Scheduler: *scheduler,
NtfyEndpoint: *ntfyEndpoint,
NtfyTopics: strings.Split(*ntfyTopics, ","),
NtfyListener: *ntfyListener,
}
err := validateArgs(args)
if err != nil {
return nil, err
}
return args, nil
}

17
template/database/conn.go Normal file
View File

@ -0,0 +1,17 @@
package database
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
"log"
)
func MakeConn(databasePath *string) *sql.DB {
log.Println("opening database at", *databasePath, "with foreign keys enabled")
dbConn, err := sql.Open("sqlite3", *databasePath+"?_foreign_keys=on")
if err != nil {
panic(err)
}
return dbConn
}

View File

@ -0,0 +1,39 @@
package database
import (
"log"
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
type Migrator func(*sql.DB) (*sql.DB, error)
func DoNothing(dbConn *sql.DB) (*sql.DB, error) {
log.Println("doing nothing")
_, err := dbConn.Exec(`SELECT 0;`)
if err != nil {
return dbConn, err
}
return dbConn, nil
}
func Migrate(dbConn *sql.DB) (*sql.DB, error) {
log.Println("migrating database")
migrations := []Migrator{
DoNothing,
}
for _, migration := range migrations {
dbConn, err := migration(dbConn)
if err != nil {
return dbConn, err
}
}
return dbConn, nil
}

View File

@ -0,0 +1,16 @@
version: "3"
services:
api:
restart: always
image: git.simponic.xyz/simponic/whois
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:8080/api/health"]
interval: 5s
timeout: 10s
retries: 5
env_file: .env
volumes:
- ./db:/app/db
ports:
- "127.0.0.1:8466:8080"

9
template/go.mod Normal file
View File

@ -0,0 +1,9 @@
module git.simponic.xyz/simponic/whois
go 1.23.4
require (
github.com/go-co-op/gocron/v2 v2.14.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
)

76
template/main.go Normal file
View File

@ -0,0 +1,76 @@
package main
import (
"fmt"
"log"
"net/http"
"git.simponic.xyz/simponic/whois/api"
"git.simponic.xyz/simponic/whois/args"
"git.simponic.xyz/simponic/whois/database"
"git.simponic.xyz/simponic/whois/scheduler"
"git.simponic.xyz/simponic/whois/ntfy"
"github.com/joho/godotenv"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
err := godotenv.Load()
if err != nil {
log.Println("could not load .env file:", err)
}
argv, err := args.GetArgs()
if err != nil {
log.Fatal(err)
}
dbConn := database.MakeConn(&argv.DatabasePath)
defer dbConn.Close()
if argv.Migrate {
_, err = database.Migrate(dbConn)
if err != nil {
log.Fatal(err)
}
log.Println("database migrated successfully")
}
if argv.NtfyListener {
ntfy := ntfy.MakeNtfyWatcher(argv.NtfyEndpoint, argv.NtfyTopics)
notifications := ntfy.Watch()
go func() {
for notification := range notifications {
message := notification.Message
log.Println("got message", message)
}
}()
}
if argv.Scheduler {
go func() {
scheduler.StartScheduler(dbConn, argv)
}()
}
if argv.Server {
mux := api.MakeMux(argv, dbConn)
log.Println("🚀🚀 whois API listening on port", argv.Port)
go func() {
server := &http.Server{
Addr: ":" + fmt.Sprint(argv.Port),
Handler: mux,
}
err = server.ListenAndServe()
if err != nil {
log.Fatal(err)
}
}()
}
if argv.Server || argv.Scheduler || argv.NtfyListener {
select {} // block forever
}
}

View File

@ -0,0 +1,16 @@
package ntfy
import (
"net/http"
"strings"
)
func SendMessage(message string, endpoint string, topics []string) error {
for _, topic := range topics {
_, err := http.Post(endpoint+"/"+topic, "text/plain", strings.NewReader(message))
if err != nil {
return err
}
}
return nil
}

96
template/ntfy/watcher.go Normal file
View File

@ -0,0 +1,96 @@
package ntfy
import (
"bufio"
"encoding/json"
"log"
"net/http"
"net/url"
"path"
"time"
)
type Message struct {
Id string `json:"id"`
Time int `json:"time"`
Message string `json:"message"`
Event string `json:"event"`
}
type NtfyWatcher struct {
Endpoint string
Topics []string
}
func (w *NtfyWatcher) Watch() chan Message {
notifications := make(chan Message)
for _, topic := range w.Topics {
log.Println("subscribing to topic:", topic)
go func() {
retryCount := 5
retryTime := 5 * time.Second
retries := retryCount
sleepAndDecrementRetry := func() {
log.Println("waiting 5 seconds before reconnecting. retries left:", retries, "topic:", topic, "endpoint:", w.Endpoint)
time.Sleep(retryTime)
retries--
}
for true {
if retries == 0 {
log.Fatal("too many retries, exiting")
}
endpoint, _ := url.JoinPath(w.Endpoint, path.Join(topic, "json"))
resp, err := http.Get(endpoint)
if err != nil {
log.Println("error connecting to endpoint:", err)
sleepAndDecrementRetry()
continue
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
bytes := scanner.Bytes()
var msg Message
err := json.Unmarshal(bytes, &msg)
if err != nil {
log.Println("could not unmarshal message:", err)
continue
}
if msg.Event == "keepalive" {
log.Println("received keepalive message")
continue
}
if msg.Event != "message" {
log.Println("received unknown event:", msg.Event)
continue
}
log.Println("received notification:", msg)
notifications <- msg
retries = retryCount // reset retries
}
if err := scanner.Err(); err != nil {
log.Println("error reading response body:", err)
sleepAndDecrementRetry()
}
}
}()
}
return notifications
}
func MakeNtfyWatcher(endpoint string, topics []string) *NtfyWatcher {
return &NtfyWatcher{
Endpoint: endpoint,
Topics: topics,
}
}

View File

@ -0,0 +1,34 @@
package scheduler
import (
"database/sql"
"log"
"time"
"git.simponic.xyz/simponic/whois/args"
"github.com/go-co-op/gocron/v2"
)
func StartScheduler(_dbConn *sql.DB, argv *args.Arguments) {
scheduler, err := gocron.NewScheduler()
if err != nil {
panic("could not create scheduler")
}
_, err = scheduler.NewJob(
gocron.DurationJob(
24*time.Hour,
),
gocron.NewTask(
func(msg string) {
log.Println(msg)
},
"it's a beautiful new day!",
),
)
if err != nil {
panic("could not create job")
}
scheduler.Start()
}

BIN
template/static/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,55 @@
/* Colors inspired by "day/night-fox" schemes */
:root {
/* Light mode colors */
--background-color-light: #f6f2ee; /* base00 */
--background-color-light-2: #dbd1dd; /* base01 */
--text-color-light: #3d2b5a; /* base05 */
--confirm-color-light: #396847; /* base0B */
--link-color-light: #6e33ce; /* base0E */
--container-bg-light: #f4ece6; /* base07 */
--border-color-light: #2848a9; /* base0D */
--error-color-light: #a5222f; /* base08 */
/* Dark mode colors */
--background-color-dark: #192330; /* base00 */
--background-color-dark-2: #212e3f; /* base01 */
--text-color-dark: #cdcecf; /* base05 */
--confirm-color-dark: #81b29a; /* base0B */
--link-color-dark: #9d79d6; /* base0E */
--container-bg-dark: #29394f; /* base02 */
--border-color-dark: #719cd6; /* base0D */
--error-color-dark: #c94f6d; /* base08 */
}
[data-theme="DARK"] {
--background-color: var(--background-color-dark);
--background-color-2: var(--background-color-dark-2);
--text-color: var(--text-color-dark);
--link-color: var(--link-color-dark);
--container-bg: var(--container-bg-dark);
--border-color: var(--border-color-dark);
--error-color: var(--error-color-dark);
--confirm-color: var(--confirm-color-dark);
}
[data-theme="LIGHT"] {
--background-color: var(--background-color-light);
--background-color-2: var(--background-color-light-2);
--text-color: var(--text-color-light);
--link-color: var(--link-color-light);
--container-bg: var(--container-bg-light);
--border-color: var(--border-color-light);
--error-color: var(--error-color-light);
--confirm-color: var(--confirm-color-light);
}
.error {
background-color: var(--error-color);
padding: 1rem;
}
.success {
background-color: var(--confirm-color);
padding: 1rem;
}

View File

@ -0,0 +1,42 @@
.form {
max-width: 600px;
padding: 1em;
background: var(--background-color-2);
border: 1px solid #ccc;
}
label {
display: block;
margin: 0 0 1em;
font-weight: bold;
}
input {
display: block;
width: 100%;
padding: 0.5em;
margin: 0 0 1em;
border: 1px solid var(--border-color);
background: var(--container-bg);
}
button,
input[type="submit"] {
padding: 0.5em 1em;
background: var(--link-color);
color: var(--text-color);
border: 0;
cursor: pointer;
}
textarea {
display: block;
width: 100%;
padding: 0.5em;
margin: 0 0 1em;
border: 1px solid var(--border-color);
background: var(--container-bg);
resize: vertical;
min-height: 100px;
}

View File

@ -0,0 +1,60 @@
@import "/static/css/colors.css";
@import "/static/css/form.css";
@import "/static/css/table.css";
@import "/static/css/chat.css";
@font-face {
font-family: 'GeistMono';
src: url('/static/fonts/GeistMono-Medium.ttf') format('truetype');
}
* {
box-sizing: border-box;
font-family: GeistMono;
}
html {
margin: 0;
padding: 0;
color: var(--text-color);
}
body {
background-color: var(--background-color);
min-height: 100vh;
}
hr {
border: 0;
border-top: 1px solid var(--text-color);
margin: 20px 0;
}
.container {
max-width: 1600px;
margin: auto;
background-color: var(--container-bg);
padding: 1rem;
}
a {
color: var(--link-color);
text-decoration: none;
font-weight: bold;
}
a:hover {
text-decoration: underline;
}
.info {
margin-bottom: 1rem;
max-width: 600px;
transition: opacity 0.3s;
}
.info:hover {
opacity: 0.8;
}

View File

@ -0,0 +1,28 @@
@import "/static/css/colors.css";
table {
width: auto;
border-collapse: collapse;
border: 1px solid var(--border-color);
}
th,
td {
padding: 12px 20px;
text-align: center;
border-bottom: 1px solid var(--border-color);
}
th,
thead {
background-color: var(--background-color-2);
}
tbody tr:nth-child(odd) {
background-color: var(--background-color);
color: var(--text-color);
}
tbody tr {
transition: background-color 0.3s ease;
}

BIN
template/static/fonts/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

View File

@ -0,0 +1,7 @@
const timeElements = document.querySelectorAll(".time");
timeElements.forEach((timeElement) => {
const dateStr = timeElement.textContent.split(" ").slice(0, 3).join(" ");
const date = new Date(dateStr);
timeElement.textContent = date.toLocaleString();
});

View File

@ -0,0 +1,6 @@
const infoBanners = document.querySelectorAll(".info");
Array.from(infoBanners).forEach((infoBanner) => {
infoBanner.addEventListener("click", () => {
infoBanner.remove();
});
});

View File

@ -0,0 +1,27 @@
const THEMES = {
DARK: "DARK",
LIGHT: "LIGHT",
};
const flipFlopTheme = (theme) =>
THEMES[theme] === THEMES.DARK ? THEMES.LIGHT : THEMES.DARK;
const themePickerText = {
DARK: "light mode.",
LIGHT: "dark mode.",
};
const themeSwitcher = document.getElementById("theme-switcher");
const setTheme = (theme) => {
themeSwitcher.textContent = `${themePickerText[theme]}`;
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
};
themeSwitcher.addEventListener("click", () =>
setTheme(flipFlopTheme(document.documentElement.getAttribute("data-theme"))),
);
setTheme(localStorage.getItem("theme") || THEMES.LIGHT);

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,6 @@
const scripts = [
"/static/js/components/themeSwitcher.js",
"/static/js/components/formatDate.js",
"/static/js/components/infoBanners.js",
];
requirejs(scripts);

View File

@ -0,0 +1,11 @@
const preferredMode = window.matchMedia("(prefers-color-scheme: dark)").matches
? "DARK"
: "LIGHT";
// sets theme before rendering & jquery loaded to prevent flashing of uninitialized theme
// (ugly white background)
localStorage.setItem("theme", localStorage.getItem("theme") || preferredMode);
document.documentElement.setAttribute(
"data-theme",
localStorage.getItem("theme"),
);

View File

@ -0,0 +1,7 @@
{{ define "content" }}
<h1>page not found</h1>
<p><em>but hey, at least you found our witty 404 page. that's something, right?</em></p>
<p><a href="/">go back home</a></p>
{{ end }}

View File

@ -0,0 +1,34 @@
{{ define "base" }}
<!DOCTYPE html>
<html>
<head>
<title>whois simponic.</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link href="/static/img/favicon.ico" rel="icon" type="image/x-icon">
<link rel="stylesheet" type="text/css" href="/static/css/styles.css">
<meta property="og:type" content="website">
<meta property="og:url" content="https://whois.simponic.xyz">
<meta property="og:title" content="whois simponic.">
<meta property="og:description" content="whois simponic.">
<meta property="og:image:secure_url" content="https://whois.simponic.xyz/static/img/favicon.ico">
<meta property="og:image:secure" content="https://whois.simponic.xyz/static/img/favicon.ico">
<script src="/static/js/util/setThemeBeforeRender.js"></script>
</head>
<body>
<div id="content" class="container">
<div>
<h1>whois</h1>
<a href="/">home.</a>
<span> | </span>
<a href="javascript:void(0);" id="theme-switcher">light mode.</a>
</div>
<hr>
{{ template "content" . }}
</div>
<script data-main="/static/js/script.js" src="/static/js/require.js"></script>
</body>
</html>
{{ end }}

View File

@ -0,0 +1,3 @@
{{ define "base" }}
{{ template "content" . }}
{{ end }}

View File

@ -0,0 +1,3 @@
{{ define "content" }}
hello from {{ .Service }}!
{{ end }}

View File

@ -0,0 +1,16 @@
package utils
import (
"crypto/rand"
"fmt"
)
func RandomId() string {
id := make([]byte, 16)
_, err := rand.Read(id)
if err != nil {
panic(err)
}
return fmt.Sprintf("%x", id)
}

7
templates/404.html Normal file
View File

@ -0,0 +1,7 @@
{{ define "content" }}
<h1>page not found</h1>
<p><em>but hey, at least you found our witty 404 page. that's something, right?</em></p>
<p><a href="/">go back home</a></p>
{{ end }}

34
templates/base.html Normal file
View File

@ -0,0 +1,34 @@
{{ define "base" }}
<!DOCTYPE html>
<html>
<head>
<title>whois simponic.</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link href="/static/img/favicon.ico" rel="icon" type="image/x-icon">
<link rel="stylesheet" type="text/css" href="/static/css/styles.css">
<meta property="og:type" content="website">
<meta property="og:url" content="https://whois.simponic.xyz">
<meta property="og:title" content="whois simponic.">
<meta property="og:description" content="whois simponic.">
<meta property="og:image:secure_url" content="https://whois.simponic.xyz/static/img/favicon.ico">
<meta property="og:image:secure" content="https://whois.simponic.xyz/static/img/favicon.ico">
<script src="/static/js/util/setThemeBeforeRender.js"></script>
</head>
<body>
<div id="content" class="container">
<div>
<h1>whois</h1>
<a href="/">home.</a>
<span> | </span>
<a href="javascript:void(0);" id="theme-switcher">light mode.</a>
</div>
<hr>
{{ template "content" . }}
</div>
<script data-main="/static/js/script.js" src="/static/js/require.js"></script>
</body>
</html>
{{ end }}

View File

@ -0,0 +1,3 @@
{{ define "base" }}
{{ template "content" . }}
{{ end }}

3
templates/home.html Normal file
View File

@ -0,0 +1,3 @@
{{ define "content" }}
hello, my name is {{ .Latest.Name }}!
{{ end }}

3
templates/name.tmpl Normal file
View File

@ -0,0 +1,3 @@
{{ define "content" }}
{{ .Latest.Name }}
{{ end }}

16
utils/random_id.go Normal file
View File

@ -0,0 +1,16 @@
package utils
import (
"crypto/rand"
"fmt"
)
func RandomId() string {
id := make([]byte, 16)
_, err := rand.Read(id)
if err != nil {
panic(err)
}
return fmt.Sprintf("%x", id)
}