initial commit by simponic-infra
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
d25ec27fb1
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
.env
|
||||
whois
|
||||
Dockerfile
|
||||
*.db
|
||||
.drone.yml
|
49
.drone.yml
Normal file
49
.drone.yml
Normal 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
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
*.env
|
||||
whois
|
||||
*.db
|
1
.tool-versions
Normal file
1
.tool-versions
Normal file
@ -0,0 +1 @@
|
||||
golang 1.23.4
|
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
3
README.md
Normal file
@ -0,0 +1,3 @@
|
||||
## whois simponic.
|
||||
|
||||
this is a simponic service for whois
|
96
api/api.go
Normal file
96
api/api.go
Normal 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
79
api/api_test.go
Normal 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
73
api/template/template.go
Normal 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
26
api/types/types.go
Normal 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
94
args/args.go
Normal 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
17
database/conn.go
Normal 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
46
database/migrate.go
Normal 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
44
database/updates.go
Normal 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
16
docker-compose.yml
Normal 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
16
go.mod
Normal 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
24
go.sum
Normal 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
82
main.go
Normal 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
16
ntfy/publisher.go
Normal 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
96
ntfy/watcher.go
Normal 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
34
scheduler/scheduler.go
Normal 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
BIN
static/.DS_Store
vendored
Normal file
Binary file not shown.
55
static/css/colors.css
Normal file
55
static/css/colors.css
Normal 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
42
static/css/form.css
Normal 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
60
static/css/styles.css
Normal 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
28
static/css/table.css
Normal 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
BIN
static/fonts/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
static/fonts/GeistMono-Medium.ttf
Normal file
BIN
static/fonts/GeistMono-Medium.ttf
Normal file
Binary file not shown.
BIN
static/img/favicon.ico
Normal file
BIN
static/img/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 591 B |
7
static/js/components/formatDate.js
Normal file
7
static/js/components/formatDate.js
Normal 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();
|
||||
});
|
6
static/js/components/infoBanners.js
Normal file
6
static/js/components/infoBanners.js
Normal file
@ -0,0 +1,6 @@
|
||||
const infoBanners = document.querySelectorAll(".info");
|
||||
Array.from(infoBanners).forEach((infoBanner) => {
|
||||
infoBanner.addEventListener("click", () => {
|
||||
infoBanner.remove();
|
||||
});
|
||||
});
|
27
static/js/components/themeSwitcher.js
Normal file
27
static/js/components/themeSwitcher.js
Normal 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
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
6
static/js/script.js
Normal file
@ -0,0 +1,6 @@
|
||||
const scripts = [
|
||||
"/static/js/components/themeSwitcher.js",
|
||||
"/static/js/components/formatDate.js",
|
||||
"/static/js/components/infoBanners.js",
|
||||
];
|
||||
requirejs(scripts);
|
11
static/js/util/setThemeBeforeRender.js
Normal file
11
static/js/util/setThemeBeforeRender.js
Normal 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
BIN
template/.DS_Store
vendored
Normal file
Binary file not shown.
5
template/.dockerignore
Normal file
5
template/.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
.env
|
||||
whois
|
||||
Dockerfile
|
||||
*.db
|
||||
.drone.yml
|
49
template/.drone.yml
Normal file
49
template/.drone.yml
Normal 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
3
template/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
*.env
|
||||
whois
|
||||
*.db
|
1
template/.tool-versions
Normal file
1
template/.tool-versions
Normal file
@ -0,0 +1 @@
|
||||
golang 1.23.4
|
13
template/Dockerfile
Normal file
13
template/Dockerfile
Normal 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
3
template/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
## whois simponic.
|
||||
|
||||
this is a simponic service for whois
|
91
template/api/api.go
Normal file
91
template/api/api.go
Normal 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
89
template/api/api_test.go
Normal 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"))
|
||||
}
|
||||
}
|
73
template/api/template/template.go
Normal file
73
template/api/template/template.go
Normal 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
template/api/types/types.go
Normal file
26
template/api/types/types.go
Normal 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
94
template/args/args.go
Normal 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
17
template/database/conn.go
Normal 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
|
||||
}
|
39
template/database/migrate.go
Normal file
39
template/database/migrate.go
Normal 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
|
||||
}
|
16
template/docker-compose.yml
Normal file
16
template/docker-compose.yml
Normal 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
9
template/go.mod
Normal 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
76
template/main.go
Normal 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
|
||||
}
|
||||
}
|
16
template/ntfy/publisher.go
Normal file
16
template/ntfy/publisher.go
Normal 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
96
template/ntfy/watcher.go
Normal 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
template/scheduler/scheduler.go
Normal file
34
template/scheduler/scheduler.go
Normal 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
BIN
template/static/.DS_Store
vendored
Normal file
Binary file not shown.
55
template/static/css/colors.css
Normal file
55
template/static/css/colors.css
Normal 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
template/static/css/form.css
Normal file
42
template/static/css/form.css
Normal 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
template/static/css/styles.css
Normal file
60
template/static/css/styles.css
Normal 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
template/static/css/table.css
Normal file
28
template/static/css/table.css
Normal 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
BIN
template/static/fonts/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
template/static/fonts/GeistMono-Medium.ttf
Normal file
BIN
template/static/fonts/GeistMono-Medium.ttf
Normal file
Binary file not shown.
BIN
template/static/img/favicon.ico
Normal file
BIN
template/static/img/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 591 B |
7
template/static/js/components/formatDate.js
Normal file
7
template/static/js/components/formatDate.js
Normal 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();
|
||||
});
|
6
template/static/js/components/infoBanners.js
Normal file
6
template/static/js/components/infoBanners.js
Normal file
@ -0,0 +1,6 @@
|
||||
const infoBanners = document.querySelectorAll(".info");
|
||||
Array.from(infoBanners).forEach((infoBanner) => {
|
||||
infoBanner.addEventListener("click", () => {
|
||||
infoBanner.remove();
|
||||
});
|
||||
});
|
27
template/static/js/components/themeSwitcher.js
Normal file
27
template/static/js/components/themeSwitcher.js
Normal 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
template/static/js/require.js
Normal file
5
template/static/js/require.js
Normal file
File diff suppressed because one or more lines are too long
6
template/static/js/script.js
Normal file
6
template/static/js/script.js
Normal file
@ -0,0 +1,6 @@
|
||||
const scripts = [
|
||||
"/static/js/components/themeSwitcher.js",
|
||||
"/static/js/components/formatDate.js",
|
||||
"/static/js/components/infoBanners.js",
|
||||
];
|
||||
requirejs(scripts);
|
11
template/static/js/util/setThemeBeforeRender.js
Normal file
11
template/static/js/util/setThemeBeforeRender.js
Normal 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"),
|
||||
);
|
7
template/templates/404.html
Normal file
7
template/templates/404.html
Normal 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
template/templates/base.html
Normal file
34
template/templates/base.html
Normal 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 }}
|
3
template/templates/base_empty.html
Normal file
3
template/templates/base_empty.html
Normal file
@ -0,0 +1,3 @@
|
||||
{{ define "base" }}
|
||||
{{ template "content" . }}
|
||||
{{ end }}
|
3
template/templates/hello.html
Normal file
3
template/templates/hello.html
Normal file
@ -0,0 +1,3 @@
|
||||
{{ define "content" }}
|
||||
hello from {{ .Service }}!
|
||||
{{ end }}
|
16
template/utils/random_id.go
Normal file
16
template/utils/random_id.go
Normal 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
7
templates/404.html
Normal 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
34
templates/base.html
Normal 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 }}
|
3
templates/base_empty.html
Normal file
3
templates/base_empty.html
Normal file
@ -0,0 +1,3 @@
|
||||
{{ define "base" }}
|
||||
{{ template "content" . }}
|
||||
{{ end }}
|
3
templates/home.html
Normal file
3
templates/home.html
Normal file
@ -0,0 +1,3 @@
|
||||
{{ define "content" }}
|
||||
hello, my name is {{ .Latest.Name }}!
|
||||
{{ end }}
|
3
templates/name.tmpl
Normal file
3
templates/name.tmpl
Normal file
@ -0,0 +1,3 @@
|
||||
{{ define "content" }}
|
||||
{{ .Latest.Name }}
|
||||
{{ end }}
|
16
utils/random_id.go
Normal file
16
utils/random_id.go
Normal 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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user