package glance import ( "bytes" "context" "fmt" "log" "net/http" "path/filepath" "strconv" "strings" "sync" "time" ) var ( pageTemplate = mustParseTemplate("page.html", "document.html") pageContentTemplate = mustParseTemplate("page-content.html") manifestTemplate = mustParseTemplate("manifest.json") ) const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour type application struct { Version string CreatedAt time.Time Config config parsedManifest []byte slugToPage map[string]*page widgetByID map[uint64]widget } func newApplication(c *config) (*application, error) { app := &application{ Version: buildVersion, CreatedAt: time.Now(), Config: *c, slugToPage: make(map[string]*page), widgetByID: make(map[uint64]widget), } config := &app.Config app.slugToPage[""] = &config.Pages[0] providers := &widgetProviders{ assetResolver: app.StaticAssetPath, } // // Init themes // themeKeys := make([]string, 0, 2) themeProps := make([]*themeProperties, 0, 2) defaultDarkTheme, ok := config.Theme.Presets.Get("default-dark") if ok && !config.Theme.SameAs(defaultDarkTheme) || !config.Theme.SameAs(&themeProperties{}) { themeKeys = append(themeKeys, "default-dark") themeProps = append(themeProps, &themeProperties{}) } themeKeys = append(themeKeys, "default-light") themeProps = append(themeProps, &themeProperties{ Light: true, BackgroundColor: &hslColorField{240, 13, 86}, PrimaryColor: &hslColorField{45, 100, 26}, NegativeColor: &hslColorField{0, 50, 50}, ContrastMultiplier: 1.3, TextSaturationMultiplier: 0.6, }) themePresets, err := newOrderedYAMLMap(themeKeys, themeProps) if err != nil { return nil, fmt.Errorf("creating theme presets: %v", err) } config.Theme.Presets = *themePresets.Merge(&config.Theme.Presets) for key, properties := range config.Theme.Presets.Items() { properties.Key = key if err := properties.init(); err != nil { return nil, fmt.Errorf("initializing preset theme %s: %v", key, err) } } config.Theme.Key = "default" if err := config.Theme.init(); err != nil { return nil, fmt.Errorf("initializing default theme: %v", err) } 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 if page.Width == "default" { page.Width = "" } if page.DesktopNavigationWidth == "" && page.DesktopNavigationWidth != "default" { page.DesktopNavigationWidth = page.Width } 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.GetID()] = widget widget.setProviders(providers) } } } config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/") config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile) config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL) config.Branding.FaviconURL = ternary( config.Branding.FaviconURL == "", app.StaticAssetPath("favicon.svg"), app.resolveUserDefinedAssetPath(config.Branding.FaviconURL), ) config.Branding.FaviconType = ternary( strings.HasSuffix(config.Branding.FaviconURL, ".svg"), "image/svg+xml", "image/png", ) if config.Branding.AppName == "" { config.Branding.AppName = "Glance" } if config.Branding.AppIconURL == "" { config.Branding.AppIconURL = app.StaticAssetPath("app-icon.png") } if config.Branding.AppBackgroundColor == "" { config.Branding.AppBackgroundColor = config.Theme.BackgroundColorAsHex } manifest, err := executeTemplateToString(manifestTemplate, pageTemplateData{App: app}) if err != nil { return nil, fmt.Errorf("parsing manifest.json: %v", err) } app.parsedManifest = []byte(manifest) return app, nil } func (p *page) updateOutdatedWidgets() { now := time.Now() var wg sync.WaitGroup context := context.Background() for c := range p.Columns { for w := range p.Columns[c].Widgets { widget := p.Columns[c].Widgets[w] if !widget.requiresUpdate(&now) { continue } wg.Add(1) go func() { defer wg.Done() widget.update(context) }() } } wg.Wait() } func (a *application) resolveUserDefinedAssetPath(path string) string { if strings.HasPrefix(path, "/assets/") { return a.Config.Server.BaseURL + path } return path } type pageTemplateRequestData struct { Theme *themeProperties } type pageTemplateData struct { App *application Page *page Request pageTemplateRequestData } func (a *application) populateTemplateRequestData(data *pageTemplateRequestData, r *http.Request) { theme := &a.Config.Theme.themeProperties selectedTheme, err := r.Cookie("theme") if err == nil { preset, exists := a.Config.Theme.Presets.Get(selectedTheme.Value) if exists { theme = preset } } data.Theme = theme } func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] if !exists { a.handleNotFound(w, r) return } data := pageTemplateData{ Page: page, App: a, } a.populateTemplateRequestData(&data.Request, r) var responseBytes bytes.Buffer err := pageTemplate.Execute(&responseBytes, data) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return } w.Write(responseBytes.Bytes()) } func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] if !exists { a.handleNotFound(w, r) return } pageData := pageTemplateData{ Page: page, } var err error var responseBytes bytes.Buffer func() { page.mu.Lock() defer page.mu.Unlock() page.updateOutdatedWidgets() err = pageContentTemplate.Execute(&responseBytes, pageData) }() if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return } w.Write(responseBytes.Bytes()) } func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) { // TODO: add proper not found page w.WriteHeader(http.StatusNotFound) w.Write([]byte("Page not found")) } func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) { widgetValue := r.PathValue("widget") widgetID, err := strconv.ParseUint(widgetValue, 10, 64) if err != nil { a.handleNotFound(w, r) return } widget, exists := a.widgetByID[widgetID] if !exists { a.handleNotFound(w, r) return } widget.handleRequest(w, r) } func (a *application) StaticAssetPath(asset string) string { return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset } func (a *application) VersionedAssetPath(asset string) string { return a.Config.Server.BaseURL + asset + "?v=" + strconv.FormatInt(a.CreatedAt.Unix(), 10) } func (a *application) server() (func() error, func() error) { // TODO: add gzip support, static files must have their gzipped contents cached // TODO: add HTTPS support mux := http.NewServeMux() mux.HandleFunc("GET /{$}", a.handlePageRequest) mux.HandleFunc("GET /{page}", a.handlePageRequest) mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest) mux.HandleFunc("POST /api/set-theme/{key}", a.handleThemeChangeRequest) mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest) mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) mux.Handle( fmt.Sprintf("GET /static/%s/{path...}", staticFSHash), http.StripPrefix( "/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), STATIC_ASSETS_CACHE_DURATION), ), ) assetCacheControlValue := fmt.Sprintf( "public, max-age=%d", int(STATIC_ASSETS_CACHE_DURATION.Seconds()), ) mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Cache-Control", assetCacheControlValue) w.Header().Add("Content-Type", "text/css; charset=utf-8") w.Write(bundledCSSContents) }) mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Cache-Control", assetCacheControlValue) w.Header().Add("Content-Type", "application/json") w.Write(a.parsedManifest) }) var absAssetsPath string if a.Config.Server.AssetsPath != "" { 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)) } server := http.Server{ Addr: fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port), Handler: mux, } start := func() error { 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 start, stop }