370 lines
10 KiB
Go
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
|
|
}
|
|
|