From 40d64b8ecf24da1ced726755785cd30c854e9b58 Mon Sep 17 00:00:00 2001 From: Captain ALM Date: Sun, 13 Aug 2023 20:19:47 +0100 Subject: [PATCH] Initial commit. --- .gitattributes | 1 + .gitignore | 21 ++ .idea/.gitignore | 8 + .idea/discord.xml | 7 + .idea/mc-webserver.iml | 9 + .idea/modules.xml | 8 + .woodpecker/build.yml | 12 + LICENSE | 29 ++ Makefile | 52 ++++ README.md | 11 + conf/cache.go | 11 + conf/config.go | 6 + conf/listen.go | 8 + conf/serve.go | 41 +++ config.example.yml | 14 + go.mod | 10 + goinfo.go.html | 313 ++++++++++++++++++++ pageHandler/get-router.go | 54 ++++ pageHandler/go-info-page.go | 174 +++++++++++ pageHandler/page-handler.go | 304 +++++++++++++++++++ pageHandler/page-provider.go | 14 + pageHandler/pages.go | 18 ++ pageHandler/pages/index/data.go | 28 ++ pageHandler/pages/index/index-page.go | 193 ++++++++++++ pageHandler/pages/index/mc.go | 25 ++ pageHandler/pages/index/template-marshal.go | 202 +++++++++++++ pageHandler/utils/content-range-value.go | 78 +++++ pageHandler/utils/etag.go | 48 +++ pageHandler/utils/partial-range-writer.go | 47 +++ pageHandler/utils/process-preconditions.go | 153 ++++++++++ pageHandler/utils/utils.go | 45 +++ utils/info/conf-info.go | 6 + utils/info/product-info.go | 13 + utils/io/buffered-writer.go | 10 + utils/io/counting-writer.go | 10 + utils/yaml/date-type.go | 31 ++ wappmcstat/main.go | 148 +++++++++ wappmcstat/utils.go | 86 ++++++ 38 files changed, 2248 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/discord.xml create mode 100644 .idea/mc-webserver.iml create mode 100644 .idea/modules.xml create mode 100644 .woodpecker/build.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 conf/cache.go create mode 100644 conf/config.go create mode 100644 conf/listen.go create mode 100644 conf/serve.go create mode 100644 config.example.yml create mode 100644 go.mod create mode 100644 goinfo.go.html create mode 100644 pageHandler/get-router.go create mode 100644 pageHandler/go-info-page.go create mode 100644 pageHandler/page-handler.go create mode 100644 pageHandler/page-provider.go create mode 100644 pageHandler/pages.go create mode 100644 pageHandler/pages/index/data.go create mode 100644 pageHandler/pages/index/index-page.go create mode 100644 pageHandler/pages/index/mc.go create mode 100644 pageHandler/pages/index/template-marshal.go create mode 100644 pageHandler/utils/content-range-value.go create mode 100644 pageHandler/utils/etag.go create mode 100644 pageHandler/utils/partial-range-writer.go create mode 100644 pageHandler/utils/process-preconditions.go create mode 100644 pageHandler/utils/utils.go create mode 100644 utils/info/conf-info.go create mode 100644 utils/info/product-info.go create mode 100644 utils/io/buffered-writer.go create mode 100644 utils/io/counting-writer.go create mode 100644 utils/yaml/date-type.go create mode 100644 wappmcstat/main.go create mode 100644 wappmcstat/utils.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5a21c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Environment variables file +.env + +# Distributable directory +dist/ + +# Test data and logs folders +.data/ +.idea/dataSources.xml + +# CDN link +cdn/ +cdn +cdn_/ +cdn_ + +# Config Link +cnf/ +cnf +cnf_/ +cnf_ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/discord.xml b/.idea/discord.xml new file mode 100644 index 0000000..d8e9561 --- /dev/null +++ b/.idea/discord.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/mc-webserver.iml b/.idea/mc-webserver.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/mc-webserver.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5998cf7 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml new file mode 100644 index 0000000..9cb0961 --- /dev/null +++ b/.woodpecker/build.yml @@ -0,0 +1,12 @@ +platform: linux/amd64 + +pipeline: + format: + image: golang + commands: + - files=$(gofmt -l .) && echo "$files" && [ -z "$files" ] + build: + image: golang + commands: + - make build + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7fbe392 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2022, Captain ALM +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cafd211 --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +SHELL := /bin/bash +PRODUCT_NAME := wappmcstat +BIN := dist/${PRODUCT_NAME} +DNAME := ${PRODUCT_NAME}_ +ENTRY_POINT := ./cmd/${PRODUCT_NAME} +HASH := $(shell git rev-parse --short HEAD) +COMMIT_DATE := $(shell git show -s --format=%ci ${HASH}) +BUILD_DATE := $(shell date '+%Y-%m-%d %H:%M:%S') +VERSION := ${HASH} +LD_FLAGS := -s -w -X 'main.buildVersion=${VERSION}' -X 'main.buildDate=${BUILD_DATE}' -X 'main.buildName=${PRODUCT_NAME}' +COMP_BIN := go + +ifeq ($(OS),Windows_NT) + BIN := $(BIN).exe + DNAME := $(DNAME).exe +endif + +.PHONY: build dev test clean deploy d + +build: + mkdir -p dist/ + ${COMP_BIN} build -o "${BIN}" -ldflags="${LD_FLAGS}" ${ENTRY_POINT} + +dev: + mkdir -p dist/ + ${COMP_BIN} build -tags debug -o "${BIN}" -ldflags="${LD_FLAGS}" ${ENTRY_POINT} + ./${BIN} + +test: + ${COMP_BIN} test + +clean: + ${COMP_BIN} clean + rm -r -f dist/ + +deploy: build + sudo systemctl stop wappcityuni + sudo cp "${BIN}" /usr/local/bin + sudo cp *.go.html cnf + sudo cp *.go.yml cnf + sudo cp *.css cdn + sudo cp *.js cdn + sudo systemctl start wappcityuni + +d: build + sudo systemctl stop wappcityuni_ + sudo cp "${BIN}" "/usr/local/bin/${DNAME}" + sudo cp *.go.html cnf_ + sudo cp *.go.yml cnf_ + sudo cp *.css cdn_ + sudo cp *.js cdn_ + sudo systemctl start wappcityuni_ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7087cc9 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Captain ALM Minecraft Statistics WebServer + +[![Build Status](https://ci.mrmelon54.com/api/badges/alfred/mc-webserver/status.svg)](https://ci.mrmelon54.com/alfred/mc-webserver) + +This provides my template and cache supporting web / application server for minecraft server status. + +Maintainer: +[Captain ALM](https://code.mrmelon54.com/alfred) + +License: +[BSD 3-Clause](https://code.mrmelon54.com/alfred/mc-webserver/src/branch/master/LICENSE) diff --git a/conf/cache.go b/conf/cache.go new file mode 100644 index 0000000..8f8b254 --- /dev/null +++ b/conf/cache.go @@ -0,0 +1,11 @@ +package conf + +type CacheSettingsYaml struct { + EnableTemplateCaching bool `yaml:"enableTemplateCaching"` + EnableTemplateCachePurge bool `yaml:"enableTemplateCachePurge"` + EnableContentsCaching bool `yaml:"enableContentsCaching"` + EnableContentsCachePurge bool `yaml:"enableContentsCachePurge"` + MaxAge uint `yaml:"maxAge"` + NotModifiedResponseUsingLastModified bool `yaml:"notModifiedUsingLastModified"` + NotModifiedResponseUsingETags bool `yaml:"notModifiedUsingETags"` +} diff --git a/conf/config.go b/conf/config.go new file mode 100644 index 0000000..8ac8c65 --- /dev/null +++ b/conf/config.go @@ -0,0 +1,6 @@ +package conf + +type ConfigYaml struct { + Listen ListenYaml `yaml:"listen"` + Serve ServeYaml `yaml:"serve"` +} diff --git a/conf/listen.go b/conf/listen.go new file mode 100644 index 0000000..04ad4ce --- /dev/null +++ b/conf/listen.go @@ -0,0 +1,8 @@ +package conf + +type ListenYaml struct { + Web string `yaml:"web"` + WebMethod string `yaml:"webMethod"` + WebNetwork string `yaml:"webNetwork"` + Identify bool `yaml:"identify"` +} diff --git a/conf/serve.go b/conf/serve.go new file mode 100644 index 0000000..a034205 --- /dev/null +++ b/conf/serve.go @@ -0,0 +1,41 @@ +package conf + +import ( + "os" + "path" + "path/filepath" + "strings" +) + +type ServeYaml struct { + DataStorage string `yaml:"dataStorage"` + Domains []string `yaml:"domains"` + RangeSupported bool `yaml:"rangeSupported"` + EnableGoInfoPage bool `yaml:"enableGoInfoPage"` + CacheSettings CacheSettingsYaml `yaml:"cacheSettings"` +} + +func (sy ServeYaml) GetDomainString() string { + if len(sy.Domains) == 0 { + return "all" + } else { + return strings.Join(sy.Domains, " ") + } +} + +func (sy ServeYaml) GetDataStoragePath() string { + if sy.DataStorage == "" || !filepath.IsAbs(sy.DataStorage) { + wd, err := os.Getwd() + if err != nil { + return "" + } else { + if sy.DataStorage == "" { + return wd + } else { + return path.Join(wd, sy.DataStorage) + } + } + } else { + return sy.DataStorage + } +} diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..e49631e --- /dev/null +++ b/config.example.yml @@ -0,0 +1,14 @@ +listen: + webNetwork: "tcp4" + web: ":8080" + webMethod: "http" + identify: true +serve: + rangeSupported: true + enableGoInfoPage: true + cacheSettings: + enableContentsCaching: true + enableContentsCachePurge: true + maxAge: 3600 + notModifiedUsingLastModified: true + notModifiedUsingETags: true \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..516707f --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module golang.captainalm.com/mc-webserver + +go 1.18 + +require ( + github.com/gorilla/mux v1.8.0 + github.com/joho/godotenv v1.4.0 + gopkg.in/yaml.v3 v3.0.1 + github.com/mcstatus-io/mcutil v1.3.1 +) diff --git a/goinfo.go.html b/goinfo.go.html new file mode 100644 index 0000000..740966a --- /dev/null +++ b/goinfo.go.html @@ -0,0 +1,313 @@ + + + + + + + Go Info + + + +

