This commit is contained in:
Mike Schwörer 2023-12-01 13:44:58 +01:00
parent b958ff7ca2
commit 6e98701299
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
239 changed files with 827 additions and 102 deletions

14
.idea/webResources.xml Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WebResourcesPaths">
<contentEntries>
<entry url="file://$PROJECT_DIR$">
<entryData>
<resourceRoots>
<path value="file://$PROJECT_DIR$/webassets" />
</resourceRoots>
</entryData>
</entry>
</contentEntries>
</component>
</project>

View File

@ -22,7 +22,7 @@ ids:
run: build
mkdir -p .run-data
sudo _build/bunny_backend
sudo BUNNY_LIVERELOAD="$(shell pwd)/webassets" CONF_NS="local-host" _build/bunny_backend
gow:
# go install github.com/mitranim/gow@latest

47
api/handler/apiHandler.go Normal file
View File

@ -0,0 +1,47 @@
package handler
import (
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
bunny "locbunny"
"locbunny/logic"
"locbunny/models"
"net/http"
)
type APIHandler struct {
app *logic.Application
}
func NewAPIHandler(app *logic.Application) APIHandler {
return APIHandler{
app: app,
}
}
// ListServer swaggerdoc
//
// @Summary List running server
//
// @Success 200 {object} handler.ListServer.response
// @Failure 400 {object} models.APIError
// @Failure 500 {object} models.APIError
//
// @Router /server [GET]
func (h APIHandler) ListServer(pctx ginext.PreContext) ginext.HTTPResponse {
type response struct {
Server []models.Server `json:"server"`
}
ctx, _, errResp := pctx.Start()
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
srvs, err := h.app.ListServer(ctx, bunny.Conf.VerifyConnTimeoutAPI)
if err != nil {
return ginext.Error(err)
}
return ginext.JSON(http.StatusOK, response{Server: srvs})
}

View File

