package backend import ( "embed" "encoding/json" "errors" "fmt" "io/fs" "net" "net/http" "os" "os/exec" "path/filepath" "slices" "strings" "time" "git.noctra.dev/noctra/servtex/globals" "github.com/fsnotify/fsnotify" ) //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 := Must(time.LoadLocation(globals.AppConfig.Timezone)) return time.Now().In(timezone) } // Returns the current localtime in the RFC3339 format func GetLocalTimeRFC() string { return GetLocalTime().Format(time.RFC3339) } func GetLocalTimeLog() string { return GetLocalTime().Format("2006/01/02 15:04:05") } // Returns the current localtime in custom pretty print format func GetLocalTimePretty() string { return GetLocalTime().Format("02.01.2006 15:04") } // Writes to log file and prints to screen // // Levels: // 1 - Debug // 2 - Info // 3 - Warning // 4 - Error func LogLine(message string, level int) { if level < globals.AppConfig.LogLevelNumeric { return } logline := GetLocalTimeLog() + " ServTeX: " + message + "\n" fmt.Print(logline) globals.LogFile.WriteString(logline) } // 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) { client, err := ProcessClientInfo(request) if err != nil { return errors.New("Request Verification Failed") } if client.Proxied && globals.AppConfig.LogLevelNumeric > 1 { LogLine(fmt.Sprintf( "httpR: %s via %s - %s %s", client.ClientIP, client.Proxy, client.RequestType, client.RequestPath, ), 2, ) } else { LogLine(fmt.Sprintf("httpR: %s - %s %s", client.ClientIP, client.RequestType, client.RequestPath), 2) } return nil } // Writes a configuration file populated with defaults func createConfigWithDefaults(path string) error { config := Must(defaultConfig.ReadFile("default_config.json")) err := os.WriteFile(path, config, 0644) return err } // Helper for configReader() that does the actual reading // // Also validates the populated fields func configReaderParse(filePath string, configOptionStorage *globals.Config) error { jsonData, err := os.ReadFile(filePath) if err == nil { if err = json.Unmarshal(jsonData, &configOptionStorage); err != nil { LogLine("Configuration file is invalid JSON", 4) return errors.New("Config file could not be read") } } else { LogLine("Configuration file does not exist", 1) return errors.New("Config file does not exist") } switch strings.ToLower(configOptionStorage.LogLevel) { case "debug": configOptionStorage.LogLevelNumeric = 1 case "info": configOptionStorage.LogLevelNumeric = 2 case "warning": configOptionStorage.LogLevelNumeric = 3 case "error": configOptionStorage.LogLevelNumeric = 4 default: configOptionStorage.LogLevelNumeric = 3 LogLine("Defaulting to log level warning", 3) } return nil } // populates the appconfig with default values func configPopulator(configOptionStorage *globals.Config) { jsonData := Must(defaultConfig.ReadFile("default_config.json")) json.Unmarshal(jsonData, &configOptionStorage) } // Reads config file and stores the options in configOptionStorage // // Tries the following paths // executable/path/configFileName // %LOCALAPPDATA%\servtex\configFileName // ~/.config/servtex/configFileName func ConfigReader(configFileName string, configOptionStorage *globals.Config) error { var defaultPath string // populate default config configPopulator(configOptionStorage) configRead := false // read config file from disk exePath, err := os.Executable() if err == nil { defaultPath = filepath.Join(filepath.Dir(exePath), configFileName) err = configReaderParse(defaultPath, configOptionStorage) if err == nil { configRead = true } } localappdata := os.Getenv("LOCALAPPDATA") if !configRead && localappdata != "" { path := filepath.Join(localappdata, "servtex", configFileName) err = configReaderParse(path, configOptionStorage) } if !configRead { path := filepath.Join("~", ".config", "servtex", configFileName) err = configReaderParse(path, configOptionStorage) // create default config file for user to edit if err != nil && defaultPath != "" { LogLine(fmt.Sprintf("Configuration file does not exist. Creating with defaults at %s", defaultPath), 4) err = createConfigWithDefaults(defaultPath) if err != nil { LogLine("Configuration file could not be created", 4) } } } return nil } // Contents copied from https://github.com/fsnotify/fsnotify/blob/main/cmd/fsnotify/watch.go func watchLoop(watcher *fsnotify.Watcher, callOnChange func() error) { defer watcher.Close() for { select { 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 { if err != nil { return err } if !d.IsDir() && filepath.Ext(path) == ".tex" { texFiles = append(texFiles, path) } return nil }) if err != nil { LogLine(fmt.Sprintf("Some paths could not be traversed while search for .tex files: %s", err), 3) } return texFiles } // Watches a specified directory and calls a function on change // // Optionally, a minimum time to wait for consecutive changes can be specified func ChangeWatch(path string, callOnChange func() error) { watcher, err := fsnotify.NewWatcher() if err != nil { LogLine("File system watcher could not be initialised, manual compile only", 4) return } for _, file := range texFinder(path) { if err = watcher.Add(file); err != nil { LogLine(fmt.Sprintf("File could not be added to watcher: %s", err), 4) } LogLine(fmt.Sprintf("File added to watcher: %s", file), 1) } go watchLoop(watcher, callOnChange) LogLine("Watcher initialised", 1) } func updateExecutionTimestamp(execution *globals.LatexExecution) { execution.Timestamp = GetLocalTimePretty() 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: Already running", 2) return errors.New("Execution already in progress") } // 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": latexCommand = exec.Command( "lualatex", "-output-directory=" + config.LatexOutputPath, "-jobname=servtex", config.LatexSourceFilePath, ) LogLine(fmt.Sprintf("Command: %s", latexCommand.String()), 1) default: execution.ExecutionState = "Unknown Latex Engine" execution.Output = []byte{} updateExecutionTimestamp(execution) LogLine("LaTeX: Unsupported engine", 4) return errors.New("Unsupported LaTeX Engine") } // execute command stdout, err := latexCommand.Output() execution.Output = stdout if err == nil { execution.ExecutionState = globals.LatexExecutionStateSuccess } else { execution.ExecutionState = globals.LatexExecutionStateFailure } updateExecutionTimestamp(execution) LogLine("LaTeX: Finished", 3) return err } // Checks whether the proxy is trusted. Returns trusted status and the proxy. // // If X-Forwarded-For chain is passed, only the last in chain will be considered func isTrustedProxy(proxy string, trustedProxies []string) (trusted bool, usedProxy string) { // removes len()>0 checks if proxy == "" { return false, "" } // untrusted unless proven otherwise trusted = false // get (last) used proxy (in chain) if slices.Contains(trustedProxies, proxy) { usedProxy = proxy } else { proxies := strings.Split(proxy, ",")[1:] usedProxy = proxies[len(proxies)-1] } // check if proxy is trusted if slices.Contains(trustedProxies, usedProxy) { return true, usedProxy } // resolve trustedProxies in case a DNS name was configured for _, trustedProxyDNS := range trustedProxies { // get IPs of this dns name trustedProxyIPs, err := net.LookupHost(trustedProxyDNS) if err != nil { continue } // set usedProxy to DNS name if proxy is trusted if slices.Contains(trustedProxyIPs, usedProxy) { trusted = true usedProxy = trustedProxyDNS return trusted, usedProxy } } return trusted, usedProxy } // Returns client info of the request. Error indicates untrusted Proxy or unavailable client IP. func ProcessClientInfo(request *http.Request) (client globals.ClientInfo, err error) { client.ClientIP, _, err = net.SplitHostPort(request.RemoteAddr) client.RequestType = request.Method client.RequestPath = request.URL.Path client.Proxy = "" client.Proxied = false client.ProxyTrusted = false trustedProxies := globals.AppConfig.TrustedProxies headerXRP := request.Header.Get("X-Real-IP") if headerXRP != "" { client.Proxied = true client.ProxyTrusted, client.Proxy = isTrustedProxy(headerXRP, trustedProxies) if !client.ProxyTrusted { LogLine(fmt.Sprintf("Request sent via untrusted Proxy %s", client.Proxy), 4) err = errors.New("Untrusted Proxy") } return client, err } headerXFF := request.Header.Get("X-Forwarded-For") if headerXFF != "" { client.Proxied = true client.ProxyTrusted, client.Proxy = isTrustedProxy(headerXRP, trustedProxies) if !client.ProxyTrusted { LogLine(fmt.Sprintf("Request sent via untrusted Proxy %s", client.Proxy), 4) err = errors.New("Untrusted Proxy") } return client, err } return client, err }