+

+

{{ .GoVersion }} - {{ .ProductName }}

+
+

+

+ {{ if .FullOutput }} +

+ + Less Output + +
+{{ else }} +
+ + More Output + +
+{{ end }} +

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ if .FullOutput }} + + + + + + + + + {{ end }} +
Product Name{{ .ProductName }}
Product Description{{ .ProductDescription }}
Product LicenseBSD 3-Clause License
Product Location{{ .ProductLocation }}
Build Commit#{{ .BuildVersion }}
Build Date{{ .BuildDate }}
Working Directory{{ .WorkingDirectory }}
Process ID{{ .ProcessID }}
Parent Process ID{{ .ParentProcessID }}
+

+

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Go Version{{ .GoVersion }}
Go Toolchain{{ .Compiler }}
GOROOT{{ .GoRoot }}
GOMAXPROCS{{ .GoMaxProcs }}
Go Routine Count{{ .GoRoutineNum }}
Go c go call Count{{ .GoCGoCallNum }}
+

+

+ + + + + + + + + + + + + + + + + + + + + +
Hostname{{ .Hostname }}
Operating System{{ .GoOS }}
Architecture{{ .GoArch }}
Number of Cores{{ .NumCPU }}
Memory Page Size{{ .PageSize }}
+

+

+ + + + + + + + + + + + + + + + + + {{ if and .FullOutput .ListenSettings.Identify }} + + + + + + + + + + + + + {{ end }} +
Listen Type{{ .ListenSettings.WebNetwork }}
Listening Address{{ .ListenSettings.Web }}
Listening Method{{ .ListenSettings.WebMethod }}
Identifying{{ .ListenSettings.Identify }}
ServerClerie Gilbert
Powered ByLove
FriendlyTrue
+

+

+ + + + + + + + + + + + + +
Template Storage Path{{ .ServeSettings.GetDataStoragePath }}
Served Domains{{ .ServeSettings.GetDomainString }}
Range Supported{{ .ServeSettings.RangeSupported }}
+

+{{ if .FullOutput }} +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Enable Template Caching{{ .ServeSettings.CacheSettings.EnableTemplateCaching }}
Enable Template Cache Purge{{ .ServeSettings.CacheSettings.EnableTemplateCachePurge }}
Enable Content Caching{{ .ServeSettings.CacheSettings.EnableContentsCaching }}
Enable Content Cache Purge{{ .ServeSettings.CacheSettings.EnableContentsCachePurge }}
Max Age{{ .ServeSettings.CacheSettings.MaxAge }}
Enable Last Modified Precondition Support{{ .ServeSettings.CacheSettings.NotModifiedResponseUsingLastModified }}
Enable ETag Precondition Support{{ .ServeSettings.CacheSettings.NotModifiedResponseUsingETags }}
+

+

+ + + + + {{ range .Environment }} + + + + {{ end }} +
Environment Variables
{{ . }}
+

+{{ end }} +

+ + + + + +
Number of Registered Pages{{ len .RegisteredPages }}
+

+{{ if and .FullOutput (not (eq (len .RegisteredPages) 0)) }} +

+ + + + + {{ range .RegisteredPages }} + + + + {{ end }} +
Registered Pages
{{ . }}
+

+{{ end }} +

+

+ + + + + +
Number of Cached Pages{{ len .CachedPages }}
+

+{{ if and .FullOutput (not (eq (len .CachedPages) 0)) }} +

+ + + + + {{ range .CachedPages }} + + + + {{ end }} +
Cached Pages
{{ . }}
+

+{{ end }} +

+

+ {{ if .FullOutput }} +

+ + Less Output + +
+{{ else }} +
+ + More Output + +
+{{ end }} +

