package backend import ( "encoding/json" "errors" "fmt" "io/fs" "net" "net/http" "os" "os/exec" "path/filepath" "slices" "strings" "time" "embed" "git.noctra.dev/noctra/servtex/globals" "github.com/fsnotify/fsnotify" ) //go:embed default_config.json var defaultConfig embed.FS // Returns the current time in the timezone specified in the config file func GetLocalTime() time.Time { timezone, _ := 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. // // 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( "Request: %s via %s - %s %s", client.ClientIP, client.Proxy, client.RequestType, client.RequestPath, ), 4, ) } else { LogLine(fmt.Sprintf("Request: %s - %s %s", client.ClientIP, client.RequestType, client.RequestPath), 4) } return nil } // Writes a configuration file populated with defaults func createConfigWithDefaults(path string) error { config, _ := 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, _ := 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 { // 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) } } } } 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("Some paths could not be traversed while search for .tex files", 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() } // Intended to be run as goroutine func LatexCompile() error { execution := &globals.LatexExec config := globals.AppConfig if execution.ExecutionLock.TryLock() { defer execution.ExecutionLock.Unlock() } else { LogLine("LaTeX execution already underway", 2) return errors.New("Execution already in progress") } LogLine("LaTeX execution started", 2) execution.ExecutionState = "Started" 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("Unsupported LaTeX Engine", 4) return errors.New("Unsupported LaTeX Engine") } execution.ExecutionState = "Running" stdout, err := latexCommand.Output() if err != nil { execution.ExecutionState = "Failed" return err } execution.Output = stdout execution.ExecutionState = "Done" updateExecutionTimestamp(execution) LogLine("LaTeX execution finished", 2) return nil } // 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.LookupAddr(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 }