Files
servtex/backend/backend.go
Maximilian Wagner 2c05415ed1 minor changes
2025-12-27 22:52:04 +01:00

370 lines
10 KiB
Go

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)
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")
}
stdout, err := latexCommand.Output()
execution.Output = stdout
if err == nil {
execution.ExecutionState = globals.LatexExecutionStateSuccess
} else {
execution.ExecutionState = globals.LatexExecutionStateFailure
}
updateExecutionTimestamp(execution)
LogLine("LaTeX execution finished", 2)
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.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
}