From 03c0e3ca7748f4f5189c591b3b574950d3f456d4 Mon Sep 17 00:00:00 2001 From: Maximilian Wagner Date: Sun, 28 Dec 2025 00:31:39 +0100 Subject: [PATCH] implemented rate limiting --- backend/backend.go | 103 +++++++++++++++++++++++------------- backend/default_config.json | 1 + config.json | 3 +- frontend/webserver.go | 5 +- globals/memory.go | 3 ++ 5 files changed, 74 insertions(+), 41 deletions(-) diff --git a/backend/backend.go b/backend/backend.go index 12b5101..80105d9 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -1,6 +1,7 @@ package backend import ( + "embed" "encoding/json" "errors" "fmt" @@ -13,7 +14,6 @@ import ( "slices" "strings" "time" - "embed" "git.noctra.dev/noctra/servtex/globals" "github.com/fsnotify/fsnotify" @@ -22,9 +22,15 @@ import ( //go:embed default_config.json var defaultConfig embed.FS +// There are no errors if I can't see them +func Must[T any](object T, err error) T { + return object +} + + // Returns the current time in the timezone specified in the config file func GetLocalTime() time.Time { - timezone, _ := time.LoadLocation(globals.AppConfig.Timezone) + timezone := Must(time.LoadLocation(globals.AppConfig.Timezone)) return time.Now().In(timezone) } @@ -57,7 +63,7 @@ func LogLine(message string, level int) { globals.LogFile.WriteString(logline) } -// Logs client info. Configured log level sets the verbosity. +// Logs client info. Configured log level sets the verbosity. Logs only up to level INFO // // Bad Request if err != nil func VerifyLogRequest(request *http.Request) (err error) { @@ -66,16 +72,16 @@ func VerifyLogRequest(request *http.Request) (err error) { if client.Proxied && globals.AppConfig.LogLevelNumeric > 1 { LogLine(fmt.Sprintf( - "Request: %s via %s - %s %s", + "httpR: %s via %s - %s %s", client.ClientIP, client.Proxy, client.RequestType, client.RequestPath, ), - 4, + 2, ) } else { - LogLine(fmt.Sprintf("Request: %s - %s %s", client.ClientIP, client.RequestType, client.RequestPath), 4) + LogLine(fmt.Sprintf("httpR: %s - %s %s", client.ClientIP, client.RequestType, client.RequestPath), 2) } return nil @@ -84,7 +90,7 @@ func VerifyLogRequest(request *http.Request) (err error) { // Writes a configuration file populated with defaults func createConfigWithDefaults(path string) error { - config, _ := defaultConfig.ReadFile("default_config.json") + config := Must(defaultConfig.ReadFile("default_config.json")) err := os.WriteFile(path, config, 0644) return err } @@ -122,7 +128,7 @@ func configReaderParse(filePath string, configOptionStorage *globals.Config) err // populates the appconfig with default values func configPopulator(configOptionStorage *globals.Config) { - jsonData, _ := defaultConfig.ReadFile("default_config.json") + jsonData := Must(defaultConfig.ReadFile("default_config.json")) json.Unmarshal(jsonData, &configOptionStorage) } @@ -175,35 +181,34 @@ func watchLoop(watcher *fsnotify.Watcher, callOnChange func() error) { defer watcher.Close() for { select { - // Errors - case err, ok := <-watcher.Errors: - if !ok { - LogLine(fmt.Sprintf("Watcher crashed on error, manual compile only: %s", err), 4) - return - } - LogLine(fmt.Sprintf("Watcher Error: %s", err), 3) - - // Events - case event, ok := <-watcher.Events: - if !ok { - LogLine(fmt.Sprintf("Watcher crashed on event, manual compile only: %s", event), 4) - return - } - LogLine(fmt.Sprintf("File change noticed: %s", event.Name), 2) - - err := callOnChange() - if err != nil { - LogLine(fmt.Sprintf("Latex compilation failed: %s", err), 4) - } - - if err = watcher.Add(event.Name); err != nil { - LogLine(fmt.Sprintf("File could not be re-added to watcher: %s", event.Name), 4) - } + case err, ok := <-watcher.Errors: + if !ok { + LogLine(fmt.Sprintf("Watcher crashed on error, manual compile only: %s", err), 4) + return } + LogLine(fmt.Sprintf("Watcher Error: %s", err), 3) + + case event, ok := <-watcher.Events: + if !ok { + LogLine(fmt.Sprintf("Watcher crashed on event, manual compile only: %s", event), 4) + return + } + LogLine(fmt.Sprintf("File change noticed: %s", event.Name), 2) + + err := callOnChange() + if err != nil { + LogLine(fmt.Sprintf("Latex compilation failed: %s", err), 4) + } + + if err = watcher.Add(event.Name); err != nil { + LogLine(fmt.Sprintf("File could not be re-added to watcher: %s", event.Name), 4) + } + } } } +// Returns a list of all .tex files recursively found in the passed directory func texFinder(path string) []string { var texFiles []string err := filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { @@ -216,7 +221,7 @@ func texFinder(path string) []string { return nil }) if err != nil { - LogLine("Some paths could not be traversed while search for .tex files", 3) + LogLine(fmt.Sprintf("Some paths could not be traversed while search for .tex files: %s", err), 3) } return texFiles } @@ -249,19 +254,40 @@ func updateExecutionTimestamp(execution *globals.LatexExecution) { execution.TimestampRFC = GetLocalTimeRFC() } +// Returns the necessary time to sleep for rate limiting. +// +// Requires an RFC3339 timestamp. A dog will die if anything else is passed. +func rateLimitTime(timestampRFC string) (sleepTime time.Duration) { + lastExeTime := Must(time.Parse(time.RFC3339, timestampRFC)) + rateLimitInterval := Must(time.ParseDuration(fmt.Sprintf("%ds", globals.AppConfig.RateLimitSeconds))) + return time.Until(lastExeTime.Add(rateLimitInterval)).Round(time.Second) +} + // Intended to be run as goroutine func LatexCompile() error { execution := &globals.LatexExec config := globals.AppConfig + + // check for already running compilation if execution.ExecutionLock.TryLock() { defer execution.ExecutionLock.Unlock() } else { - LogLine("LaTeX execution already underway", 2) + LogLine("LaTeX: Already running", 2) return errors.New("Execution already in progress") } - LogLine("LaTeX execution started", 2) + // rate limit or start right away + rateLimit := rateLimitTime(execution.TimestampRFC) + if rateLimit.Seconds() > 0 { + LogLine(fmt.Sprintf("LaTeX: Rate limit: %d seconds", int(rateLimit.Seconds())), 3) + execution.ExecutionState = globals.LatexExecutionStateLimit + time.Sleep(rateLimit) + } else { + LogLine("LaTeX: Started", 3) + execution.ExecutionState = globals.LatexExecutionStateRunning + } + // build command var latexCommand *exec.Cmd switch config.LatexEngine { case "lualatex": @@ -277,10 +303,11 @@ func LatexCompile() error { execution.Output = []byte{} updateExecutionTimestamp(execution) - LogLine("Unsupported LaTeX Engine", 4) + LogLine("LaTeX: Unsupported engine", 4) return errors.New("Unsupported LaTeX Engine") } + // execute command stdout, err := latexCommand.Output() execution.Output = stdout if err == nil { @@ -290,7 +317,7 @@ func LatexCompile() error { } updateExecutionTimestamp(execution) - LogLine("LaTeX execution finished", 2) + LogLine("LaTeX: Finished", 3) return err } @@ -317,7 +344,7 @@ func isTrustedProxy(proxy string, trustedProxies []string) (trusted bool, usedPr // resolve trustedProxies in case a DNS name was configured for _, trustedProxyDNS := range trustedProxies { // get IPs of this dns name - trustedProxyIPs, err := net.LookupAddr(trustedProxyDNS) + trustedProxyIPs, err := net.LookupHost(trustedProxyDNS) if err != nil { continue } // set usedProxy to DNS name if proxy is trusted diff --git a/backend/default_config.json b/backend/default_config.json index 3a62b66..3ed3ff6 100644 --- a/backend/default_config.json +++ b/backend/default_config.json @@ -6,6 +6,7 @@ "latexEngine": "lualatex", "latexSourceFilePath": "./latex/Example.tex", "latexOutputPath": "./latex/output", + "rateLimitSeconds": 20, "webserverDomain": "localhost", "webserverPort": "8080", diff --git a/config.json b/config.json index 34751dd..e12db74 100644 --- a/config.json +++ b/config.json @@ -1,11 +1,12 @@ { "timezone": "Europe/Berlin", "logFilePath": "./servtex.log", - "logLevel": "debug", + "logLevel": "info", "latexEngine": "lualatex", "latexSourceFilePath": "./testfiles/Example.tex", "latexOutputPath": "./testfiles/output", + "rateLimitSeconds": 0, "webserverDomain": "localhost", "webserverPort": "8080", diff --git a/frontend/webserver.go b/frontend/webserver.go index 3b2201a..9550a7c 100644 --- a/frontend/webserver.go +++ b/frontend/webserver.go @@ -95,8 +95,9 @@ func SSEventHandler(writer http.ResponseWriter, request *http.Request) { ssePing(&writer) } else { sseStatusSend(&writer) - sseOutputSend(&writer) - // let client keep current pdf, if compile failed + if globals.LatexExec.ExecutionState != globals.LatexExecutionStateRunning { + sseOutputSend(&writer) + } if globals.LatexExec.ExecutionState != globals.LatexExecutionStateFailure { ssePDFSend(&writer) } diff --git a/globals/memory.go b/globals/memory.go index 95b340f..836e93d 100644 --- a/globals/memory.go +++ b/globals/memory.go @@ -18,6 +18,7 @@ type Config struct { LatexEngine string `json:"latexEngine"` LatexSourceFilePath string `json:"latexSourceFilePath"` LatexOutputPath string `json:"latexOutputPath"` + RateLimitSeconds int `json:"rateLimitSeconds"` // webserver WebserverDomain string `json:"webserverDomain"` @@ -40,6 +41,8 @@ type LatexExecution struct { var LatexExec LatexExecution var LatexExecutionStateSuccess = "Success" var LatexExecutionStateFailure = "Failure" +var LatexExecutionStateRunning = "Running" +var LatexExecutionStateLimit = "Running (rate limited)" // Creates a copy of a LatexExecution (without the Mutex) func (execution *LatexExecution) Copy() (exeCopy *LatexExecution) {