It looks like support for hsl color values within the manifest is limited so we have to convert the colors to hex
493 lines
14 KiB
Go
493 lines
14 KiB
Go
package glance
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT = 20
|
|
|
|
const (
|
|
configVarTypeEnv = "env"
|
|
configVarTypeSecret = "secret"
|
|
configVarTypeFileFromEnv = "readFileFromEnv"
|
|
)
|
|
|
|
type config struct {
|
|
Server struct {
|
|
Host string `yaml:"host"`
|
|
Port uint16 `yaml:"port"`
|
|
AssetsPath string `yaml:"assets-path"`
|
|
BaseURL string `yaml:"base-url"`
|
|
} `yaml:"server"`
|
|
|
|
Document struct {
|
|
Head template.HTML `yaml:"head"`
|
|
} `yaml:"document"`
|
|
|
|
Theme struct {
|
|
BackgroundColor *hslColorField `yaml:"background-color"`
|
|
BackgroundColorAsHex string `yaml:"-"`
|
|
PrimaryColor *hslColorField `yaml:"primary-color"`
|
|
PositiveColor *hslColorField `yaml:"positive-color"`
|
|
NegativeColor *hslColorField `yaml:"negative-color"`
|
|
Light bool `yaml:"light"`
|
|
ContrastMultiplier float32 `yaml:"contrast-multiplier"`
|
|
TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"`
|
|
CustomCSSFile string `yaml:"custom-css-file"`
|
|
} `yaml:"theme"`
|
|
|
|
Branding struct {
|
|
HideFooter bool `yaml:"hide-footer"`
|
|
CustomFooter template.HTML `yaml:"custom-footer"`
|
|
LogoText string `yaml:"logo-text"`
|
|
LogoURL string `yaml:"logo-url"`
|
|
FaviconURL string `yaml:"favicon-url"`
|
|
AppName string `yaml:"app-name"`
|
|
AppIconURL string `yaml:"app-icon-url"`
|
|
AppBackgroundColor string `yaml:"app-background-color"`
|
|
} `yaml:"branding"`
|
|
|
|
Pages []page `yaml:"pages"`
|
|
}
|
|
|
|
type page struct {
|
|
Title string `yaml:"name"`
|
|
Slug string `yaml:"slug"`
|
|
Width string `yaml:"width"`
|
|
DesktopNavigationWidth string `yaml:"desktop-navigation-width"`
|
|
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
|
ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"`
|
|
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
|
CenterVertically bool `yaml:"center-vertically"`
|
|
Columns []struct {
|
|
Size string `yaml:"size"`
|
|
Widgets widgets `yaml:"widgets"`
|
|
} `yaml:"columns"`
|
|
PrimaryColumnIndex int8 `yaml:"-"`
|
|
mu sync.Mutex `yaml:"-"`
|
|
}
|
|
|
|
func newConfigFromYAML(contents []byte) (*config, error) {
|
|
contents, err := parseConfigVariables(contents)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
config := &config{}
|
|
config.Server.Port = 8080
|
|
|
|
err = yaml.Unmarshal(contents, config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err = isConfigStateValid(config); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for p := range config.Pages {
|
|
for c := range config.Pages[p].Columns {
|
|
for w := range config.Pages[p].Columns[c].Widgets {
|
|
if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
|
|
return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
var envVariableNamePattern = regexp.MustCompile(`^[A-Z0-9_]+$`)
|
|
var configVariablePattern = regexp.MustCompile(`(^|.)\$\{(?:([a-zA-Z]+):)?([a-zA-Z0-9_-]+)\}`)
|
|
|
|
// Parses variables defined in the config such as:
|
|
// ${API_KEY} - gets replaced with the value of the API_KEY environment variable
|
|
// \${API_KEY} - escaped, gets used as is without the \ in the config
|
|
// ${secret:api_key} - value gets loaded from /run/secrets/api_key
|
|
// ${readFileFromEnv:PATH_TO_SECRET} - value gets loaded from the file path specified in the environment variable PATH_TO_SECRET
|
|
//
|
|
// TODO: don't match against commented out sections, not sure exactly how since
|
|
// variables can be placed anywhere and used to modify the YAML structure itself
|
|
func parseConfigVariables(contents []byte) ([]byte, error) {
|
|
var err error
|
|
|
|
replaced := configVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
groups := configVariablePattern.FindSubmatch(match)
|
|
if len(groups) != 4 {
|
|
// we can't handle this match, this shouldn't happen unless the number of groups
|
|
// in the regex has been changed without updating the below code
|
|
return match
|
|
}
|
|
|
|
prefix := string(groups[1])
|
|
if prefix == `\` {
|
|
if len(match) >= 2 {
|
|
return match[1:]
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
typeAsString, variableName := string(groups[2]), string(groups[3])
|
|
variableType := ternary(typeAsString == "", configVarTypeEnv, typeAsString)
|
|
|
|
parsedValue, returnOriginal, localErr := parseConfigVariableOfType(variableType, variableName)
|
|
if localErr != nil {
|
|
err = fmt.Errorf("parsing variable: %v", localErr)
|
|
return nil
|
|
}
|
|
|
|
if returnOriginal {
|
|
return match
|
|
}
|
|
|
|
return []byte(prefix + parsedValue)
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return replaced, nil
|
|
}
|
|
|
|
// When the bool return value is true, it indicates that the caller should use the original value
|
|
func parseConfigVariableOfType(variableType, variableName string) (string, bool, error) {
|
|
switch variableType {
|
|
case configVarTypeEnv:
|
|
if !envVariableNamePattern.MatchString(variableName) {
|
|
return "", true, nil
|
|
}
|
|
|
|
v, found := os.LookupEnv(variableName)
|
|
if !found {
|
|
return "", false, fmt.Errorf("environment variable %s not found", variableName)
|
|
}
|
|
|
|
return v, false, nil
|
|
case configVarTypeSecret:
|
|
secretPath := filepath.Join("/run/secrets", variableName)
|
|
secret, err := os.ReadFile(secretPath)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("reading secret file: %v", err)
|
|
}
|
|
|
|
return strings.TrimSpace(string(secret)), false, nil
|
|
case configVarTypeFileFromEnv:
|
|
if !envVariableNamePattern.MatchString(variableName) {
|
|
return "", true, nil
|
|
}
|
|
|
|
filePath, found := os.LookupEnv(variableName)
|
|
if !found {
|
|
return "", false, fmt.Errorf("readFileFromEnv: environment variable %s not found", variableName)
|
|
}
|
|
|
|
if !filepath.IsAbs(filePath) {
|
|
return "", false, fmt.Errorf("readFileFromEnv: file path %s is not absolute", filePath)
|
|
}
|
|
|
|
fileContents, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("readFileFromEnv: reading file from %s: %v", variableName, err)
|
|
}
|
|
|
|
return strings.TrimSpace(string(fileContents)), false, nil
|
|
default:
|
|
return "", true, nil
|
|
}
|
|
}
|
|
|
|
func formatWidgetInitError(err error, w widget) error {
|
|
return fmt.Errorf("%s widget: %v", w.GetType(), err)
|
|
}
|
|
|
|
var configIncludePattern = regexp.MustCompile(`(?m)^([ \t]*)(?:-[ \t]*)?(?:!|\$)include:[ \t]*(.+)$`)
|
|
|
|
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
|
|
return recursiveParseYAMLIncludes(mainFilePath, nil, 0)
|
|
}
|
|
|
|
func recursiveParseYAMLIncludes(mainFilePath string, includes map[string]struct{}, depth int) ([]byte, map[string]struct{}, error) {
|
|
if depth > CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT {
|
|
return nil, nil, fmt.Errorf("recursion depth limit of %d reached", CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT)
|
|
}
|
|
|
|
mainFileContents, err := os.ReadFile(mainFilePath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("reading %s: %w", mainFilePath, err)
|
|
}
|
|
|
|
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("getting absolute path of %s: %w", mainFilePath, err)
|
|
}
|
|
mainFileDir := filepath.Dir(mainFileAbsPath)
|
|
|
|
if includes == nil {
|
|
includes = make(map[string]struct{})
|
|
}
|
|
var includesLastErr error
|
|
|
|
mainFileContents = configIncludePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
|
if includesLastErr != nil {
|
|
return nil
|
|
}
|
|
|
|
matches := configIncludePattern.FindSubmatch(match)
|
|
if len(matches) != 3 {
|
|
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
|
|
return nil
|
|
}
|
|
|
|
indent := string(matches[1])
|
|
includeFilePath := strings.TrimSpace(string(matches[2]))
|
|
if !filepath.IsAbs(includeFilePath) {
|
|
includeFilePath = filepath.Join(mainFileDir, includeFilePath)
|
|
}
|
|
|
|
var fileContents []byte
|
|
var err error
|
|
|
|
includes[includeFilePath] = struct{}{}
|
|
|
|
fileContents, includes, err = recursiveParseYAMLIncludes(includeFilePath, includes, depth+1)
|
|
if err != nil {
|
|
includesLastErr = err
|
|
return nil
|
|
}
|
|
|
|
return []byte(prefixStringLines(indent, string(fileContents)))
|
|
})
|
|
|
|
if includesLastErr != nil {
|
|
return nil, nil, includesLastErr
|
|
}
|
|
|
|
return mainFileContents, includes, nil
|
|
}
|
|
|
|
func configFilesWatcher(
|
|
mainFilePath string,
|
|
lastContents []byte,
|
|
lastIncludes map[string]struct{},
|
|
onChange func(newContents []byte),
|
|
onErr func(error),
|
|
) (func() error, error) {
|
|
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting absolute path of main file: %w", err)
|
|
}
|
|
|
|
// TODO: refactor, flaky
|
|
lastIncludes[mainFileAbsPath] = struct{}{}
|
|
|
|
watcher, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating watcher: %w", err)
|
|
}
|
|
|
|
updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) {
|
|
for filePath := range previousWatched {
|
|
if _, ok := newWatched[filePath]; !ok {
|
|
watcher.Remove(filePath)
|
|
}
|
|
}
|
|
|
|
for filePath := range newWatched {
|
|
if _, ok := previousWatched[filePath]; !ok {
|
|
if err := watcher.Add(filePath); err != nil {
|
|
log.Printf(
|
|
"Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v",
|
|
filePath, err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
updateWatchedFiles(nil, lastIncludes)
|
|
|
|
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
|
|
mu := sync.Mutex{}
|
|
|
|
parseAndCompareBeforeCallback := func() {
|
|
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
|
|
if err != nil {
|
|
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
|
|
return
|
|
}
|
|
|
|
// TODO: refactor, flaky
|
|
currentIncludes[mainFileAbsPath] = struct{}{}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if !maps.Equal(currentIncludes, lastIncludes) {
|
|
updateWatchedFiles(lastIncludes, currentIncludes)
|
|
lastIncludes = currentIncludes
|
|
}
|
|
|
|
if !bytes.Equal(lastContents, currentContents) {
|
|
lastContents = currentContents
|
|
onChange(currentContents)
|
|
}
|
|
}
|
|
|
|
const debounceDuration = 500 * time.Millisecond
|
|
var debounceTimer *time.Timer
|
|
debouncedParseAndCompareBeforeCallback := func() {
|
|
if debounceTimer != nil {
|
|
debounceTimer.Stop()
|
|
debounceTimer.Reset(debounceDuration)
|
|
} else {
|
|
debounceTimer = time.AfterFunc(debounceDuration, parseAndCompareBeforeCallback)
|
|
}
|
|
}
|
|
|
|
deleteLastInclude := func(filePath string) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
fileAbsPath, _ := filepath.Abs(filePath)
|
|
delete(lastIncludes, fileAbsPath)
|
|
}
|
|
|
|
go func() {
|
|
for {
|
|
select {
|
|
case event, isOpen := <-watcher.Events:
|
|
if !isOpen {
|
|
return
|
|
}
|
|
if event.Has(fsnotify.Write) {
|
|
debouncedParseAndCompareBeforeCallback()
|
|
} else if event.Has(fsnotify.Rename) {
|
|
// on linux the file will no longer be watched after a rename, on windows
|
|
// it will continue to be watched with the new name but we have no access to
|
|
// the new name in this event in order to stop watching it manually and match the
|
|
// behavior in linux, may lead to weird unintended behaviors on windows as we're
|
|
// only handling renames from linux's perspective
|
|
// see https://github.com/fsnotify/fsnotify/issues/255
|
|
|
|
// remove the old file from our manually tracked includes, calling
|
|
// debouncedParseAndCompareBeforeCallback will re-add it if it's still
|
|
// required after it triggers
|
|
deleteLastInclude(event.Name)
|
|
|
|
// wait for file to maybe get created again
|
|
// see https://github.com/glanceapp/glance/pull/358
|
|
for range 10 {
|
|
if _, err := os.Stat(event.Name); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
|
|
debouncedParseAndCompareBeforeCallback()
|
|
} else if event.Has(fsnotify.Remove) {
|
|
deleteLastInclude(event.Name)
|
|
debouncedParseAndCompareBeforeCallback()
|
|
}
|
|
case err, isOpen := <-watcher.Errors:
|
|
if !isOpen {
|
|
return
|
|
}
|
|
onErr(fmt.Errorf("watcher error: %w", err))
|
|
}
|
|
}
|
|
}()
|
|
|
|
onChange(lastContents)
|
|
|
|
return func() error {
|
|
if debounceTimer != nil {
|
|
debounceTimer.Stop()
|
|
}
|
|
|
|
return watcher.Close()
|
|
}, nil
|
|
}
|
|
|
|
func isConfigStateValid(config *config) error {
|
|
if len(config.Pages) == 0 {
|
|
return fmt.Errorf("no pages configured")
|
|
}
|
|
|
|
if config.Server.AssetsPath != "" {
|
|
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
|
|
}
|
|
}
|
|
|
|
for i := range config.Pages {
|
|
page := &config.Pages[i]
|
|
|
|
if page.Title == "" {
|
|
return fmt.Errorf("page %d has no name", i+1)
|
|
}
|
|
|
|
if page.Width != "" && (page.Width != "wide" && page.Width != "slim" && page.Width != "default") {
|
|
return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
|
|
}
|
|
|
|
if page.DesktopNavigationWidth != "" {
|
|
if page.DesktopNavigationWidth != "wide" && page.DesktopNavigationWidth != "slim" && page.DesktopNavigationWidth != "default" {
|
|
return fmt.Errorf("page %d: desktop-navigation-width can only be either wide or slim", i+1)
|
|
}
|
|
}
|
|
|
|
if len(page.Columns) == 0 {
|
|
return fmt.Errorf("page %d has no columns", i+1)
|
|
}
|
|
|
|
if page.Width == "slim" {
|
|
if len(page.Columns) > 2 {
|
|
return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
|
|
}
|
|
} else {
|
|
if len(page.Columns) > 3 {
|
|
return fmt.Errorf("page %d has more than 3 columns", i+1)
|
|
}
|
|
}
|
|
|
|
columnSizesCount := make(map[string]int)
|
|
|
|
for j := range page.Columns {
|
|
column := &page.Columns[j]
|
|
|
|
if column.Size != "small" && column.Size != "full" {
|
|
return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
|
|
}
|
|
|
|
columnSizesCount[page.Columns[j].Size]++
|
|
}
|
|
|
|
full := columnSizesCount["full"]
|
|
|
|
if full > 2 || full == 0 {
|
|
return fmt.Errorf("page %d must have either 1 or 2 full width columns", i+1)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|