+ + \ No newline at end of file diff --git a/pageHandler/get-router.go b/pageHandler/get-router.go new file mode 100644 index 0000000..61fdc1f --- /dev/null +++ b/pageHandler/get-router.go @@ -0,0 +1,54 @@ +package pageHandler + +import ( + "github.com/gorilla/mux" + "golang.captainalm.com/mc-webserver/conf" + "golang.captainalm.com/mc-webserver/pageHandler/utils" + "net/http" +) + +var theRouter *mux.Router +var thePageHandler *PageHandler + +func GetRouter(config conf.ConfigYaml) http.Handler { + if theRouter == nil { + theRouter = mux.NewRouter() + if thePageHandler == nil { + thePageHandler = NewPageHandler(config.Serve) + } + if len(config.Serve.Domains) == 0 { + theRouter.PathPrefix("/").HandlerFunc(thePageHandler.ServeHTTP) + } else { + for _, domain := range config.Serve.Domains { + theRouter.Host(domain).HandlerFunc(thePageHandler.ServeHTTP) + } + theRouter.PathPrefix("/").HandlerFunc(domainNotAllowed) + } + if config.Listen.Identify { + theRouter.Use(headerMiddleware) + } + } + return theRouter +} + +func domainNotAllowed(rw http.ResponseWriter, req *http.Request) { + if req.Method == http.MethodGet || req.Method == http.MethodHead { + utils.WriteResponseHeaderCanWriteBody(req.Method, rw, http.StatusNotFound, "Domain Not Allowed") + } else { + rw.Header().Set("Allow", http.MethodOptions+", "+http.MethodGet+", "+http.MethodHead) + if req.Method == http.MethodOptions { + utils.WriteResponseHeaderCanWriteBody(req.Method, rw, http.StatusOK, "") + } else { + utils.WriteResponseHeaderCanWriteBody(req.Method, rw, http.StatusMethodNotAllowed, "") + } + } +} + +func headerMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", "Clerie Gilbert") + w.Header().Set("X-Powered-By", "Love") + w.Header().Set("X-Friendly", "True") + next.ServeHTTP(w, r) + }) +} diff --git a/pageHandler/go-info-page.go b/pageHandler/go-info-page.go new file mode 100644 index 0000000..524a3fb --- /dev/null +++ b/pageHandler/go-info-page.go @@ -0,0 +1,174 @@ +package pageHandler + +import ( + "golang.captainalm.com/mc-webserver/conf" + "golang.captainalm.com/mc-webserver/utils/info" + "golang.captainalm.com/mc-webserver/utils/io" + "html/template" + "net/url" + "os" + "path" + "runtime" + "sync" + "time" +) + +const templateName = "goinfo.go.html" + +func newGoInfoPage(handlerIn *PageHandler, dataStore string, cacheTemplates bool) *goInfoPage { + var ptm *sync.Mutex + if cacheTemplates { + ptm = &sync.Mutex{} + } + pageToReturn := &goInfoPage{ + Handler: handlerIn, + DataStore: dataStore, + PageTemplateMutex: ptm, + } + return pageToReturn +} + +type goInfoPage struct { + Handler *PageHandler + DataStore string + PageTemplateMutex *sync.Mutex + PageTemplate *template.Template +} + +func (gipg *goInfoPage) GetCacheIDExtension(urlParameters url.Values) string { + if urlParameters.Has("full") { + return "full" + } else { + return "" + } +} + +type goInfoTemplateMarshal struct { + FullOutput bool + RegisteredPages []string + CachedPages []string + ProcessID int + ParentProcessID int + ProductLocation string + ProductName string + ProductDescription string + BuildVersion string + BuildDate string + WorkingDirectory string + Hostname string + PageSize int + GoVersion string + GoRoutineNum int + GoCGoCallNum int64 + NumCPU int + GoRoot string + GoMaxProcs int + Compiler string + GoArch string + GoOS string + ListenSettings conf.ListenYaml + ServeSettings conf.ServeYaml + Environment []string +} + +func (gipg *goInfoPage) GetPath() string { + return "/goinfo.go" +} + +func (gipg *goInfoPage) GetLastModified() time.Time { + return time.Now() +} + +func (gipg *goInfoPage) GetContents(urlParameters url.Values) (contentType string, contents []byte, canCache bool) { + theTemplate, err := gipg.getPageTemplate() + if err != nil { + return "text/plain", []byte("Cannot Get Info.\r\n" + err.Error()), false + } + var regPages []string + var cacPages []string + env := make([]string, 0) + if urlParameters.Has("full") { + regPages = gipg.Handler.GetRegisteredPages() + cacPages = gipg.Handler.GetCachedPages() + env = os.Environ() + } else { + regPages = make([]string, len(gipg.Handler.PageProviders)) + cacPages = make([]string, gipg.Handler.GetNumberOfCachedPages()) + } + theBuffer := &io.BufferedWriter{} + err = theTemplate.ExecuteTemplate(theBuffer, templateName, &goInfoTemplateMarshal{ + FullOutput: urlParameters.Has("full"), + RegisteredPages: regPages, + CachedPages: cacPages, + ProcessID: os.Getpid(), + ParentProcessID: os.Getppid(), + ProductLocation: getStringOrError(os.Executable), + ProductName: info.BuildName, + ProductDescription: info.BuildDescription, + BuildVersion: info.BuildVersion, + BuildDate: info.BuildDate, + WorkingDirectory: getStringOrError(os.Getwd), + Hostname: getStringOrError(os.Hostname), + PageSize: os.Getpagesize(), + GoVersion: runtime.Version(), + GoRoutineNum: runtime.NumGoroutine(), + GoCGoCallNum: runtime.NumCgoCall(), + NumCPU: runtime.NumCPU(), + GoRoot: runtime.GOROOT(), + GoMaxProcs: runtime.GOMAXPROCS(0), + Compiler: runtime.Compiler, + GoArch: runtime.GOARCH, + GoOS: runtime.GOOS, + ListenSettings: info.ListenSettings, + ServeSettings: info.ServeSettings, + Environment: env, + }) + if err != nil { + return "text/plain", []byte("Cannot Get Info.\r\n" + err.Error()), false + } + return "text/html", theBuffer.Data, false +} + +func (gipg *goInfoPage) PurgeTemplate() { + if gipg.PageTemplateMutex != nil { + gipg.PageTemplateMutex.Lock() + gipg.PageTemplate = nil + gipg.PageTemplateMutex.Unlock() + } +} + +func (gipg *goInfoPage) getPageTemplate() (*template.Template, error) { + if gipg.PageTemplateMutex != nil { + gipg.PageTemplateMutex.Lock() + defer gipg.PageTemplateMutex.Unlock() + } + if gipg.PageTemplate == nil { + thePath := templateName + if gipg.DataStore != "" { + thePath = path.Join(gipg.DataStore, thePath) + } + loadedData, err := os.ReadFile(thePath) + if err != nil { + return nil, err + } + tmpl, err := template.New(templateName).Parse(string(loadedData)) + if err != nil { + return nil, err + } + if gipg.PageTemplateMutex != nil { + gipg.PageTemplate = tmpl + } + return tmpl, nil + } else { + return gipg.PageTemplate, nil + } +} + +func getStringOrError(funcIn func() (string, error)) string { + toReturn, err := funcIn() + if err == nil { + return toReturn + } else { + return "Error: " + err.Error() + } +} diff --git a/pageHandler/page-handler.go b/pageHandler/page-handler.go new file mode 100644 index 0000000..fcb7b6c --- /dev/null +++ b/pageHandler/page-handler.go @@ -0,0 +1,304 @@ +package pageHandler + +import ( + "golang.captainalm.com/mc-webserver/conf" + "golang.captainalm.com/mc-webserver/pageHandler/utils" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "strconv" + "strings" + "sync" + "time" +) + +const indexName = "index.go" + +type PageHandler struct { + PageContentsCache map[string]*CachedPage + PageProviders map[string]PageProvider + pageContentsCacheRWMutex *sync.RWMutex + RangeSupported bool + CacheSettings conf.CacheSettingsYaml +} + +type CachedPage struct { + Content []byte + ContentType string + LastMod time.Time +} + +func NewPageHandler(config conf.ServeYaml) *PageHandler { + var thePCCMap map[string]*CachedPage + var theMutex *sync.RWMutex + if config.CacheSettings.EnableContentsCaching { + thePCCMap = make(map[string]*CachedPage) + theMutex = &sync.RWMutex{} + } + toReturn := &PageHandler{ + PageContentsCache: thePCCMap, + pageContentsCacheRWMutex: theMutex, + RangeSupported: config.RangeSupported, + CacheSettings: config.CacheSettings, + } + if config.EnableGoInfoPage { + toReturn.PageProviders = GetProviders(config.CacheSettings.EnableTemplateCaching, config.DataStorage, toReturn) + } else { + toReturn.PageProviders = GetProviders(config.CacheSettings.EnableTemplateCaching, config.DataStorage, nil) + } + return toReturn +} + +func (ph *PageHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + actualPagePath := "" + if strings.HasSuffix(request.URL.Path, "/") { + if strings.HasSuffix(request.URL.Path, ".go/") { + actualPagePath = strings.TrimRight(request.URL.Path, "/") + } else { + actualPagePath = request.URL.Path + indexName + } + } else { + actualPagePath = request.URL.Path + } + + var currentProvider PageProvider + canCache := false + actualQueries := "" + queryValues := request.URL.Query() + var pageContent []byte + pageContentType := "" + var lastMod time.Time + + if currentProvider = ph.PageProviders[actualPagePath]; currentProvider != nil { + actualQueries = currentProvider.GetCacheIDExtension(queryValues) + + if ph.CacheSettings.EnableContentsCaching { + cached := ph.getPageFromCache(request.URL.Path, actualQueries) + if cached != nil { + pageContent = cached.Content + pageContentType = cached.ContentType + lastMod = cached.LastMod + } + } + + if pageContentType == "" { + pageContentType, pageContent, canCache = currentProvider.GetContents(queryValues) + lastMod = currentProvider.GetLastModified() + if pageContentType != "" && canCache && ph.CacheSettings.EnableContentsCaching { + ph.setPageToCache(request.URL.Path, actualQueries, &CachedPage{ + Content: pageContent, + ContentType: pageContentType, + LastMod: lastMod, + }) + } + } + } + + allowedMethods := ph.getAllowedMethodsForPath(request.URL.Path) + allowed := false + if request.Method != http.MethodOptions { + for _, method := range allowedMethods { + if method == request.Method { + allowed = true + break + } + } + } + + if allowed { + + if pageContentType == "" { + utils.WriteResponseHeaderCanWriteBody(request.Method, writer, http.StatusNotFound, "Page Not Found") + } else { + + switch request.Method { + case http.MethodGet, http.MethodHead: + + writer.Header().Set("Content-Type", pageContentType) + writer.Header().Set("Content-Length", strconv.Itoa(len(pageContent))) + utils.SetLastModifiedHeader(writer.Header(), lastMod) + utils.SetCacheHeaderWithAge(writer.Header(), ph.CacheSettings.MaxAge, lastMod) + theETag := utils.GetValueForETagUsingByteArray(pageContent) + writer.Header().Set("ETag", theETag) + + if utils.ProcessSupportedPreconditionsForNext(writer, request, lastMod, theETag, ph.CacheSettings.NotModifiedResponseUsingLastModified, ph.CacheSettings.NotModifiedResponseUsingETags) { + + httpRangeParts := utils.ProcessRangePreconditions(int64(len(pageContent)), writer, request, lastMod, theETag, ph.RangeSupported) + if httpRangeParts != nil { + if len(httpRangeParts) <= 1 { + var theWriter io.Writer = writer + if len(httpRangeParts) == 1 { + theWriter = utils.NewPartialRangeWriter(theWriter, httpRangeParts[0]) + } + _, _ = theWriter.Write(pageContent) + } else { + multWriter := multipart.NewWriter(writer) + writer.Header().Set("Content-Type", "multipart/byteranges; boundary="+multWriter.Boundary()) + for _, currentPart := range httpRangeParts { + mimePart, err := multWriter.CreatePart(textproto.MIMEHeader{ + "Content-Range": {currentPart.ToField(int64(len(pageContent)))}, + "Content-Type": {"text/plain; charset=utf-8"}, + }) + if err != nil { + break + } + _, err = mimePart.Write(pageContent[currentPart.Start : currentPart.Start+currentPart.Length]) + if err != nil { + break + } + } + _ = multWriter.Close() + } + } + } + case http.MethodDelete: + ph.PurgeTemplateCache(actualPagePath, request.URL.Path == "/") + ph.PurgeContentsCache(request.URL.Path, actualQueries) + utils.SetNeverCacheHeader(writer.Header()) + utils.WriteResponseHeaderCanWriteBody(request.Method, writer, http.StatusOK, "") + } + } + } else { + + theAllowHeaderContents := "" + for _, method := range allowedMethods { + theAllowHeaderContents += method + ", " + } + + writer.Header().Set("Allow", strings.TrimSuffix(theAllowHeaderContents, ", ")) + if request.Method == http.MethodOptions { + utils.WriteResponseHeaderCanWriteBody(request.Method, writer, http.StatusOK, "") + } else { + utils.WriteResponseHeaderCanWriteBody(request.Method, writer, http.StatusMethodNotAllowed, "") + } + } +} + +func (ph *PageHandler) PurgeContentsCache(path string, query string) { + if ph.CacheSettings.EnableContentsCaching && ph.CacheSettings.EnableContentsCachePurge { + if path == "/" { + ph.pageContentsCacheRWMutex.Lock() + ph.PageContentsCache = make(map[string]*CachedPage) + ph.pageContentsCacheRWMutex.Unlock() + } else { + if strings.HasSuffix(path, ".go/") { + ph.pageContentsCacheRWMutex.RLock() + toDelete := make([]string, len(ph.PageContentsCache)) + theSize := 0 + for cPath := range ph.PageContentsCache { + dPath := strings.Split(cPath, "?")[0] + if dPath == path || dPath == path[:len(path)-1] { + toDelete[theSize] = cPath + theSize++ + } + } + ph.pageContentsCacheRWMutex.RUnlock() + ph.pageContentsCacheRWMutex.Lock() + for i := 0; i < theSize; i++ { + delete(ph.PageContentsCache, toDelete[i]) + } + ph.pageContentsCacheRWMutex.Unlock() + return + } else if strings.HasSuffix(path, "/") { + path += indexName + } + ph.pageContentsCacheRWMutex.Lock() + if query == "" { + delete(ph.PageContentsCache, path) + } else { + delete(ph.PageContentsCache, path+"?"+query) + } + ph.pageContentsCacheRWMutex.Unlock() + } + } +} + +func (ph *PageHandler) PurgeTemplateCache(path string, all bool) { + if ph.CacheSettings.EnableTemplateCaching && ph.CacheSettings.EnableTemplateCachePurge { + if all { + for _, pageProvider := range ph.PageProviders { + pageProvider.PurgeTemplate() + } + } else { + if pageProvider, ok := ph.PageProviders[path]; ok { + pageProvider.PurgeTemplate() + } + } + } +} +func (ph *PageHandler) getPageFromCache(pathIn string, cleanedQueries string) *CachedPage { + ph.pageContentsCacheRWMutex.RLock() + defer ph.pageContentsCacheRWMutex.RUnlock() + if strings.HasSuffix(pathIn, ".go/") { + return ph.PageContentsCache[strings.TrimRight(pathIn, "/")] + } else if strings.HasSuffix(pathIn, "/") { + pathIn += indexName + } + if cleanedQueries == "" { + return ph.PageContentsCache[pathIn] + } else { + return ph.PageContentsCache[pathIn+"?"+cleanedQueries] + } +} + +func (ph *PageHandler) setPageToCache(pathIn string, cleanedQueries string, newPage *CachedPage) { + ph.pageContentsCacheRWMutex.Lock() + defer ph.pageContentsCacheRWMutex.Unlock() + if strings.HasSuffix(pathIn, ".go/") { + ph.PageContentsCache[strings.TrimRight(pathIn, "/")] = newPage + return + } else if strings.HasSuffix(pathIn, "/") { + pathIn += indexName + } + if cleanedQueries == "" { + ph.PageContentsCache[pathIn] = newPage + } else { + ph.PageContentsCache[pathIn+"?"+cleanedQueries] = newPage + } +} + +func (ph *PageHandler) getAllowedMethodsForPath(pathIn string) []string { + if pathIn == "/" || strings.HasSuffix(pathIn, ".go/") { + if (ph.CacheSettings.EnableTemplateCaching && ph.CacheSettings.EnableTemplateCachePurge) || + (ph.CacheSettings.EnableContentsCaching && ph.CacheSettings.EnableContentsCachePurge) { + return []string{http.MethodHead, http.MethodGet, http.MethodOptions, http.MethodDelete} + } else { + return []string{http.MethodHead, http.MethodGet, http.MethodOptions} + } + } else { + if ph.CacheSettings.EnableContentsCaching && ph.CacheSettings.EnableContentsCachePurge { + return []string{http.MethodHead, http.MethodGet, http.MethodOptions, http.MethodDelete} + } else { + return []string{http.MethodHead, http.MethodGet, http.MethodOptions} + } + } +} + +func (ph *PageHandler) GetRegisteredPages() []string { + pages := make([]string, len(ph.PageProviders)) + index := 0 + for s := range ph.PageProviders { + pages[index] = s + index++ + } + return pages +} + +func (ph *PageHandler) GetCachedPages() []string { + ph.pageContentsCacheRWMutex.RLock() + defer ph.pageContentsCacheRWMutex.RUnlock() + pages := make([]string, len(ph.PageContentsCache)) + index := 0 + for s := range ph.PageContentsCache { + pages[index] = s + index++ + } + return pages +} + +func (ph *PageHandler) GetNumberOfCachedPages() int { + ph.pageContentsCacheRWMutex.RLock() + defer ph.pageContentsCacheRWMutex.RUnlock() + return len(ph.PageContentsCache) +} diff --git a/pageHandler/page-provider.go b/pageHandler/page-provider.go new file mode 100644 index 0000000..8cd6b6f --- /dev/null +++ b/pageHandler/page-provider.go @@ -0,0 +1,14 @@ +package pageHandler + +import ( + "net/url" + "time" +) + +type PageProvider interface { + GetPath() string + GetLastModified() time.Time + GetCacheIDExtension(urlParameters url.Values) string + GetContents(urlParameters url.Values) (contentType string, contents []byte, canCache bool) + PurgeTemplate() +} diff --git a/pageHandler/pages.go b/pageHandler/pages.go new file mode 100644 index 0000000..24a9e8b --- /dev/null +++ b/pageHandler/pages.go @@ -0,0 +1,18 @@ +package pageHandler + +import "golang.captainalm.com/mc-webserver/pageHandler/pages/index" + +var providers map[string]PageProvider + +func GetProviders(cacheTemplates bool, dataStorage string, pageHandler *PageHandler) map[string]PageProvider { + if providers == nil { + providers = make(map[string]PageProvider) + if pageHandler != nil { + infoPage := newGoInfoPage(pageHandler, dataStorage, cacheTemplates) + providers[infoPage.GetPath()] = infoPage //Go Information Page + } + indexPage := index.NewPage(dataStorage, cacheTemplates) + providers[indexPage.GetPath()] = indexPage + } + return providers +} diff --git a/pageHandler/pages/index/data.go b/pageHandler/pages/index/data.go new file mode 100644 index 0000000..76fe93b --- /dev/null +++ b/pageHandler/pages/index/data.go @@ -0,0 +1,28 @@ +package index + +import ( + "html/template" + "time" +) + +type DataYaml struct { + PageTitle string `yaml:"pageTitle"` + ServerDescription template.HTML `yaml:"serverDescription"` + MCAddress string `yaml:"mcAddress"` + MCPort uint16 `yaml:"mcPort"` + MCType string `yaml:"mcType"` + MCProtocolVersion int `yaml:"mcProtocolVersion"` + MCClientGUID int64 `yaml:"mcClientGUID"` + MCTimeout time.Duration `yaml:"mcTimeout"` + AllowDisplayState bool `yaml:"allowDisplayState"` + AllowDisplayVersion bool `yaml:"allowDisplayVersion"` + AllowDisplayActualAddress bool `yaml:"allowDisplayActualAddress"` + AllowPlayerCountDisplay bool `yaml:"allowPlayerCountDisplay"` + AllowPlayerListing bool `yaml:"allowPlayerListing"` + AllowMOTDDisplay bool `yaml:"allowMOTDDisplay"` + AllowFaviconDisplay bool `yaml:"allowFaviconDisplay"` + AllowSecureProfileModeDisplay bool `yaml:"allowSecureProfileModeDisplay"` + AllowPreviewChatModeDisplay bool `yaml:"allowPreviewChatModeDisplay"` + AllowDisplayModded bool `yaml:"allowDisplayModded"` + AllowModListing bool `yaml:"allowModListing"` +} diff --git a/pageHandler/pages/index/index-page.go b/pageHandler/pages/index/index-page.go new file mode 100644 index 0000000..398b9bd --- /dev/null +++ b/pageHandler/pages/index/index-page.go @@ -0,0 +1,193 @@ +package index + +import ( + "golang.captainalm.com/mc-webserver/utils/io" + "gopkg.in/yaml.v3" + "html/template" + "net/url" + "os" + "path" + "strings" + "sync" + "time" +) + +const templateName = "index.go.html" +const yamlName = "index.go.yml" + +func NewPage(dataStore string, cacheTemplates bool) *Page { + var ptm *sync.Mutex + var sdm *sync.Mutex + if cacheTemplates { + ptm = &sync.Mutex{} + sdm = &sync.Mutex{} + } + pageToReturn := &Page{ + DataStore: dataStore, + StoredDataMutex: sdm, + PageTemplateMutex: ptm, + } + return pageToReturn +} + +type Page struct { + DataStore string + StoredDataMutex *sync.Mutex + StoredData *DataYaml + LastModifiedData time.Time + PageTemplateMutex *sync.Mutex + PageTemplate *template.Template + LastModifiedTemplate time.Time +} + +func (p *Page) GetPath() string { + return "/index.go" +} + +func (p *Page) GetLastModified() time.Time { + if p.LastModifiedData.After(p.LastModifiedTemplate) { + return p.LastModifiedData + } else { + return p.LastModifiedTemplate + } +} + +func (p *Page) GetCacheIDExtension(urlParameters url.Values) string { + toReturn := p.getNonThemedCleanQuery(urlParameters) + if toReturn != "" { + toReturn += "&" + } + if urlParameters.Has("light") { + toReturn += "light" + } + return strings.TrimRight(toReturn, "&") +} + +func (p *Page) getNonThemedCleanQuery(urlParameters url.Values) string { + toReturn := "" + if urlParameters.Has("players") { + toReturn += "players&" + } + if urlParameters.Has("mods") { + toReturn += "mods&" + } + if urlParameters.Has("extended") { + toReturn += "extended" + } + return strings.TrimRight(toReturn, "&") +} + +func (p *Page) GetContents(urlParameters url.Values) (contentType string, contents []byte, canCache bool) { + theTemplate, err := p.getPageTemplate() + if err != nil { + return "text/plain", []byte("Cannot Get Index.\r\n" + err.Error()), false + } + theData, err := p.getPageData() + if err != nil { + return "text/plain", []byte("Cannot Get Data.\r\n" + err.Error()), false + } + theMarshal := &Marshal{ + Data: *theData, + Light: urlParameters.Has("light"), + PlayersShown: urlParameters.Has("players"), + ModsShown: urlParameters.Has("mods"), + ExtendedShown: urlParameters.Has("extended"), + Parameters: template.URL(p.getNonThemedCleanQuery(urlParameters)), + Online: true, + } + theMC, err := theMarshal.NewMC() + theMarshal.Queried = theMC + if err != nil { + theMarshal.Online = false + } + theBuffer := &io.BufferedWriter{} + err = theTemplate.ExecuteTemplate(theBuffer, templateName, theMarshal) + if err != nil { + return "text/plain", []byte("Cannot Get Page.\r\n" + err.Error()), false + } + return "text/html", theBuffer.Data, true +} + +func (p *Page) PurgeTemplate() { + if p.PageTemplateMutex != nil { + p.PageTemplateMutex.Lock() + p.PageTemplate = nil + p.PageTemplateMutex.Unlock() + } + if p.StoredDataMutex != nil { + p.StoredDataMutex.Lock() + p.StoredData = nil + p.StoredDataMutex.Unlock() + } +} + +func (p *Page) getPageTemplate() (*template.Template, error) { + if p.PageTemplateMutex != nil { + p.PageTemplateMutex.Lock() + defer p.PageTemplateMutex.Unlock() + } + if p.PageTemplate == nil { + thePath := templateName + if p.DataStore != "" { + thePath = path.Join(p.DataStore, thePath) + } + stat, err := os.Stat(thePath) + if err != nil { + return nil, err + } + p.LastModifiedTemplate = stat.ModTime() + loadedData, err := os.ReadFile(thePath) + if err != nil { + return nil, err + } + tmpl, err := template.New(templateName).Parse(string(loadedData)) + if err != nil { + return nil, err + } + if p.PageTemplateMutex != nil { + p.PageTemplate = tmpl + } + return tmpl, nil + } else { + return p.PageTemplate, nil + } +} + +func (p *Page) getPageData() (*DataYaml, error) { + if p.StoredDataMutex != nil { + p.StoredDataMutex.Lock() + defer p.StoredDataMutex.Unlock() + } + if p.StoredData == nil { + thePath := yamlName + if p.DataStore != "" { + thePath = path.Join(p.DataStore, thePath) + } + stat, err := os.Stat(thePath) + if err != nil { + return nil, err + } + p.LastModifiedData = stat.ModTime() + fileHandle, err := os.Open(thePath) + if err != nil { + return nil, err + } + dataYaml := &DataYaml{} + decoder := yaml.NewDecoder(fileHandle) + err = decoder.Decode(dataYaml) + if err != nil { + _ = fileHandle.Close() + return nil, err + } + err = fileHandle.Close() + if err != nil { + return nil, err + } + if p.StoredDataMutex != nil { + p.StoredData = dataYaml + } + return dataYaml, nil + } else { + return p.StoredData, nil + } +} diff --git a/pageHandler/pages/index/mc.go b/pageHandler/pages/index/mc.go new file mode 100644 index 0000000..0543435 --- /dev/null +++ b/pageHandler/pages/index/mc.go @@ -0,0 +1,25 @@ +package index + +import ( + "html/template" +) + +type MC struct { + Version *string + ProtocolVersion *int64 + Address string + Port uint16 + Port6 *uint16 + PlayerCount *int64 + MaxPlayers *int64 + Players []string + MOTD string + ActualHost *string + ActualPort *uint16 + Favicon *template.HTML + Edition *string + ModCount int64 + Mods []string + SecureProfilesEnforced *bool + PreviewChatEnforced *bool +} diff --git a/pageHandler/pages/index/template-marshal.go b/pageHandler/pages/index/template-marshal.go new file mode 100644 index 0000000..e368d4e --- /dev/null +++ b/pageHandler/pages/index/template-marshal.go @@ -0,0 +1,202 @@ +package index + +import ( + "errors" + "github.com/mcstatus-io/mcutil" + "github.com/mcstatus-io/mcutil/options" + "github.com/mcstatus-io/mcutil/response" + "html/template" + "strings" +) + +type Marshal struct { + Data DataYaml + Queried MC + PlayersShown bool + ModsShown bool + ExtendedShown bool + Parameters template.URL + Light bool + Online bool +} + +func (m Marshal) NewMC() (MC, error) { + switch strings.ToLower(m.Data.MCType) { + case "java": + r, err := mcutil.Status(m.Data.MCAddress, m.Data.MCPort, options.JavaStatus{ + EnableSRV: m.ExtendedShown && m.Data.AllowDisplayActualAddress, + Timeout: m.Data.MCTimeout, + ProtocolVersion: m.Data.MCProtocolVersion, + }) + if err != nil { + return MC{}, err + } + r2, err := mcutil.StatusRaw(m.Data.MCAddress, m.Data.MCPort, options.JavaStatus{ + Timeout: m.Data.MCTimeout, + ProtocolVersion: m.Data.MCProtocolVersion, + }) + if err != nil { + return MC{}, err + } + return MC{ + Version: &r.Version.NameClean, + ProtocolVersion: &r.Version.Protocol, + Address: m.Data.MCAddress, + Port: m.Data.MCPort, + Port6: nil, + PlayerCount: r.Players.Online, + MaxPlayers: r.Players.Max, + Players: CollectPlayers(r.Players.Sample), + MOTD: r.MOTD.Clean, + ActualHost: CollectSRVHost(r.SRVResult), + ActualPort: CollectSRVPort(r.SRVResult), + Favicon: CollectFavicon(r.Favicon), + Edition: CollectModEdition(r.ModInfo), + ModCount: CollectModCount(r.ModInfo), + Mods: CollectMods(r.ModInfo), + SecureProfilesEnforced: CollectSecureProfileEnforcement(r2), + PreviewChatEnforced: CollectPreviewChatEnforcement(r2), + }, nil + case "legacy", "legacyjava", "javalegacy", "legacy java", "java legacy", "legacy_java", "java_legacy": + r, err := mcutil.StatusLegacy(m.Data.MCAddress, m.Data.MCPort, options.JavaStatusLegacy{ + EnableSRV: m.ExtendedShown && m.Data.AllowDisplayActualAddress, + Timeout: m.Data.MCTimeout, + ProtocolVersion: m.Data.MCProtocolVersion, + }) + if err != nil { + return MC{}, err + } + return MC{ + Version: &r.Version.NameClean, + ProtocolVersion: &r.Version.Protocol, + Address: m.Data.MCAddress, + Port: m.Data.MCPort, + Port6: nil, + PlayerCount: &r.Players.Online, + MaxPlayers: &r.Players.Max, + Players: nil, + MOTD: r.MOTD.Clean, + ActualHost: CollectSRVHost(r.SRVResult), + ActualPort: CollectSRVPort(r.SRVResult), + Favicon: nil, + Edition: nil, + ModCount: 0, + Mods: nil, + SecureProfilesEnforced: nil, + PreviewChatEnforced: nil, + }, nil + case "bedrock": + r, err := mcutil.StatusBedrock(m.Data.MCAddress, m.Data.MCPort, options.BedrockStatus{ + EnableSRV: m.ExtendedShown && m.Data.AllowDisplayActualAddress, + Timeout: m.Data.MCTimeout, + ClientGUID: m.Data.MCClientGUID, + }) + if err != nil { + return MC{}, err + } + return MC{ + Version: r.Version, + ProtocolVersion: r.ProtocolVersion, + Address: m.Data.MCAddress, + Port: m.CollectIPv4Port(r.PortIPv4), + Port6: r.PortIPv6, + PlayerCount: r.OnlinePlayers, + MaxPlayers: r.MaxPlayers, + Players: nil, + MOTD: r.MOTD.Clean, + ActualHost: CollectSRVHost(r.SRVResult), + ActualPort: CollectSRVPort(r.SRVResult), + Favicon: nil, + Edition: r.Edition, + ModCount: 0, + Mods: nil, + SecureProfilesEnforced: nil, + PreviewChatEnforced: nil, + }, nil + default: + return MC{}, errors.New("Invalid MCType") + } +} + +func CollectPlayers(sampleArray []response.SamplePlayer) []string { + if sampleArray == nil { + return nil + } + toReturn := make([]string, len(sampleArray)) + for i := 0; i < len(sampleArray); i++ { + toReturn[i] = sampleArray[i].NameClean + } + return toReturn +} + +func CollectSRVHost(srv *response.SRVRecord) *string { + if srv == nil { + return nil + } + return &srv.Host +} + +func CollectSRVPort(srv *response.SRVRecord) *uint16 { + if srv == nil { + return nil + } + return &srv.Port +} + +func CollectFavicon(favicon *string) *template.HTML { + if favicon == nil { + return nil + } + toReturn := template.HTML(*favicon) + return &toReturn +} + +func CollectModEdition(mod *response.ModInfo) *string { + if mod == nil { + return nil + } + return &mod.Type +} + +func CollectModCount(mod *response.ModInfo) int64 { + if mod == nil { + return 0 + } + return int64(len(mod.Mods)) +} + +func CollectMods(mod *response.ModInfo) []string { + if mod == nil { + return nil + } + toReturn := make([]string, len(mod.Mods)) + for i := 0; i < len(mod.Mods); i++ { + toReturn[i] = mod.Mods[i].ID + " (" + mod.Mods[i].Version + ")" + } + return toReturn +} + +func CollectSecureProfileEnforcement(data map[string]interface{}) *bool { + val, ok := data["enforcesSecureChat"] + if ok { + toReturn := val.(bool) + return &toReturn + } + return nil +} + +func CollectPreviewChatEnforcement(data map[string]interface{}) *bool { + val, ok := data["previewsChat"] + if ok { + toReturn := val.(bool) + return &toReturn + } + return nil +} + +func (m Marshal) CollectIPv4Port(port *uint16) uint16 { + if port == nil { + return m.Data.MCPort + } + return *port +} diff --git a/pageHandler/utils/content-range-value.go b/pageHandler/utils/content-range-value.go new file mode 100644 index 0000000..b0944b8 --- /dev/null +++ b/pageHandler/utils/content-range-value.go @@ -0,0 +1,78 @@ +package utils + +import ( + "strconv" + "strings" +) + +type ContentRangeValue struct { + Start, Length int64 +} + +func (rstrc ContentRangeValue) ToField(maxLength int64) string { + return "bytes " + strconv.FormatInt(rstrc.Start, 10) + "-" + strconv.FormatInt(rstrc.Start+rstrc.Length-1, 10) + "/" + strconv.FormatInt(maxLength, 10) +} + +func GetRanges(rangeStringIn string, maxLength int64) []ContentRangeValue { + actualRangeString := strings.TrimPrefix(rangeStringIn, "bytes=") + if strings.ContainsAny(actualRangeString, ",") { + seperated := strings.Split(actualRangeString, ",") + toReturn := make([]ContentRangeValue, len(seperated)) + pos := 0 + for _, s := range seperated { + if cRange, ok := GetRange(s, maxLength); ok { + toReturn[pos] = cRange + pos += 1 + } + } + if pos == 0 { + return nil + } + return toReturn[:pos] + } + if cRange, ok := GetRange(actualRangeString, maxLength); ok { + return []ContentRangeValue{cRange} + } + return nil +} + +func GetRange(rangePartIn string, maxLength int64) (ContentRangeValue, bool) { + before, after, done := strings.Cut(rangePartIn, "-") + before = strings.Trim(before, " ") + after = strings.Trim(after, " ") + if !done { + return ContentRangeValue{}, false + } + var parsedAfter, parsedBefore int64 = -1, -1 + if after != "" { + if parsed, err := strconv.ParseInt(after, 10, 64); err == nil { + parsedAfter = parsed + } else { + return ContentRangeValue{}, false + } + } + if before != "" { + if parsed, err := strconv.ParseInt(before, 10, 64); err == nil { + parsedBefore = parsed + } else { + return ContentRangeValue{}, false + } + } + if parsedBefore >= 0 && parsedAfter > parsedBefore && parsedAfter < maxLength { + return ContentRangeValue{ + Start: parsedBefore, + Length: parsedAfter - parsedBefore + 1, + }, true + } else if parsedAfter < 0 && parsedBefore >= 0 && parsedBefore < maxLength { + return ContentRangeValue{ + Start: parsedBefore, + Length: maxLength - parsedBefore, + }, true + } else if parsedBefore < 0 && parsedAfter >= 1 && maxLength-parsedAfter >= 0 { + return ContentRangeValue{ + Start: maxLength - parsedAfter, + Length: parsedAfter, + }, true + } + return ContentRangeValue{}, false +} diff --git a/pageHandler/utils/etag.go b/pageHandler/utils/etag.go new file mode 100644 index 0000000..a5b4781 --- /dev/null +++ b/pageHandler/utils/etag.go @@ -0,0 +1,48 @@ +package utils + +import ( + "crypto" + "encoding/hex" + "strings" +) + +func GetValueForETagUsingByteArray(b []byte) string { + theHash := crypto.SHA1.New() + _, _ = theHash.Write(b) + theSum := theHash.Sum(nil) + theHash.Reset() + return "\"" + hex.EncodeToString(theSum) + "\"" +} + +func GetETagValues(stringIn string) []string { + if strings.ContainsAny(stringIn, ",") { + seperated := strings.Split(stringIn, ",") + toReturn := make([]string, len(seperated)) + pos := 0 + for _, s := range seperated { + cETag := GetETagValue(s) + if cETag != "" { + toReturn[pos] = cETag + pos += 1 + } + } + if pos == 0 { + return nil + } + return toReturn[:pos] + } + toReturn := []string{GetETagValue(stringIn)} + if toReturn[0] == "" { + return nil + } + return toReturn +} + +func GetETagValue(stringIn string) string { + startIndex := strings.IndexAny(stringIn, "\"") + 1 + endIndex := strings.LastIndexAny(stringIn, "\"") + if endIndex > startIndex { + return stringIn[startIndex:endIndex] + } + return "" +} diff --git a/pageHandler/utils/partial-range-writer.go b/pageHandler/utils/partial-range-writer.go new file mode 100644 index 0000000..2f82753 --- /dev/null +++ b/pageHandler/utils/partial-range-writer.go @@ -0,0 +1,47 @@ +package utils + +import "io" + +func NewPartialRangeWriter(writerIn io.Writer, httpRangeIn ContentRangeValue) io.Writer { + return &PartialRangeWriter{ + passedWriter: writerIn, + passedWriterIndex: 0, + httpRange: httpRangeIn, + exclusiveLastIndex: httpRangeIn.Start + httpRangeIn.Length, + } +} + +type PartialRangeWriter struct { + passedWriter io.Writer + passedWriterIndex int64 + exclusiveLastIndex int64 + httpRange ContentRangeValue +} + +func (prw *PartialRangeWriter) Write(p []byte) (n int, err error) { + var pOffsetIndex int64 = -1 + if prw.passedWriterIndex >= prw.httpRange.Start && prw.passedWriterIndex < prw.exclusiveLastIndex { + pOffsetIndex = 0 + } else if prw.passedWriterIndex+int64(len(p)) > prw.httpRange.Start && prw.passedWriterIndex < prw.exclusiveLastIndex { + pOffsetIndex = prw.httpRange.Start - prw.passedWriterIndex + prw.passedWriterIndex += pOffsetIndex + } else { + prw.passedWriterIndex += int64(len(p)) + } + if pOffsetIndex >= 0 { + if prw.passedWriterIndex+(int64(len(p))-pOffsetIndex) <= prw.exclusiveLastIndex { + written, err := prw.passedWriter.Write(p[pOffsetIndex:]) + prw.passedWriterIndex += int64(written) + if err != nil { + return written, err + } + } else { + written, err := prw.passedWriter.Write(p[pOffsetIndex : prw.exclusiveLastIndex-prw.passedWriterIndex+pOffsetIndex]) + prw.passedWriterIndex += int64(written) + if err != nil { + return written, err + } + } + } + return n, nil +} diff --git a/pageHandler/utils/process-preconditions.go b/pageHandler/utils/process-preconditions.go new file mode 100644 index 0000000..59221f7 --- /dev/null +++ b/pageHandler/utils/process-preconditions.go @@ -0,0 +1,153 @@ +package utils + +import ( + "golang.captainalm.com/mc-webserver/utils/io" + "mime/multipart" + "net/http" + "net/textproto" + "strconv" + "strings" + "time" +) + +func ProcessSupportedPreconditionsForNext(rw http.ResponseWriter, req *http.Request, modT time.Time, etag string, noBypassModify bool, noBypassMatch bool) bool { + theStrippedETag := GetETagValue(etag) + if noBypassMatch && theStrippedETag != "" && req.Header.Get("If-None-Match") != "" { + etagVals := GetETagValues(req.Header.Get("If-None-Match")) + conditionSuccess := false + for _, s := range etagVals { + if s == theStrippedETag { + conditionSuccess = true + break + } + } + if conditionSuccess { + WriteResponseHeaderCanWriteBody(req.Method, rw, http.StatusNotModified, "") + return false + } + } + + if noBypassMatch && theStrippedETag != "" && req.Header.Get("If-Match") != "" { + etagVals := GetETagValues(req.Header.Get("If-Match")) + conditionFailed := true + for _, s := range etagVals { + if s == theStrippedETag { + conditionFailed = false + break + } + } + if conditionFailed { + SwitchToNonCachingHeaders(rw.Header()) + rw.Header().Del("Content-Type") + rw.Header().Del("Content-Length") + WriteResponseHeaderCanWriteBody(req.Method, rw, http.StatusPreconditionFailed, "") + return false + } + } + + if noBypassModify && !modT.IsZero() && req.Header.Get("If-Modified-Since") != "" { + parse, err := time.Parse(http.TimeFormat, req.Header.Get("If-Modified-Since")) + if err == nil && modT.Before(parse) || strings.EqualFold(modT.Format(http.TimeFormat), req.Header.Get("If-Modified-Since")) { + WriteResponseHeaderCanWriteBody(req.Method, rw, http.StatusNotModified, "") + return false + } + } + + if noBypassModify && !modT.IsZero() && req.Header.Get("If-Unmodified-Since") != "" { + parse, err := time.Parse(http.TimeFormat, req.Header.Get("If-Unmodified-Since")) + if err == nil && modT.After(parse) { + SwitchToNonCachingHeaders(rw.Header()) + rw.Header().Del("Content-Type") + rw.Header().Del("Content-Length") + WriteResponseHeaderCanWriteBody(req.Method, rw, http.StatusPreconditionFailed, "") + return false + } + } + + return true +} + +func ProcessRangePreconditions(maxLength int64, rw http.ResponseWriter, req *http.Request, modT time.Time, etag string, supported bool) []ContentRangeValue { + canDoRange := supported + theStrippedETag := GetETagValue(etag) + modTStr := modT.Format(http.TimeFormat) + + if canDoRange { + rw.Header().Set("Accept-Ranges", "bytes") + } + + if canDoRange && !modT.IsZero() && strings.HasSuffix(req.Header.Get("If-Range"), "GMT") { + newModT, err := time.Parse(http.TimeFormat, modTStr) + parse, err := time.Parse(http.TimeFormat, req.Header.Get("If-Range")) + if err == nil && !newModT.Equal(parse) { + canDoRange = false + } + } else if canDoRange && theStrippedETag != "" && req.Header.Get("If-Range") != "" { + if GetETagValue(req.Header.Get("If-Range")) != theStrippedETag { + canDoRange = false + } + } + + if canDoRange && strings.HasPrefix(req.Header.Get("Range"), "bytes=") { + if theRanges := GetRanges(req.Header.Get("Range"), maxLength); len(theRanges) != 0 { + if len(theRanges) == 1 { + rw.Header().Set("Content-Length", strconv.FormatInt(theRanges[0].Length, 10)) + rw.Header().Set("Content-Range", theRanges[0].ToField(maxLength)) + } else { + theSize := GetMultipartLength(theRanges, rw.Header().Get("Content-Type"), maxLength) + rw.Header().Set("Content-Length", strconv.FormatInt(theSize, 10)) + } + if WriteResponseHeaderCanWriteBody(req.Method, rw, http.StatusPartialContent, "") { + return theRanges + } else { + return nil + } + } else { + SwitchToNonCachingHeaders(rw.Header()) + rw.Header().Del("Content-Type") + rw.Header().Del("Content-Length") + rw.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(maxLength, 10)) + WriteResponseHeaderCanWriteBody(req.Method, rw, http.StatusRequestedRangeNotSatisfiable, "") + return nil + } + } + if WriteResponseHeaderCanWriteBody(req.Method, rw, http.StatusOK, "") { + return make([]ContentRangeValue, 0) + } + return nil +} + +func GetMultipartLength(parts []ContentRangeValue, contentType string, maxLength int64) int64 { + cWriter := &io.CountingWriter{Length: 0} + var returnLength int64 = 0 + multWriter := multipart.NewWriter(cWriter) + for _, currentPart := range parts { + _, _ = multWriter.CreatePart(textproto.MIMEHeader{ + "Content-Range": {currentPart.ToField(maxLength)}, + "Content-Type": {contentType}, + }) + returnLength += currentPart.Length + } + _ = multWriter.Close() + returnLength += cWriter.Length + return returnLength +} + +func WriteResponseHeaderCanWriteBody(method string, rw http.ResponseWriter, statusCode int, message string) bool { + hasBody := method != http.MethodHead && method != http.MethodOptions + if hasBody && message != "" { + rw.Header().Set("Content-Type", "text/plain; charset=utf-8") + rw.Header().Set("X-Content-Type-Options", "nosniff") + rw.Header().Set("Content-Length", strconv.Itoa(len(message)+2)) + SetNeverCacheHeader(rw.Header()) + } + rw.WriteHeader(statusCode) + if hasBody { + if message != "" { + _, _ = rw.Write([]byte(message + "\r\n")) + return false + } + return true + } + return false +} diff --git a/pageHandler/utils/utils.go b/pageHandler/utils/utils.go new file mode 100644 index 0000000..551caa4 --- /dev/null +++ b/pageHandler/utils/utils.go @@ -0,0 +1,45 @@ +package utils + +import ( + "net/http" + "strconv" + "time" +) + +func SetNeverCacheHeader(header http.Header) { + header.Set("Cache-Control", "max-age=0, no-cache, no-store, must-revalidate") + header.Set("Pragma", "no-cache") +} + +func SetLastModifiedHeader(header http.Header, modTime time.Time) { + if !modTime.IsZero() { + header.Set("Last-Modified", modTime.UTC().Format(http.TimeFormat)) + } +} + +func SetCacheHeaderWithAge(header http.Header, maxAge uint, modifiedTime time.Time) { + header.Set("Cache-Control", "max-age="+strconv.Itoa(int(maxAge))+", must-revalidate") + if maxAge > 0 { + checkerSecondsBetween := int64(time.Now().UTC().Sub(modifiedTime.UTC()).Seconds()) + if checkerSecondsBetween < 0 { + checkerSecondsBetween *= -1 + } + header.Set("Age", strconv.FormatUint(uint64(checkerSecondsBetween)%uint64(maxAge), 10)) + } +} + +func SwitchToNonCachingHeaders(header http.Header) { + SetNeverCacheHeader(header) + if header.Get("Last-Modified") != "" { + header.Del("Last-Modified") + } + if header.Get("Age") != "" { + header.Del("Age") + } + if header.Get("Expires") != "" { + header.Del("Expires") + } + if header.Get("ETag") != "" { + header.Del("ETag") + } +} diff --git a/utils/info/conf-info.go b/utils/info/conf-info.go new file mode 100644 index 0000000..53f4ed1 --- /dev/null +++ b/utils/info/conf-info.go @@ -0,0 +1,6 @@ +package info + +import "golang.captainalm.com/mc-webserver/conf" + +var ListenSettings conf.ListenYaml +var ServeSettings conf.ServeYaml diff --git a/utils/info/product-info.go b/utils/info/product-info.go new file mode 100644 index 0000000..fcf8ad4 --- /dev/null +++ b/utils/info/product-info.go @@ -0,0 +1,13 @@ +package info + +var BuildName string +var BuildDescription string +var BuildVersion string +var BuildDate string + +func SetupProductInfo(buildName string, buildDescription string, buildVersion string, buildDate string) { + BuildName = buildName + BuildDescription = buildDescription + BuildVersion = buildVersion + BuildDate = buildDate +} diff --git a/utils/io/buffered-writer.go b/utils/io/buffered-writer.go new file mode 100644 index 0000000..b9f1d9e --- /dev/null +++ b/utils/io/buffered-writer.go @@ -0,0 +1,10 @@ +package io + +type BufferedWriter struct { + Data []byte +} + +func (c *BufferedWriter) Write(p []byte) (n int, err error) { + c.Data = append(c.Data, p...) + return len(p), nil +} diff --git a/utils/io/counting-writer.go b/utils/io/counting-writer.go new file mode 100644 index 0000000..152a5b9 --- /dev/null +++ b/utils/io/counting-writer.go @@ -0,0 +1,10 @@ +package io + +type CountingWriter struct { + Length int64 +} + +func (c *CountingWriter) Write(p []byte) (n int, err error) { + c.Length += int64(len(p)) + return len(p), nil +} diff --git a/utils/yaml/date-type.go b/utils/yaml/date-type.go new file mode 100644 index 0000000..910cd12 --- /dev/null +++ b/utils/yaml/date-type.go @@ -0,0 +1,31 @@ +package yaml + +import ( + "gopkg.in/yaml.v3" + "strings" + "time" +) + +const dateFormat = "02/01/2006" + +type DateType struct { + time.Time +} + +func (dt *DateType) MarshalYAML() (interface{}, error) { + return dt.Time.Format(dateFormat), nil +} + +func (dt *DateType) UnmarshalYAML(value *yaml.Node) error { + var stringIn string + err := value.Decode(&stringIn) + if err != nil { + return nil + } + pt, err := time.Parse(dateFormat, strings.TrimSpace(stringIn)) + if err != nil { + return err + } + dt.Time = pt + return nil +} diff --git a/wappmcstat/main.go b/wappmcstat/main.go new file mode 100644 index 0000000..18c8a40 --- /dev/null +++ b/wappmcstat/main.go @@ -0,0 +1,148 @@ +package main + +import ( + "fmt" + "github.com/joho/godotenv" + "golang.captainalm.com/mc-webserver/conf" + "golang.captainalm.com/mc-webserver/pageHandler" + "golang.captainalm.com/mc-webserver/utils/info" + "gopkg.in/yaml.v3" + "log" + "net" + "net/http" + "os" + "os/signal" + "path" + "path/filepath" + "strings" + "sync" + "syscall" + "time" +) + +var ( + buildName = "" + buildDescription = "Minecraft Status Web APP" + buildVersion = "develop" + buildDate = "" +) + +func main() { + log.Printf("[Main] Starting up %s (%s) #%s (%s)\n", buildDescription, buildName, buildVersion, buildDate) + y := time.Now() + info.SetupProductInfo(buildName, buildDescription, buildVersion, buildDate) + + //Hold main thread till safe shutdown exit: + wg := &sync.WaitGroup{} + wg.Add(1) + + //Get working directory: + cwdDir, err := os.Getwd() + if err != nil { + log.Println(err) + } + + //Load environment file: + err = godotenv.Load() + if err != nil { + log.Fatalln("Error loading .env file") + } + + //Data directory processing: + dataDir := os.Getenv("DIR_DATA") + if dataDir == "" { + dataDir = path.Join(cwdDir, ".data") + } + + check(os.MkdirAll(dataDir, 0777)) + + //Config file processing: + configLocation := os.Getenv("CONFIG_FILE") + if configLocation == "" { + configLocation = path.Join(dataDir, "config.yml") + } else { + if !filepath.IsAbs(configLocation) { + configLocation = path.Join(dataDir, configLocation) + } + } + + //Config loading: + configFile, err := os.Open(configLocation) + if err != nil { + log.Fatalln("Failed to open config.yml") + } + + var configYml conf.ConfigYaml + groupsDecoder := yaml.NewDecoder(configFile) + err = groupsDecoder.Decode(&configYml) + if err != nil { + log.Fatalln("Failed to parse config.yml:", err) + } + err = configFile.Close() + if err != nil { + log.Println("Failed to close config file.") + } + + //Server definitions: + var webServer *http.Server + var fcgiListen net.Listener + info.ListenSettings = configYml.Listen + info.ServeSettings = configYml.Serve + switch strings.ToLower(configYml.Listen.WebMethod) { + case "http": + webServer = &http.Server{Handler: pageHandler.GetRouter(configYml)} + go runBackgroundHttp(webServer, getListener(configYml, cwdDir), false) + case "fcgi": + fcgiListen = getListener(configYml, cwdDir) + if fcgiListen == nil { + log.Fatalln("Listener Nil") + } else { + go runBackgroundFCgi(pageHandler.GetRouter(configYml), fcgiListen) + } + default: + log.Fatalln("Unknown Web Method.") + } + + //===================== + // Safe shutdown + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + //Startup complete: + z := time.Now().Sub(y) + log.Printf("[Main] Took '%s' to fully initialize modules\n", z.String()) + + go func() { + <-sigs + fmt.Printf("\n") + + log.Printf("[Main] Attempting safe shutdown\n") + a := time.Now() + + if webServer != nil { + log.Printf("[Main] Shutting down HTTP server...\n") + err := webServer.Close() + if err != nil { + log.Println(err) + } + } + + if fcgiListen != nil { + log.Printf("[Main] Shutting down FCGI server...\n") + err := fcgiListen.Close() + if err != nil { + log.Println(err) + } + } + + log.Printf("[Main] Signalling program exit...\n") + b := time.Now().Sub(a) + log.Printf("[Main] Took '%s' to fully shutdown modules\n", b.String()) + wg.Done() + }() + // + //===================== + wg.Wait() + log.Println("[Main] Goodbye") + //os.Exit(0) +} diff --git a/wappmcstat/utils.go b/wappmcstat/utils.go new file mode 100644 index 0000000..b5fdd2a --- /dev/null +++ b/wappmcstat/utils.go @@ -0,0 +1,86 @@ +package main + +import ( + "golang.captainalm.com/mc-webserver/conf" + "log" + "net" + "net/http" + "net/http/fcgi" + "os" + "path" + "path/filepath" + "strings" +) + +func check(err error) { + if err != nil { + panic(err) + } +} + +func getListener(config conf.ConfigYaml, cwd string) net.Listener { + split := strings.Split(strings.ToLower(config.Listen.WebNetwork), ":") + if len(split) == 0 { + log.Fatalln("Invalid Web Network") + return nil + } else { + var theListener net.Listener + var theError error + log.Println("[Main] Socket Network Type: " + split[0]) + log.Printf("[Main] Starting up %s server on %s...\n", config.Listen.WebMethod, config.Listen.Web) + switch split[0] { + case "tcp", "tcp4", "tcp6": + theListener, theError = net.Listen(strings.ToLower(config.Listen.WebNetwork), config.Listen.Web) + case "unix", "unixgram", "unixpacket": + socketPath := config.Listen.Web + if !filepath.IsAbs(socketPath) { + if !filepath.IsAbs(cwd) { + log.Fatalln("Web Path Not Absolute And No Working Directory.") + return nil + } + socketPath = path.Join(cwd, socketPath) + } + log.Println("[Main] Removing old socket.") + if err := os.RemoveAll(socketPath); err != nil { + log.Fatalln("Could Not Remove Old Socket.") + return nil + } + theListener, theError = net.Listen(strings.ToLower(config.Listen.WebNetwork), config.Listen.Web) + default: + log.Fatalln("Unknown Web Network.") + return nil + } + if theError != nil { + log.Fatalln("Failed to listen due to:", theError) + return nil + } + return theListener + } +} + +func runBackgroundHttp(s *http.Server, l net.Listener, tlsEnabled bool) { + var err error + if tlsEnabled { + err = s.ServeTLS(l, "", "") + } else { + err = s.Serve(l) + } + if err != nil { + if err == http.ErrServerClosed { + log.Println("The http server shutdown successfully") + } else { + log.Fatalf("[Http] Error trying to host the http server: %s\n", err.Error()) + } + } +} + +func runBackgroundFCgi(h http.Handler, l net.Listener) { + err := fcgi.Serve(l, h) + if err != nil { + if err == net.ErrClosed { + log.Println("The fcgi server shutdown successfully") + } else { + log.Fatalf("[Http] Error trying to host the fcgi server: %s\n", err.Error()) + } + } +}