html
This commit is contained in:
parent
b958ff7ca2
commit
6e98701299
|
@ -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>
|
2
Makefile
2
Makefile
|
@ -22,7 +22,7 @@ ids:
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
mkdir -p .run-data
|
mkdir -p .run-data
|
||||||
sudo _build/bunny_backend
|
sudo BUNNY_LIVERELOAD="$(shell pwd)/webassets" CONF_NS="local-host" _build/bunny_backend
|
||||||
|
|
||||||
gow:
|
gow:
|
||||||
# go install github.com/mitranim/gow@latest
|
# go install github.com/mitranim/gow@latest
|
||||||
|
|
|
@ -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})
|
||||||
|
}
|
|
@ -1,11 +1,20 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
|
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/langext"
|
templhtml "html/template"
|
||||||
|
bunny "locbunny"
|
||||||
"locbunny/logic"
|
"locbunny/logic"
|
||||||
"locbunny/models"
|
"locbunny/models"
|
||||||
|
"locbunny/webassets"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
templtext "text/template"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebHandler struct {
|
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
|
// @Router / [GET]
|
||||||
// @Failure 400 {object} models.APIError
|
// @Router /index.html [GET]
|
||||||
// @Failure 500 {object} models.APIError
|
func (h WebHandler) ServeIndexHTML(pctx ginext.PreContext) ginext.HTTPResponse {
|
||||||
//
|
|
||||||
// @Router /server [GET]
|
|
||||||
func (h WebHandler) ListServer(pctx ginext.PreContext) ginext.HTTPResponse {
|
|
||||||
type response struct {
|
|
||||||
Server []models.Server `json:"server"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, _, errResp := pctx.Start()
|
ctx, _, errResp := pctx.Start()
|
||||||
if errResp != nil {
|
if errResp != nil {
|
||||||
return *errResp
|
return *errResp
|
||||||
}
|
}
|
||||||
defer ctx.Cancel()
|
defer ctx.Cancel()
|
||||||
|
|
||||||
srvs, err := h.app.ListServer(ctx)
|
templ, err := h.app.Assets.Template("index.html", h.buildIndexHTMLTemplate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ginext.Error(err)
|
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) },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ type Router struct {
|
||||||
app *logic.Application
|
app *logic.Application
|
||||||
|
|
||||||
commonHandler handler.CommonHandler
|
commonHandler handler.CommonHandler
|
||||||
|
apiHandler handler.APIHandler
|
||||||
webHandler handler.WebHandler
|
webHandler handler.WebHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,6 +22,7 @@ func NewRouter(app *logic.Application) *Router {
|
||||||
app: app,
|
app: app,
|
||||||
|
|
||||||
commonHandler: handler.NewCommonHandler(app),
|
commonHandler: handler.NewCommonHandler(app),
|
||||||
|
apiHandler: handler.NewAPIHandler(app),
|
||||||
webHandler: handler.NewWebHandler(app),
|
webHandler: handler.NewWebHandler(app),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,9 +52,18 @@ func (r *Router) Init(e *ginext.GinWrapper) {
|
||||||
docs.GET("/swagger/*sub").Handle(swagger.Handle)
|
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 ================
|
||||||
|
|
||||||
api.GET("/server").Handle(r.webHandler.ListServer)
|
api.GET("/server").Handle(r.apiHandler.ListServer)
|
||||||
|
|
||||||
// ================ ================
|
// ================ ================
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
bunny "locbunny"
|
bunny "locbunny"
|
||||||
"locbunny/api"
|
"locbunny/api"
|
||||||
"locbunny/logic"
|
"locbunny/logic"
|
||||||
|
"locbunny/webassets"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
@ -16,7 +17,9 @@ func main() {
|
||||||
|
|
||||||
log.Info().Msg(fmt.Sprintf("Starting with config-namespace <%s>", conf.Namespace))
|
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)
|
ginengine := ginext.NewEngine(conf.Cors, conf.GinDebug, true, conf.RequestTimeout)
|
||||||
|
|
||||||
|
|
140
config.go
140
config.go
|
@ -14,92 +14,110 @@ const APILevel = 1
|
||||||
var SelfProcessID int
|
var SelfProcessID int
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Namespace string
|
Namespace string
|
||||||
GinDebug bool `env:"GINDEBUG"`
|
GinDebug bool `env:"GINDEBUG"`
|
||||||
ReturnRawErrors bool `env:"RETURNERRORS"`
|
ReturnRawErrors bool `env:"RETURNERRORS"`
|
||||||
Custom404 bool `env:"CUSTOM404"`
|
Custom404 bool `env:"CUSTOM404"`
|
||||||
LogLevel zerolog.Level `env:"LOGLEVEL"`
|
LogLevel zerolog.Level `env:"LOGLEVEL"`
|
||||||
ServerIP string `env:"IP"`
|
ServerIP string `env:"IP"`
|
||||||
ServerPort int `env:"PORT"`
|
ServerPort int `env:"PORT"`
|
||||||
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT"`
|
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT"`
|
||||||
Cors bool `env:"CORS"`
|
Cors bool `env:"CORS"`
|
||||||
VerifyConnTimeout time.Duration `env:"VERIFY_CONN_TIMEOUT"`
|
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 Conf Config
|
||||||
|
|
||||||
var configLocHost = func() Config {
|
var configLocHost = func() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Namespace: "local",
|
Namespace: "local",
|
||||||
GinDebug: true,
|
GinDebug: true,
|
||||||
ServerIP: "0.0.0.0",
|
ServerIP: "0.0.0.0",
|
||||||
ServerPort: 80,
|
ServerPort: 80,
|
||||||
Custom404: true,
|
Custom404: true,
|
||||||
ReturnRawErrors: true,
|
ReturnRawErrors: true,
|
||||||
RequestTimeout: 16 * time.Second,
|
RequestTimeout: 16 * time.Second,
|
||||||
LogLevel: zerolog.DebugLevel,
|
LogLevel: zerolog.DebugLevel,
|
||||||
Cors: true,
|
Cors: true,
|
||||||
VerifyConnTimeout: time.Second,
|
VerifyConnTimeoutAPI: 2 * time.Second,
|
||||||
|
VerifyConnTimeoutHTML: 500 * time.Millisecond,
|
||||||
|
LiveReload: nil,
|
||||||
|
CacheDuration: 8 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var configLocDocker = func() Config {
|
var configLocDocker = func() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Namespace: "local-docker",
|
Namespace: "local-docker",
|
||||||
GinDebug: true,
|
GinDebug: true,
|
||||||
ServerIP: "0.0.0.0",
|
ServerIP: "0.0.0.0",
|
||||||
ServerPort: 80,
|
ServerPort: 80,
|
||||||
Custom404: true,
|
Custom404: true,
|
||||||
ReturnRawErrors: true,
|
ReturnRawErrors: true,
|
||||||
RequestTimeout: 16 * time.Second,
|
RequestTimeout: 16 * time.Second,
|
||||||
LogLevel: zerolog.DebugLevel,
|
LogLevel: zerolog.DebugLevel,
|
||||||
Cors: true,
|
Cors: true,
|
||||||
VerifyConnTimeout: time.Second,
|
VerifyConnTimeoutAPI: 2 * time.Second,
|
||||||
|
VerifyConnTimeoutHTML: 500 * time.Millisecond,
|
||||||
|
LiveReload: nil,
|
||||||
|
CacheDuration: 8 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var configDev = func() Config {
|
var configDev = func() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Namespace: "develop",
|
Namespace: "develop",
|
||||||
GinDebug: true,
|
GinDebug: true,
|
||||||
ServerIP: "0.0.0.0",
|
ServerIP: "0.0.0.0",
|
||||||
ServerPort: 80,
|
ServerPort: 80,
|
||||||
Custom404: false,
|
Custom404: false,
|
||||||
ReturnRawErrors: false,
|
ReturnRawErrors: false,
|
||||||
RequestTimeout: 16 * time.Second,
|
RequestTimeout: 16 * time.Second,
|
||||||
LogLevel: zerolog.DebugLevel,
|
LogLevel: zerolog.DebugLevel,
|
||||||
Cors: false,
|
Cors: false,
|
||||||
VerifyConnTimeout: time.Second,
|
VerifyConnTimeoutAPI: 2 * time.Second,
|
||||||
|
VerifyConnTimeoutHTML: 500 * time.Millisecond,
|
||||||
|
LiveReload: nil,
|
||||||
|
CacheDuration: 8 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var configStag = func() Config {
|
var configStag = func() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Namespace: "staging",
|
Namespace: "staging",
|
||||||
GinDebug: true,
|
GinDebug: true,
|
||||||
ServerIP: "0.0.0.0",
|
ServerIP: "0.0.0.0",
|
||||||
ServerPort: 80,
|
ServerPort: 80,
|
||||||
Custom404: false,
|
Custom404: false,
|
||||||
ReturnRawErrors: false,
|
ReturnRawErrors: false,
|
||||||
RequestTimeout: 16 * time.Second,
|
RequestTimeout: 16 * time.Second,
|
||||||
LogLevel: zerolog.DebugLevel,
|
LogLevel: zerolog.DebugLevel,
|
||||||
Cors: false,
|
Cors: false,
|
||||||
VerifyConnTimeout: time.Second,
|
VerifyConnTimeoutAPI: 2 * time.Second,
|
||||||
|
VerifyConnTimeoutHTML: 500 * time.Millisecond,
|
||||||
|
LiveReload: nil,
|
||||||
|
CacheDuration: 8 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var configProd = func() Config {
|
var configProd = func() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Namespace: "production",
|
Namespace: "production",
|
||||||
GinDebug: false,
|
GinDebug: false,
|
||||||
ServerIP: "0.0.0.0",
|
ServerIP: "0.0.0.0",
|
||||||
ServerPort: 80,
|
ServerPort: 80,
|
||||||
Custom404: false,
|
Custom404: false,
|
||||||
ReturnRawErrors: false,
|
ReturnRawErrors: false,
|
||||||
RequestTimeout: 16 * time.Second,
|
RequestTimeout: 16 * time.Second,
|
||||||
LogLevel: zerolog.InfoLevel,
|
LogLevel: zerolog.InfoLevel,
|
||||||
Cors: false,
|
Cors: false,
|
||||||
VerifyConnTimeout: time.Second,
|
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) {
|
func getConfig(ns string) (Config, bool) {
|
||||||
if ns == "" {
|
if ns == "" {
|
||||||
ns = "local-host"
|
ns = "production"
|
||||||
}
|
}
|
||||||
if cfn, ok := allConfig[ns]; ok {
|
if cfn, ok := allConfig[ns]; ok {
|
||||||
c := cfn()
|
c := cfn()
|
||||||
|
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
bunny "locbunny"
|
bunny "locbunny"
|
||||||
"locbunny/models"
|
"locbunny/models"
|
||||||
|
"locbunny/webassets"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
@ -30,12 +31,19 @@ type Application struct {
|
||||||
Port string
|
Port string
|
||||||
IsRunning *syncext.AtomicBool
|
IsRunning *syncext.AtomicBool
|
||||||
|
|
||||||
Gin *ginext.GinWrapper
|
Gin *ginext.GinWrapper
|
||||||
Jobs []Job
|
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{
|
return &Application{
|
||||||
|
Assets: ass,
|
||||||
stopChan: make(chan bool),
|
stopChan: make(chan bool),
|
||||||
IsRunning: syncext.NewAtomicBool(false),
|
IsRunning: syncext.NewAtomicBool(false),
|
||||||
}
|
}
|
||||||
|
@ -110,18 +118,35 @@ func (app *Application) Run() {
|
||||||
app.IsRunning.Set(false)
|
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)
|
socks4, err := netstat.TCPSocks(netstat.NoopFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
socks6, err := netstat.TCP6Socks(netstat.NoopFilter)
|
socks6, err := netstat.TCP6Socks(netstat.NoopFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
sockCount := len(socks4) + len(socks6)
|
sockCount := len(socks4) + len(socks6)
|
||||||
|
|
||||||
wg := sync.WaitGroup{}
|
wg := sync.WaitGroup{}
|
||||||
|
@ -135,7 +160,7 @@ func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, err
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
con1, err := app.verifyHTTPConn(socks4[i], "HTTP", "v4")
|
con1, err := app.verifyHTTPConn(socks4[i], "HTTP", "v4", timeout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
rchan <- con1
|
rchan <- con1
|
||||||
return
|
return
|
||||||
|
@ -148,7 +173,7 @@ func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, err
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
con2, err := app.verifyHTTPConn(socks4[i], "HTTPS", "v4")
|
con2, err := app.verifyHTTPConn(socks4[i], "HTTPS", "v4", timeout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
rchan <- con2
|
rchan <- con2
|
||||||
return
|
return
|
||||||
|
@ -164,7 +189,7 @@ func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, err
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
con1, err := app.verifyHTTPConn(socks6[i], "HTTP", "v6")
|
con1, err := app.verifyHTTPConn(socks6[i], "HTTP", "v6", timeout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
rchan <- con1
|
rchan <- con1
|
||||||
return
|
return
|
||||||
|
@ -177,7 +202,7 @@ func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, err
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
con2, err := app.verifyHTTPConn(socks6[i], "HTTPS", "v6")
|
con2, err := app.verifyHTTPConn(socks6[i], "HTTPS", "v6", timeout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
rchan <- con2
|
rchan <- con2
|
||||||
return
|
return
|
||||||
|
@ -191,6 +216,10 @@ func (app *Application) ListServer(ctx *ginext.AppContext) ([]models.Server, err
|
||||||
close(echan)
|
close(echan)
|
||||||
close(rchan)
|
close(rchan)
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
duplicates := make(map[int]bool, sockCount*3)
|
duplicates := make(map[int]bool, sockCount*3)
|
||||||
res := make([]models.Server, 0, sockCount*3)
|
res := make([]models.Server, 0, sockCount*3)
|
||||||
for v := range rchan {
|
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
|
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()
|
defer cancel()
|
||||||
|
|
||||||
if sock.State != netstat.Listen {
|
port := int(sock.LocalAddr.Port)
|
||||||
log.Debug().Msg(fmt.Sprintf("Failed to verify socket [%s|%s] invalid state: %s", ipversion, strings.ToUpper(proto), sock.State.String()))
|
|
||||||
|
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")
|
return models.Server{}, errors.New("invalid sock-state")
|
||||||
}
|
}
|
||||||
|
|
||||||
if int(sock.LocalAddr.Port) == bunny.Conf.ServerPort && sock.Process != nil && sock.Process.Pid == bunny.SelfProcessID {
|
if 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)))
|
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")
|
return models.Server{}, errors.New("skip self")
|
||||||
}
|
}
|
||||||
|
|
||||||
c := http.Client{}
|
c := http.Client{}
|
||||||
url := fmt.Sprintf("%s://localhost:%d", strings.ToLower(proto), sock.LocalAddr.Port)
|
url := fmt.Sprintf("%s://localhost:%d", strings.ToLower(proto), port)
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != 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
|
return models.Server{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp1, err := c.Do(req)
|
resp1, err := c.Do(req)
|
||||||
if err != nil {
|
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
|
return models.Server{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -237,7 +275,7 @@ func (app *Application) verifyHTTPConn(sock netstat.SockTabEntry, proto string,
|
||||||
|
|
||||||
resbody, err := io.ReadAll(resp1.Body)
|
resbody, err := io.ReadAll(resp1.Body)
|
||||||
if err != nil {
|
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
|
return models.Server{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -252,7 +290,7 @@ func (app *Application) verifyHTTPConn(sock netstat.SockTabEntry, proto string,
|
||||||
}
|
}
|
||||||
|
|
||||||
return models.Server{
|
return models.Server{
|
||||||
Port: int(sock.LocalAddr.Port),
|
Port: port,
|
||||||
IP: sock.LocalAddr.IP.String(),
|
IP: sock.LocalAddr.IP.String(),
|
||||||
Protocol: proto,
|
Protocol: proto,
|
||||||
StatusCode: resp1.StatusCode,
|
StatusCode: resp1.StatusCode,
|
||||||
|
@ -265,6 +303,6 @@ func (app *Application) verifyHTTPConn(sock netstat.SockTabEntry, proto string,
|
||||||
}, nil
|
}, 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")
|
return models.Server{}, errors.New("invalid response-type")
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@ package swagger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
_ "embed"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
|
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
|
@ -8,6 +8,12 @@
|
||||||
"host": "localhost",
|
"host": "localhost",
|
||||||
"basePath": "/api/v1/",
|
"basePath": "/api/v1/",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"get": {
|
||||||
|
"summary": "(Website)",
|
||||||
|
"responses": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/health": {
|
"/api/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
@ -169,6 +175,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/index.html": {
|
||||||
|
"get": {
|
||||||
|
"summary": "(Website)",
|
||||||
|
"responses": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/scripts.script.js": {
|
||||||
|
"get": {
|
||||||
|
"summary": "(Website)",
|
||||||
|
"responses": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/server": {
|
"/server": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "List running server",
|
"summary": "List running server",
|
||||||
|
|
|
@ -83,6 +83,10 @@ info:
|
||||||
title: LocalHostBunny
|
title: LocalHostBunny
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
paths:
|
paths:
|
||||||
|
/:
|
||||||
|
get:
|
||||||
|
responses: {}
|
||||||
|
summary: (Website)
|
||||||
/api/health:
|
/api/health:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
|
@ -187,6 +191,14 @@ paths:
|
||||||
summary: Return 200 after x seconds
|
summary: Return 200 after x seconds
|
||||||
tags:
|
tags:
|
||||||
- Common
|
- Common
|
||||||
|
/index.html:
|
||||||
|
get:
|
||||||
|
responses: {}
|
||||||
|
summary: (Website)
|
||||||
|
/scripts.script.js:
|
||||||
|
get:
|
||||||
|
responses: {}
|
||||||
|
summary: (Website)
|
||||||
/server:
|
/server:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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') }
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
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.
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
Loading…
Reference in New Issue