@ -1,11 +1,20 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
templhtml "html/template"
bunny "locbunny"
"locbunny/logic"
"locbunny/models"
"locbunny/webassets"
"net/http"
"path/filepath"
templtext "text/template"
"time"
)
type WebHandler struct {
@ -18,32 +27,151 @@ func NewWebHandler(app *logic.Application) WebHandler {
}
}
// ListServer swaggerdoc
// ServeIndexHTML swaggerdoc
//
// @Summary List running server
// @Summary (Website)
//
// @Success 200 {object} handler.ListServer.response
// @Failure 400 {object} models.APIError
// @Failure 500 {object} models.APIError
//
// @Router /server [GET]
func (h WebHandler) ListServer(pctx ginext.PreContext) ginext.HTTPResponse {
type response struct {
Server []models.Server `json:"server"`
}
// @Router / [GET]
// @Router /index.html [GET]
func (h WebHandler) ServeIndexHTML(pctx ginext.PreContext) ginext.HTTPResponse {
ctx, _, errResp := pctx.Start()
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
srvs, err := h.app.ListServer(ctx)
templ, err := h.app.Assets.Template("index.html", h.buildIndexHTMLTemplate)
if err != nil {
return ginext.Error(err)
}
langext.SortBy(srvs, func(v models.Server) int { return v.Port })
data := map[string]any{}
return ginext.JSON(http.StatusOK, response{Server: srvs})
bin := bytes.Buffer{}
err = templ.Execute(&bin, data)
if err != nil {
return ginext.Error(err)
}
return ginext.Data(http.StatusOK, "text/html", bin.Bytes())
}
// ServeScriptJS swaggerdoc
//
// @Summary (Website)
//
// @Router /scripts.script.js [GET]
func (h WebHandler) ServeScriptJS(pctx ginext.PreContext) ginext.HTTPResponse {
ctx, _, errResp := pctx.Start()
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
templ, err := h.app.Assets.Template("scripts/script.js", h.buildScriptJSTemplate)
if err != nil {
return ginext.Error(err)
}
data := map[string]any{}
bin := bytes.Buffer{}
err = templ.Execute(&bin, data)
if err != nil {
return ginext.Error(err)
}
return ginext.Data(http.StatusOK, "text/javascript", bin.Bytes())
}
func (h WebHandler) buildIndexHTMLTemplate(content []byte) (webassets.ITemplate, error) {
t := templhtml.New("index.html")
t.Funcs(h.templateFuncMap())
_, err := t.Parse(string(content))
if err != nil {
return nil, err
}
return t, nil
}
func (h WebHandler) ServeAssets(pctx ginext.PreContext) ginext.HTTPResponse {
type uri struct {
FP1 *string `uri:"fp1"`
FP2 *string `uri:"fp2"`
FP3 *string `uri:"fp3"`
}
var u uri
ctx, _, errResp := pctx.URI(&u).Start()
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
assetpath := ""
if u.FP1 == nil && u.FP2 == nil && u.FP3 == nil {
assetpath = filepath.Join()
} else if u.FP2 == nil && u.FP3 == nil {
assetpath = filepath.Join(*u.FP1)
} else if u.FP3 == nil {
assetpath = filepath.Join(*u.FP1, *u.FP2)
} else {
assetpath = filepath.Join(*u.FP1, *u.FP2, *u.FP3)
}
data, err := h.app.Assets.Read(assetpath)
if err != nil {
return ginext.JSON(http.StatusNotFound, gin.H{"error": "AssetNotFound", "assetpath": assetpath})
}
mime := bunny.FilenameToMime(assetpath, "text/plain")
return ginext.Data(http.StatusOK, mime, data)
}
func (h WebHandler) buildScriptJSTemplate(content []byte) (webassets.ITemplate, error) {
t := templtext.New("scripts/script.js")
t.Funcs(h.templateFuncMap())
_, err := t.Parse(string(content))
if err != nil {
return nil, err
}
return t, nil
}
func (h WebHandler) templateFuncMap() map[string]any {
return map[string]any{
"listServers": func() []models.Server {
ctx, cancel := context.WithTimeout(context.Background(), bunny.Conf.VerifyConnTimeoutHTML+2*time.Second)
defer cancel()
v, err := h.app.ListServer(ctx, bunny.Conf.VerifyConnTimeoutHTML)
if err != nil {
panic(err)
}
return v
},
"safe_html": func(s string) templhtml.HTML { return templhtml.HTML(s) }, //nolint:gosec
"safe_js": func(s string) templhtml.JS { return templhtml.JS(s) }, //nolint:gosec
"json": func(obj any) string {
v, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return string(v)
},
"json_indent": func(obj any) string {
v, err := json.MarshalIndent(obj, "", " ")
if err != nil {
panic(err)
}
return string(v)
},
"mkarr": func(ln int) []int { return make([]int, ln) },
}
}

View File

@ -13,6 +13,7 @@ type Router struct {
app *logic.Application
commonHandler handler.CommonHandler
apiHandler handler.APIHandler
webHandler handler.WebHandler
}
@ -21,6 +22,7 @@ func NewRouter(app *logic.Application) *Router {
app: app,
commonHandler: handler.NewCommonHandler(app),
apiHandler: handler.NewAPIHandler(app),
webHandler: handler.NewWebHandler(app),
}
}
@ -50,9 +52,18 @@ func (r *Router) Init(e *ginext.GinWrapper) {
docs.GET("/swagger/*sub").Handle(swagger.Handle)
}
// ================ Website ================
e.Routes().GET("/").Handle(r.webHandler.ServeIndexHTML)
e.Routes().GET("/index.html").Handle(r.webHandler.ServeIndexHTML)
e.Routes().GET("/scripts/script.js").Handle(r.webHandler.ServeScriptJS)
e.Routes().GET("/:fp1").Handle(r.webHandler.ServeAssets)
e.Routes().GET("/:fp1/:fp2").Handle(r.webHandler.ServeAssets)
e.Routes().GET("/:fp1/:fp2/:fp3").Handle(r.webHandler.ServeAssets)
// ================ API ================
api.GET("/server").Handle(r.webHandler.ListServer)
api.GET("/server").Handle(r.apiHandler.ListServer)
// ================ ================

