|
@@ -5,80 +5,96 @@ import (
|
|
"context"
|
|
"context"
|
|
"fmt"
|
|
"fmt"
|
|
"html/template"
|
|
"html/template"
|
|
- "log/slog"
|
|
|
|
|
|
+ "log"
|
|
"net/http"
|
|
"net/http"
|
|
"path/filepath"
|
|
"path/filepath"
|
|
- "regexp"
|
|
|
|
"strconv"
|
|
"strconv"
|
|
"strings"
|
|
"strings"
|
|
"sync"
|
|
"sync"
|
|
"time"
|
|
"time"
|
|
-
|
|
|
|
- "github.com/glanceapp/glance/internal/assets"
|
|
|
|
- "github.com/glanceapp/glance/internal/widget"
|
|
|
|
)
|
|
)
|
|
|
|
|
|
-var buildVersion = "dev"
|
|
|
|
|
|
+var (
|
|
|
|
+ pageTemplate = mustParseTemplate("page.html", "document.html")
|
|
|
|
+ pageContentTemplate = mustParseTemplate("page-content.html")
|
|
|
|
+ pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
|
|
|
|
+)
|
|
|
|
|
|
-var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
|
|
|
|
|
+type application struct {
|
|
|
|
+ Version string
|
|
|
|
+ Config config
|
|
|
|
+ ParsedThemeStyle template.HTML
|
|
|
|
|
|
-type Application struct {
|
|
|
|
- Version string
|
|
|
|
- Config Config
|
|
|
|
- slugToPage map[string]*Page
|
|
|
|
- widgetByID map[uint64]widget.Widget
|
|
|
|
|
|
+ slugToPage map[string]*page
|
|
|
|
+ widgetByID map[uint64]widget
|
|
}
|
|
}
|
|
|
|
|
|
-type Theme struct {
|
|
|
|
- BackgroundColor *widget.HSLColorField `yaml:"background-color"`
|
|
|
|
- PrimaryColor *widget.HSLColorField `yaml:"primary-color"`
|
|
|
|
- PositiveColor *widget.HSLColorField `yaml:"positive-color"`
|
|
|
|
- NegativeColor *widget.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"`
|
|
|
|
-}
|
|
|
|
|
|
+func newApplication(config *config) (*application, error) {
|
|
|
|
+ app := &application{
|
|
|
|
+ Version: buildVersion,
|
|
|
|
+ Config: *config,
|
|
|
|
+ slugToPage: make(map[string]*page),
|
|
|
|
+ widgetByID: make(map[uint64]widget),
|
|
|
|
+ }
|
|
|
|
|
|
-type Server struct {
|
|
|
|
- Host string `yaml:"host"`
|
|
|
|
- Port uint16 `yaml:"port"`
|
|
|
|
- AssetsPath string `yaml:"assets-path"`
|
|
|
|
- BaseURL string `yaml:"base-url"`
|
|
|
|
- AssetsHash string `yaml:"-"`
|
|
|
|
- StartedAt time.Time `yaml:"-"` // used in custom css file
|
|
|
|
-}
|
|
|
|
|
|
+ app.slugToPage[""] = &config.Pages[0]
|
|
|
|
|
|
-type 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"`
|
|
|
|
-}
|
|
|
|
|
|
+ providers := &widgetProviders{
|
|
|
|
+ assetResolver: app.AssetPath,
|
|
|
|
+ }
|
|
|
|
|
|
-type Column struct {
|
|
|
|
- Size string `yaml:"size"`
|
|
|
|
- Widgets widget.Widgets `yaml:"widgets"`
|
|
|
|
-}
|
|
|
|
|
|
+ var err error
|
|
|
|
+ app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme)
|
|
|
|
+ if err != nil {
|
|
|
|
+ return nil, fmt.Errorf("parsing theme style: %v", err)
|
|
|
|
+ }
|
|
|
|
|
|
-type templateData struct {
|
|
|
|
- App *Application
|
|
|
|
- Page *Page
|
|
|
|
-}
|
|
|
|
|
|
+ for p := range config.Pages {
|
|
|
|
+ page := &config.Pages[p]
|
|
|
|
+ page.PrimaryColumnIndex = -1
|
|
|
|
+
|
|
|
|
+ if page.Slug == "" {
|
|
|
|
+ page.Slug = titleToSlug(page.Title)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ app.slugToPage[page.Slug] = page
|
|
|
|
+
|
|
|
|
+ for c := range page.Columns {
|
|
|
|
+ column := &page.Columns[c]
|
|
|
|
+
|
|
|
|
+ if page.PrimaryColumnIndex == -1 && column.Size == "full" {
|
|
|
|
+ page.PrimaryColumnIndex = int8(c)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ for w := range column.Widgets {
|
|
|
|
+ widget := column.Widgets[w]
|
|
|
|
+ app.widgetByID[widget.id()] = widget
|
|
|
|
|
|
-type Page struct {
|
|
|
|
- Title string `yaml:"name"`
|
|
|
|
- Slug string `yaml:"slug"`
|
|
|
|
- Width string `yaml:"width"`
|
|
|
|
- ShowMobileHeader bool `yaml:"show-mobile-header"`
|
|
|
|
- HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
|
|
|
- CenterVertically bool `yaml:"center-vertically"`
|
|
|
|
- Columns []Column `yaml:"columns"`
|
|
|
|
- mu sync.Mutex
|
|
|
|
|
|
+ widget.setProviders(providers)
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ config = &app.Config
|
|
|
|
+
|
|
|
|
+ config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
|
|
|
+ config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
|
|
|
+
|
|
|
|
+ if config.Branding.FaviconURL == "" {
|
|
|
|
+ config.Branding.FaviconURL = app.AssetPath("favicon.png")
|
|
|
|
+ } else {
|
|
|
|
+ config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL)
|
|
|
|
+
|
|
|
|
+ return app, nil
|
|
}
|
|
}
|
|
|
|
|
|
-func (p *Page) UpdateOutdatedWidgets() {
|
|
|
|
|
|
+func (p *page) updateOutdatedWidgets() {
|
|
|
|
+ p.mu.Lock()
|
|
|
|
+ defer p.mu.Unlock()
|
|
|
|
+
|
|
now := time.Now()
|
|
now := time.Now()
|
|
|
|
|
|
var wg sync.WaitGroup
|
|
var wg sync.WaitGroup
|
|
@@ -88,14 +104,14 @@ func (p *Page) UpdateOutdatedWidgets() {
|
|
for w := range p.Columns[c].Widgets {
|
|
for w := range p.Columns[c].Widgets {
|
|
widget := p.Columns[c].Widgets[w]
|
|
widget := p.Columns[c].Widgets[w]
|
|
|
|
|
|
- if !widget.RequiresUpdate(&now) {
|
|
|
|
|
|
+ if !widget.requiresUpdate(&now) {
|
|
continue
|
|
continue
|
|
}
|
|
}
|
|
|
|
|
|
wg.Add(1)
|
|
wg.Add(1)
|
|
go func() {
|
|
go func() {
|
|
defer wg.Done()
|
|
defer wg.Done()
|
|
- widget.Update(context)
|
|
|
|
|
|
+ widget.update(context)
|
|
}()
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
@@ -103,16 +119,7 @@ func (p *Page) UpdateOutdatedWidgets() {
|
|
wg.Wait()
|
|
wg.Wait()
|
|
}
|
|
}
|
|
|
|
|
|
-// TODO: fix, currently very simple, lots of uncovered edge cases
|
|
|
|
-func titleToSlug(s string) string {
|
|
|
|
- s = strings.ToLower(s)
|
|
|
|
- s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
|
|
|
|
- s = strings.Trim(s, "-")
|
|
|
|
-
|
|
|
|
- return s
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-func (a *Application) TransformUserDefinedAssetPath(path string) string {
|
|
|
|
|
|
+func (a *application) transformUserDefinedAssetPath(path string) string {
|
|
if strings.HasPrefix(path, "/assets/") {
|
|
if strings.HasPrefix(path, "/assets/") {
|
|
return a.Config.Server.BaseURL + path
|
|
return a.Config.Server.BaseURL + path
|
|
}
|
|
}
|
|
@@ -120,74 +127,26 @@ func (a *Application) TransformUserDefinedAssetPath(path string) string {
|
|
return path
|
|
return path
|
|
}
|
|
}
|
|
|
|
|
|
-func NewApplication(config *Config) (*Application, error) {
|
|
|
|
- if len(config.Pages) == 0 {
|
|
|
|
- return nil, fmt.Errorf("no pages configured")
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- app := &Application{
|
|
|
|
- Version: buildVersion,
|
|
|
|
- Config: *config,
|
|
|
|
- slugToPage: make(map[string]*Page),
|
|
|
|
- widgetByID: make(map[uint64]widget.Widget),
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- app.Config.Server.AssetsHash = assets.PublicFSHash
|
|
|
|
- app.slugToPage[""] = &config.Pages[0]
|
|
|
|
-
|
|
|
|
- providers := &widget.Providers{
|
|
|
|
- AssetResolver: app.AssetPath,
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- for p := range config.Pages {
|
|
|
|
- if config.Pages[p].Slug == "" {
|
|
|
|
- config.Pages[p].Slug = titleToSlug(config.Pages[p].Title)
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- app.slugToPage[config.Pages[p].Slug] = &config.Pages[p]
|
|
|
|
-
|
|
|
|
- for c := range config.Pages[p].Columns {
|
|
|
|
- for w := range config.Pages[p].Columns[c].Widgets {
|
|
|
|
- widget := config.Pages[p].Columns[c].Widgets[w]
|
|
|
|
- app.widgetByID[widget.GetID()] = widget
|
|
|
|
-
|
|
|
|
- widget.SetProviders(providers)
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- config = &app.Config
|
|
|
|
-
|
|
|
|
- config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
|
|
|
- config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile)
|
|
|
|
-
|
|
|
|
- if config.Branding.FaviconURL == "" {
|
|
|
|
- config.Branding.FaviconURL = app.AssetPath("favicon.png")
|
|
|
|
- } else {
|
|
|
|
- config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL)
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
|
|
|
|
-
|
|
|
|
- return app, nil
|
|
|
|
|
|
+type pageTemplateData struct {
|
|
|
|
+ App *application
|
|
|
|
+ Page *page
|
|
}
|
|
}
|
|
|
|
|
|
-func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
+func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) {
|
|
page, exists := a.slugToPage[r.PathValue("page")]
|
|
page, exists := a.slugToPage[r.PathValue("page")]
|
|
|
|
|
|
if !exists {
|
|
if !exists {
|
|
- a.HandleNotFound(w, r)
|
|
|
|
|
|
+ a.handleNotFound(w, r)
|
|
return
|
|
return
|
|
}
|
|
}
|
|
|
|
|
|
- pageData := templateData{
|
|
|
|
|
|
+ pageData := pageTemplateData{
|
|
Page: page,
|
|
Page: page,
|
|
App: a,
|
|
App: a,
|
|
}
|
|
}
|
|
|
|
|
|
var responseBytes bytes.Buffer
|
|
var responseBytes bytes.Buffer
|
|
- err := assets.PageTemplate.Execute(&responseBytes, pageData)
|
|
|
|
-
|
|
|
|
|
|
+ err := pageTemplate.Execute(&responseBytes, pageData)
|
|
if err != nil {
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte(err.Error()))
|
|
w.Write([]byte(err.Error()))
|
|
@@ -197,25 +156,22 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request)
|
|
w.Write(responseBytes.Bytes())
|
|
w.Write(responseBytes.Bytes())
|
|
}
|
|
}
|
|
|
|
|
|
-func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
+func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) {
|
|
page, exists := a.slugToPage[r.PathValue("page")]
|
|
page, exists := a.slugToPage[r.PathValue("page")]
|
|
|
|
|
|
if !exists {
|
|
if !exists {
|
|
- a.HandleNotFound(w, r)
|
|
|
|
|
|
+ a.handleNotFound(w, r)
|
|
return
|
|
return
|
|
}
|
|
}
|
|
|
|
|
|
- pageData := templateData{
|
|
|
|
|
|
+ pageData := pageTemplateData{
|
|
Page: page,
|
|
Page: page,
|
|
}
|
|
}
|
|
|
|
|
|
- page.mu.Lock()
|
|
|
|
- defer page.mu.Unlock()
|
|
|
|
- page.UpdateOutdatedWidgets()
|
|
|
|
|
|
+ page.updateOutdatedWidgets()
|
|
|
|
|
|
var responseBytes bytes.Buffer
|
|
var responseBytes bytes.Buffer
|
|
- err := assets.PageContentTemplate.Execute(&responseBytes, pageData)
|
|
|
|
-
|
|
|
|
|
|
+ err := pageContentTemplate.Execute(&responseBytes, pageData)
|
|
if err != nil {
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte(err.Error()))
|
|
w.Write([]byte(err.Error()))
|
|
@@ -225,74 +181,58 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re
|
|
w.Write(responseBytes.Bytes())
|
|
w.Write(responseBytes.Bytes())
|
|
}
|
|
}
|
|
|
|
|
|
-func (a *Application) HandleNotFound(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
+func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) {
|
|
// TODO: add proper not found page
|
|
// TODO: add proper not found page
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write([]byte("Page not found"))
|
|
w.Write([]byte("Page not found"))
|
|
}
|
|
}
|
|
|
|
|
|
-func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
|
|
|
|
- server := http.FileServer(fs)
|
|
|
|
-
|
|
|
|
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
- // TODO: fix always setting cache control even if the file doesn't exist
|
|
|
|
- w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds())))
|
|
|
|
- server.ServeHTTP(w, r)
|
|
|
|
- })
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
+func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
|
|
widgetValue := r.PathValue("widget")
|
|
widgetValue := r.PathValue("widget")
|
|
|
|
|
|
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
|
|
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
|
|
-
|
|
|
|
if err != nil {
|
|
if err != nil {
|
|
- a.HandleNotFound(w, r)
|
|
|
|
|
|
+ a.handleNotFound(w, r)
|
|
return
|
|
return
|
|
}
|
|
}
|
|
|
|
|
|
widget, exists := a.widgetByID[widgetID]
|
|
widget, exists := a.widgetByID[widgetID]
|
|
|
|
|
|
if !exists {
|
|
if !exists {
|
|
- a.HandleNotFound(w, r)
|
|
|
|
|
|
+ a.handleNotFound(w, r)
|
|
return
|
|
return
|
|
}
|
|
}
|
|
|
|
|
|
- widget.HandleRequest(w, r)
|
|
|
|
|
|
+ widget.handleRequest(w, r)
|
|
}
|
|
}
|
|
|
|
|
|
-func (a *Application) AssetPath(asset string) string {
|
|
|
|
- return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
|
|
|
|
|
|
+func (a *application) AssetPath(asset string) string {
|
|
|
|
+ return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
|
|
}
|
|
}
|
|
|
|
|
|
-func (a *Application) Serve() error {
|
|
|
|
|
|
+func (a *application) server() (func() error, func() error) {
|
|
// TODO: add gzip support, static files must have their gzipped contents cached
|
|
// TODO: add gzip support, static files must have their gzipped contents cached
|
|
// TODO: add HTTPS support
|
|
// TODO: add HTTPS support
|
|
mux := http.NewServeMux()
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
- mux.HandleFunc("GET /{$}", a.HandlePageRequest)
|
|
|
|
- mux.HandleFunc("GET /{page}", a.HandlePageRequest)
|
|
|
|
|
|
+ mux.HandleFunc("GET /{$}", a.handlePageRequest)
|
|
|
|
+ mux.HandleFunc("GET /{page}", a.handlePageRequest)
|
|
|
|
|
|
- mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
|
|
|
|
- mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
|
|
|
|
|
|
+ mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
|
|
|
|
+ mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
|
|
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
})
|
|
|
|
|
|
mux.Handle(
|
|
mux.Handle(
|
|
- fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
|
|
|
|
- http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
|
|
|
|
|
|
+ fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
|
|
|
|
+ http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
|
|
)
|
|
)
|
|
|
|
|
|
|
|
+ var absAssetsPath string
|
|
if a.Config.Server.AssetsPath != "" {
|
|
if a.Config.Server.AssetsPath != "" {
|
|
- absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
|
|
|
|
-
|
|
|
|
- if err != nil {
|
|
|
|
- return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath)
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- slog.Info("Serving assets", "path", absAssetsPath)
|
|
|
|
- assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
|
|
|
|
|
|
+ absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
|
|
|
|
+ assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
|
|
mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
|
|
mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
|
|
}
|
|
}
|
|
|
|
|
|
@@ -301,8 +241,25 @@ func (a *Application) Serve() error {
|
|
Handler: mux,
|
|
Handler: mux,
|
|
}
|
|
}
|
|
|
|
|
|
- a.Config.Server.StartedAt = time.Now()
|
|
|
|
- slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
|
|
|
|
|
|
+ start := func() error {
|
|
|
|
+ a.Config.Server.StartedAt = time.Now()
|
|
|
|
+ log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
|
|
|
|
+ a.Config.Server.Host,
|
|
|
|
+ a.Config.Server.Port,
|
|
|
|
+ a.Config.Server.BaseURL,
|
|
|
|
+ absAssetsPath,
|
|
|
|
+ )
|
|
|
|
+
|
|
|
|
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
|
|
+ return err
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return nil
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ stop := func() error {
|
|
|
|
+ return server.Close()
|
|
|
|
+ }
|
|
|
|
|
|
- return server.ListenAndServe()
|
|
|
|
|
|
+ return start, stop
|
|
}
|
|
}
|