View File

@ -7,6 +7,7 @@ import (
bunny "locbunny"
"locbunny/api"
"locbunny/logic"
"locbunny/webassets"
)
func main() {
@ -16,7 +17,9 @@ func main() {
log.Info().Msg(fmt.Sprintf("Starting with config-namespace <%s>", conf.Namespace))
app := logic.NewApp()
assets := webassets.NewAssets()
app := logic.NewApp(assets)
ginengine := ginext.NewEngine(conf.Cors, conf.GinDebug, true, conf.RequestTimeout)

140
config.go
View File

@ -14,92 +14,110 @@ const APILevel = 1
var SelfProcessID int
type Config struct {
Namespace string
GinDebug bool `env:"GINDEBUG"`
ReturnRawErrors bool `env:"RETURNERRORS"`
Custom404 bool `env:"CUSTOM404"`
LogLevel zerolog.Level `env:"LOGLEVEL"`
ServerIP string `env:"IP"`
ServerPort int `env:"PORT"`
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT"`
Cors bool `env:"CORS"`
VerifyConnTimeout time.Duration `env:"VERIFY_CONN_TIMEOUT"`
Namespace string
GinDebug bool `env:"GINDEBUG"`
ReturnRawErrors bool `env:"RETURNERRORS"`
Custom404 bool `env:"CUSTOM404"`
LogLevel zerolog.Level `env:"LOGLEVEL"`
ServerIP string `env:"IP"`
ServerPort int `env:"PORT"`
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT"`
Cors bool `env:"CORS"`
VerifyConnTimeoutHTML time.Duration `env:"VERIFY_CONN_TIMEOUT_HTML"`
VerifyConnTimeoutAPI time.Duration `env:"VERIFY_CONN_TIMEOUT_API"`
LiveReload *string `env:"LIVERELOAD"`
CacheDuration time.Duration `env:"CACHE_DURATION"`
}
var Conf Config
var configLocHost = func() Config {
return Config{
Namespace: "local",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: 80,
Custom404: true,
ReturnRawErrors: true,
RequestTimeout: 16 * time.Second,
LogLevel: zerolog.DebugLevel,
Cors: true,
VerifyConnTimeout: time.Second,
Namespace: "local",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: 80,
Custom404: true,
ReturnRawErrors: true,
RequestTimeout: 16 * time.Second,
LogLevel: zerolog.DebugLevel,
Cors: true,
VerifyConnTimeoutAPI: 2 * time.Second,
VerifyConnTimeoutHTML: 500 * time.Millisecond,
LiveReload: nil,
CacheDuration: 8 * time.Second,
}
}
var configLocDocker = func() Config {
return Config{
Namespace: "local-docker",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: 80,
Custom404: true,
ReturnRawErrors: true,
RequestTimeout: 16 * time.Second,
LogLevel: zerolog.DebugLevel,
Cors: true,
VerifyConnTimeout: time.Second,
Namespace: "local-docker",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: 80,
Custom404: true,
ReturnRawErrors: true,
RequestTimeout: 16 * time.Second,
LogLevel: zerolog.DebugLevel,
Cors: true,
VerifyConnTimeoutAPI: 2 * time.Second,
VerifyConnTimeoutHTML: 500 * time.Millisecond,
LiveReload: nil,
CacheDuration: 8 * time.Second,
}
}
var configDev = func() Config {
return Config{
Namespace: "develop",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: 80,
Custom404: false,
ReturnRawErrors: false,
RequestTimeout: 16 * time.Second,
LogLevel: zerolog.DebugLevel,
Cors: false,
VerifyConnTimeout: time.Second,
Namespace: "develop",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: 80,
Custom404: false,
ReturnRawErrors: false,
RequestTimeout: 16 * time.Second,
LogLevel: zerolog.DebugLevel,
Cors: false,
VerifyConnTimeoutAPI: 2 * time.Second,
VerifyConnTimeoutHTML: 500 * time.Millisecond,
LiveReload: nil,
CacheDuration: 8 * time.Second,
}
}
var configStag = func() Config {
return Config{
Namespace: "staging",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: 80,
Custom404: false,
ReturnRawErrors: false,
RequestTimeout: 16 * time.Second,
LogLevel: zerolog.DebugLevel,
Cors: false,
VerifyConnTimeout: time.Second,
Namespace: "staging",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: 80,
Custom404: false,
ReturnRawErrors: false,
RequestTimeout: 16 * time.Second,
LogLevel: zerolog.DebugLevel,
Cors: false,
VerifyConnTimeoutAPI: 2 * time.Second,
VerifyConnTimeoutHTML: 500 * time.Millisecond,
LiveReload: nil,
CacheDuration: 8 * time.Second,
}
}
var configProd = func() Config {
return Config{
Namespace: "production",
GinDebug: false,
ServerIP: "0.0.0.0",
ServerPort: 80,
Custom404: false,
ReturnRawErrors: false,
RequestTimeout: 16 * time.Second,
LogLevel: zerolog.InfoLevel,
Cors: false,
VerifyConnTimeout: time.Second,
Namespace: "production",
GinDebug: false,
ServerIP: "0.0.0.0",
ServerPort: 80,
Custom404: false,
ReturnRawErrors: false,
RequestTimeout: 16 * time.Second,
LogLevel: zerolog.InfoLevel,
Cors: false,
VerifyConnTimeoutAPI: 2 * time.Second,
VerifyConnTimeoutHTML: 500 * time.Millisecond,
LiveReload: nil,
CacheDuration: 8 * time.Second,
}
}
@ -119,7 +137,7 @@ func InstanceID() string {
func getConfig(ns string) (Config, bool) {
if ns == "" {
ns = "local-host"
ns = "production"
}
if cfn, ok := allConfig[ns]; ok {
c := cfn()

View File

@ -12,6 +12,7 @@ import (
"io"
bunny "locbunny"
"locbunny/models"
"locbunny/webassets"
"net"
"net/http"
"os"
@ -30,12 +31,19 @@ type Application struct {
Port string
IsRunning *syncext.AtomicBool
Gin *ginext.GinWrapper
Jobs []Job
Gin *ginext.GinWrapper
Assets *webassets.Assets
Jobs []Job
cacheLock sync.Mutex
serverCacheValue []models.Server
serverCacheTime *time.Time
}
func NewApp() *Application {
func NewApp(ass *webassets.Assets) *Application {
//nolint:exhaustruct
return &Application{
Assets: ass,
stopChan: make(chan bool),
IsRunning: syncext.NewAtomicBool(false),
}
@ -110,18 +118,35 @@ func (app *Application) Run() {
app.IsRunning.Set(false)
}
func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, error) {
func (app *Application) ListServer(ctx context.Context, timeout time.Duration) ([]models.Server, error) {
app.cacheLock.Lock()
if app.serverCacheTime != nil && app.serverCacheTime.After(time.Now().Add(-bunny.Conf.CacheDuration)) {
v := langext.ArrCopy(app.serverCacheValue)
log.Debug().Msg(fmt.Sprintf("Return cache values (from %s)", app.serverCacheTime.Format(time.RFC3339Nano)))
app.cacheLock.Unlock()
return v, nil
}
app.cacheLock.Unlock()
socks4, err := netstat.TCPSocks(netstat.NoopFilter)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
socks6, err := netstat.TCP6Socks(netstat.NoopFilter)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
sockCount := len(socks4) + len(socks6)
wg := sync.WaitGroup{}
@ -135,7 +160,7 @@ func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, err
go func() {
defer wg.Done()
con1, err := app.verifyHTTPConn(socks4[i], "HTTP", "v4")
con1, err := app.verifyHTTPConn(socks4[i], "HTTP", "v4", timeout)
if err == nil {
rchan <- con1
return
@ -148,7 +173,7 @@ func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, err
go func() {
defer wg.Done()
con2, err := app.verifyHTTPConn(socks4[i], "HTTPS", "v4")
con2, err := app.verifyHTTPConn(socks4[i], "HTTPS", "v4", timeout)
if err == nil {
rchan <- con2
return
@ -164,7 +189,7 @@ func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, err
go func() {
defer wg.Done()
con1, err := app.verifyHTTPConn(socks6[i], "HTTP", "v6")
con1, err := app.verifyHTTPConn(socks6[i], "HTTP", "v6", timeout)
if err == nil {
rchan <- con1
return
@ -177,7 +202,7 @@ func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, err
go func() {
defer wg.Done()
con2, err := app.verifyHTTPConn(socks6[i], "HTTPS", "v6")
con2, err := app.verifyHTTPConn(socks6[i], "HTTPS", "v6", timeout)
if err == nil {
rchan <- con2
return
@ -191,6 +216,10 @@ func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, err
close(echan)
close(rchan)
if err := ctx.Err(); err != nil {
return nil, err
}
duplicates := make(map[int]bool, sockCount*3)
res := make([]models.Server, 0, sockCount*3)
for v := range rchan {
@ -201,35 +230,44 @@ func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, err
}
}
langext.SortBy(res, func(v models.Server) int { return v.Port })
app.cacheLock.Lock()
app.serverCacheValue = langext.ArrCopy(res)
app.serverCacheTime = langext.Ptr(time.Now())
app.cacheLock.Unlock()
return res, nil
}
func (app *Application) verifyHTTPConn(sock netstat.SockTabEntry, proto string, ipversion string) (models.Server, error) {
func (app *Application) verifyHTTPConn(sock netstat.SockTabEntry, proto string, ipversion string, timeout time.Duration) (models.Server, error) {
ctx, cancel := context.WithTimeout(context.Background(), bunny.Conf.VerifyConnTimeout)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if sock.State != netstat.Listen {
log.Debug().Msg(fmt.Sprintf("Failed to verify socket [%s|%s] invalid state: %s", ipversion, strings.ToUpper(proto), sock.State.String()))
port := int(sock.LocalAddr.Port)
if sock.State != netstat.Listen && sock.State != netstat.Established && sock.State != netstat.TimeWait {
log.Debug().Msg(fmt.Sprintf("Failed to verify socket [%s|%s|%d] invalid state: %s", strings.ToUpper(proto), ipversion, port, sock.State.String()))
return models.Server{}, errors.New("invalid sock-state")
}
if int(sock.LocalAddr.Port) == bunny.Conf.ServerPort && sock.Process != nil && sock.Process.Pid == bunny.SelfProcessID {
log.Debug().Msg(fmt.Sprintf("Skip socket [%s|%s] (this is our own server)", ipversion, strings.ToUpper(proto)))
if port == bunny.Conf.ServerPort && sock.Process != nil && sock.Process.Pid == bunny.SelfProcessID {
log.Debug().Msg(fmt.Sprintf("Skip socket [%s|%s|%d] (this is our own server)", strings.ToUpper(proto), ipversion, port))
return models.Server{}, errors.New("skip self")
}
c := http.Client{}
url := fmt.Sprintf("%s://localhost:%d", strings.ToLower(proto), sock.LocalAddr.Port)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
url := fmt.Sprintf("%s://localhost:%d", strings.ToLower(proto), port)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
log.Debug().Msg(fmt.Sprintf("Failed to create [%s|%s] request to %d", ipversion, strings.ToUpper(proto), sock.LocalAddr.Port))
log.Debug().Msg(fmt.Sprintf("Failed to create [%s|%s|%d] request to %d (-> %s)", strings.ToUpper(proto), ipversion, port, port, err.Error()))
return models.Server{}, err
}
resp1, err := c.Do(req)
if err != nil {
log.Debug().Msg(fmt.Sprintf("Failed to send [%s|%s] request to %s", ipversion, strings.ToUpper(proto), url))
log.Debug().Msg(fmt.Sprintf("Failed to send [%s|%s|%d] request to %s (-> %s)", strings.ToUpper(proto), ipversion, port, url, err.Error()))
return models.Server{}, err
}
@ -237,7 +275,7 @@ func (app *Application) verifyHTTPConn(sock netstat.SockTabEntry, proto string,
resbody, err := io.ReadAll(resp1.Body)
if err != nil {
log.Debug().Msg(fmt.Sprintf("Failed to read [%s|%s] response from %s", ipversion, strings.ToUpper(proto), url))
log.Debug().Msg(fmt.Sprintf("Failed to read [%s|%s|%d] response from %s (-> %s)", strings.ToUpper(proto), ipversion, port, url, err.Error()))
return models.Server{}, err
}
@ -252,7 +290,7 @@ func (app *Application) verifyHTTPConn(sock netstat.SockTabEntry, proto string,
}
return models.Server{
Port: int(sock.LocalAddr.Port),
Port: port,
IP: sock.LocalAddr.IP.String(),
Protocol: proto,
StatusCode: resp1.StatusCode,
@ -265,6 +303,6 @@ func (app *Application) verifyHTTPConn(sock netstat.SockTabEntry, proto string,
}, nil
}
log.Debug().Msg(fmt.Sprintf("Failed to categorize [%s|%s] response from %s (Content-Type: '%s')", ipversion, strings.ToUpper(proto), url, ct))
log.Debug().Msg(fmt.Sprintf("Failed to categorize [%s|%s|%d] response from %s (Content-Type: '%s')", strings.ToUpper(proto), ipversion, port, url, ct))
return models.Server{}, errors.New("invalid response-type")
}

View File

@ -2,7 +2,6 @@ package swagger
import (
"embed"
_ "embed"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
"net/http"

View File

@ -8,6 +8,12 @@
"host": "localhost",
"basePath": "/api/v1/",
"paths": {
"/": {
"get": {
"summary": "(Website)",
"responses": {}
}
},
"/api/health": {
"get": {
"tags": [
@ -169,6 +175,18 @@
}
}
},
"/index.html": {
"get": {
"summary": "(Website)",
"responses": {}
}
},
"/scripts.script.js": {
"get": {
"summary": "(Website)",
"responses": {}
}
},
"/server": {
"get": {
"summary": "List running server",

View File

@ -83,6 +83,10 @@ info:
title: LocalHostBunny
version: "1.0"
paths:
/:
get:
responses: {}
summary: (Website)
/api/health:
get:
responses:
@ -187,6 +191,14 @@ paths:
summary: Return 200 after x seconds
tags:
- Common
/index.html:
get:
responses: {}
summary: (Website)
/scripts.script.js:
get:
responses: {}
summary: (Website)
/server:
get:
responses:

39
utils.go Normal file
View File

@ -0,0 +1,39 @@
package bunny
import "strings"
func FilenameToMime(fn string, fallback string) string {
lowerFN := strings.ToLower(fn)
if strings.HasSuffix(lowerFN, ".html") || strings.HasSuffix(lowerFN, ".htm") {
return "text/html"
}
if strings.HasSuffix(lowerFN, ".css") {
return "text/css"
}
if strings.HasSuffix(lowerFN, ".js") {
return "text/javascript"
}
if strings.HasSuffix(lowerFN, ".json") {
return "application/json"
}
if strings.HasSuffix(lowerFN, ".jpeg") || strings.HasSuffix(lowerFN, ".jpg") {
return "image/jpeg"
}
if strings.HasSuffix(lowerFN, ".png") {
return "image/png"
}
if strings.HasSuffix(lowerFN, ".svg") {
return "image/svg+xml"
}
if strings.HasSuffix(lowerFN, ".gif") {
return "image/gif"
}
if strings.HasSuffix(lowerFN, ".webp") {
return "audio/webm"
}
if strings.HasSuffix(lowerFN, ".bmp") {
return "image/bmp"
}
return fallback
}

20
webassets/css/fonts.css Normal file
View File

@ -0,0 +1,20 @@
@font-face { font-display: swap; font-family: 'MonaspaceArgon'; font-weight: bold; font-style: normal; src: url('/fonts/MonaspaceArgon-Bold.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceArgon'; font-weight: bold; font-style: italic; src: url('/fonts/MonaspaceArgon-BoldItalic.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceArgon'; font-weight: normal; font-style: italic; src: url('/fonts/MonaspaceArgon-Italic.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceArgon'; font-weight: normal; font-style: normal; src: url('/fonts/MonaspaceArgon-Regular.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceKrypton'; font-weight: bold; font-style: normal; src: url('/fonts/MonaspaceKrypton-Bold.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceKrypton'; font-weight: bold; font-style: italic; src: url('/fonts/MonaspaceKrypton-BoldItalic.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceKrypton'; font-weight: normal; font-style: italic; src: url('/fonts/MonaspaceKrypton-Italic.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceKrypton'; font-weight: normal; font-style: normal; src: url('/fonts/MonaspaceKrypton-Regular.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceNeon'; font-weight: bold; font-style: normal; src: url('/fonts/MonaspaceNeon-Bold.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceNeon'; font-weight: bold; font-style: italic; src: url('/fonts/MonaspaceNeon-BoldItalic.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceNeon'; font-weight: normal; font-style: italic; src: url('/fonts/MonaspaceNeon-Italic.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceNeon'; font-weight: normal; font-style: normal; src: url('/fonts/MonaspaceNeon-Regular.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceRadon'; font-weight: bold; font-style: normal; src: url('/fonts/MonaspaceRadon-Bold.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceRadon'; font-weight: bold; font-style: italic; src: url('/fonts/MonaspaceRadon-BoldItalic.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceRadon'; font-weight: normal; font-style: italic; src: url('/fonts/MonaspaceRadon-Italic.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceRadon'; font-weight: normal; font-style: normal; src: url('/fonts/MonaspaceRadon-Regular.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceXenon'; font-weight: bold; font-style: normal; src: url('/fonts/MonaspaceXenon-Bold.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceXenon'; font-weight: bold; font-style: italic; src: url('/fonts/MonaspaceXenon-BoldItalic.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceXenon'; font-weight: normal; font-style: italic; src: url('/fonts/MonaspaceXenon-Italic.woff') format('woff2') }
@font-face { font-display: swap; font-family: 'MonaspaceXenon'; font-weight: normal; font-style: normal; src: url('/fonts/MonaspaceXenon-Regular.woff') format('woff2') }

51
webassets/css/reset.css Normal file
View File

@ -0,0 +1,51 @@
/* https://meyerweb.com/eric/tools/css/reset/ */
/*
1. Use a more-intuitive box-sizing model.
*/
*, *::before, *::after {
box-sizing: border-box;
}
/*
2. Remove default margin
*/
* {
margin: 0;
}
/*
Typographic tweaks!
3. Add accessible line-height
4. Improve text rendering
*/
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/*
5. Improve media defaults
*/
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
/*
6. Remove built-in form typography styles
*/
input, button, textarea, select {
font: inherit;
}
/*
7. Avoid text overflows
*/
p, h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}
/*
8. Create a root stacking context
*/
#root, #__next {
isolation: isolate;
}

55
webassets/css/styles.css Normal file
View File

@ -0,0 +1,55 @@
* {
font-family: 'MonaspaceXenon';
}
html, body {
width: 100%;
}
body {
display: flex;
justify-content: center;
}
main {
display: flex;
flex-direction: column;
margin: 1rem;
}
h1 {
margin-bottom: 1rem;
}
#maincontent {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0 1rem;
}
.server {
background-color: #CCC;
text-align: center;
padding: 2px 0.5rem;
color: black;
text-decoration: none;
border-radius: 6px;
border: 1px solid #888;
box-shadow: 0 0 4px #888;
transition: all 0.2s;
}
.server:hover {
box-shadow: 0 0 4px #000;
background-color: #AAA;
color: #00F;
}

BIN
webassets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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