diff --git a/.gitignore b/.gitignore index 2cd84fc..29c978d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /playground /.idea /glance*.yml +*.yml.bak diff --git a/docs/glance.yml b/docs/glance.yml index b5c68c4..17cec8b 100644 --- a/docs/glance.yml +++ b/docs/glance.yml @@ -1,105 +1,119 @@ +# --- Edit access --- +# /edit is locked down by default. Pick ONE of the two blocks below. +# +# Option A (recommended): require login. +# Generate values with: +# go run . secret:make +# go run . password:hash +# +# auth: +# secret-key: +# users: +# yourname: +# password-hash: +# +# Option B: open /edit without authentication. ONLY on a trusted network — +# anyone who can reach this server can rewrite your config. +# (The config key is still named `admin` for backward compatibility.) +# +admin: + allow-without-auth: true pages: - - name: Home - # Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look - # hide-desktop-navigation: true - columns: - - size: small - widgets: - - type: calendar - first-day-of-week: monday + - name: Home + # Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look + # hide-desktop-navigation: true + columns: + - size: small + widgets: + - type: calendar + first-day-of-week: monday + - type: rss + limit: 10 + collapse-after: 3 + cache: 12h + feeds: + - url: https://selfh.st/rss/ + title: selfh.st + limit: 4 + - url: https://ciechanow.ski/atom.xml + - url: https://www.joshwcomeau.com/rss.xml + title: Josh Comeau + - url: https://samwho.dev/rss.xml + - url: https://ishadeed.com/feed.xml + title: Ahmad Shadeed + - url: https://www.techmeme.com/feed.xml + title: TechMeme + - type: twitch-channels + channels: + - theprimeagen + - j_blow + - giantwaffle + - cohhcarnage + - christitustech + - EJ_SA + - size: full + widgets: + - type: group + widgets: + - type: hacker-news + - type: lobsters + - type: videos + channels: + - UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips + - UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling + - UCsBjURrPoezykLs9EqgamOA # Fireship + - UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee + - UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium + - type: group + widgets: + - type: reddit + subreddit: technology + show-thumbnails: true + - type: reddit + subreddit: selfhosted + show-thumbnails: true + - size: small + widgets: + - type: weather + location: London, United Kingdom + units: metric # alternatively "imperial" + hour-format: 12h # alternatively "24h" + # Optionally hide the location from being displayed in the widget + # hide-location: true + - type: markets + markets: + - symbol: SPY + name: S&P 500 + - symbol: BTC-USD + name: Bitcoin + - symbol: NVDA + name: NVIDIA + - symbol: AAPL + name: Apple + - symbol: MSFT + name: Microsoft + - type: releases + cache: 1d + # Without authentication the Github API allows for up to 60 requests per hour. You can create a + # read-only token from your Github account settings and use it here to increase the limit. + # token: ... + repositories: + - glanceapp/glance + - go-gitea/gitea + - immich-app/immich + - syncthing/syncthing - - type: rss - limit: 10 - collapse-after: 3 - cache: 12h - feeds: - - url: https://selfh.st/rss/ - title: selfh.st - limit: 4 - - url: https://ciechanow.ski/atom.xml - - url: https://www.joshwcomeau.com/rss.xml - title: Josh Comeau - - url: https://samwho.dev/rss.xml - - url: https://ishadeed.com/feed.xml - title: Ahmad Shadeed +# Add more pages here: +# - name: Your page name +# columns: +# - size: small +# widgets: +# # Add widgets here - - type: twitch-channels - channels: - - theprimeagen - - j_blow - - giantwaffle - - cohhcarnage - - christitustech - - EJ_SA +# - size: full +# widgets: +# # Add widgets here - - size: full - widgets: - - type: group - widgets: - - type: hacker-news - - type: lobsters - - - type: videos - channels: - - UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips - - UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling - - UCsBjURrPoezykLs9EqgamOA # Fireship - - UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee - - UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium - - - type: group - widgets: - - type: reddit - subreddit: technology - show-thumbnails: true - - type: reddit - subreddit: selfhosted - show-thumbnails: true - - - size: small - widgets: - - type: weather - location: London, United Kingdom - units: metric # alternatively "imperial" - hour-format: 12h # alternatively "24h" - # Optionally hide the location from being displayed in the widget - # hide-location: true - - - type: markets - markets: - - symbol: SPY - name: S&P 500 - - symbol: BTC-USD - name: Bitcoin - - symbol: NVDA - name: NVIDIA - - symbol: AAPL - name: Apple - - symbol: MSFT - name: Microsoft - - - type: releases - cache: 1d - # Without authentication the Github API allows for up to 60 requests per hour. You can create a - # read-only token from your Github account settings and use it here to increase the limit. - # token: ... - repositories: - - glanceapp/glance - - go-gitea/gitea - - immich-app/immich - - syncthing/syncthing - - # Add more pages here: - # - name: Your page name - # columns: - # - size: small - # widgets: - # # Add widgets here - - # - size: full - # widgets: - # # Add widgets here - - # - size: small - # widgets: - # # Add widgets here +# - size: small +# widgets: +# # Add widgets here diff --git a/internal/glance/admin-edit.go b/internal/glance/admin-edit.go new file mode 100644 index 0000000..f0506ac --- /dev/null +++ b/internal/glance/admin-edit.go @@ -0,0 +1,555 @@ +package glance + +import ( + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// configEditor manipulates glance.yml as a yaml.Node tree, preserving +// comments, ordering, and unresolved ${env:X}/${secret:X} tokens that +// would be destroyed by round-tripping through the parsed config struct. +type configEditor struct { + path string + root yaml.Node +} + +func loadConfigEditor(path string) (*configEditor, error) { + _, includes, err := parseYAMLIncludes(path) + if err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + if len(includes) > 0 { + return nil, fmt.Errorf("admin editing is not supported when the config uses include directives — edit included files manually for now") + } + + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + e := &configEditor{path: path} + if err := yaml.Unmarshal(raw, &e.root); err != nil { + return nil, fmt.Errorf("parsing config yaml: %w", err) + } + if e.root.Kind != yaml.DocumentNode || len(e.root.Content) == 0 { + return nil, fmt.Errorf("config has no document content") + } + if e.root.Content[0].Kind != yaml.MappingNode { + return nil, fmt.Errorf("config top-level is not a mapping") + } + return e, nil +} + +// save validates the edited tree against newConfigFromYAML, writes a +// .bak of the previous file, then writes via temp+rename for atomicity. +func (e *configEditor) save() error { + out, err := yaml.Marshal(&e.root) + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + + if _, err := newConfigFromYAML(out); err != nil { + return fmt.Errorf("edited config is invalid: %w", err) + } + + if existing, err := os.ReadFile(e.path); err == nil { + if werr := os.WriteFile(e.path+".bak", existing, 0644); werr != nil { + log.Printf("admin: failed to write backup: %v", werr) + } + } + + tmpPath := e.path + ".tmp" + if err := os.WriteFile(tmpPath, out, 0644); err != nil { + return fmt.Errorf("writing temp file: %w", err) + } + if err := os.Rename(tmpPath, e.path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("renaming temp file into place: %w", err) + } + return nil +} + +func (e *configEditor) topMapping() *yaml.Node { + return e.root.Content[0] +} + +// findOrCreateKey returns the value node for the given key under a mapping. +// If create is true and the key does not exist, it is appended with a fresh +// value node of the given kind. +func findOrCreateKey(m *yaml.Node, key string, create bool, valueKind yaml.Kind) (*yaml.Node, error) { + if m.Kind != yaml.MappingNode { + return nil, fmt.Errorf("node is not a mapping") + } + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1], nil + } + } + if !create { + return nil, fmt.Errorf("key %q not found", key) + } + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} + valNode := &yaml.Node{Kind: valueKind} + m.Content = append(m.Content, keyNode, valNode) + return valNode, nil +} + +func (e *configEditor) pagesNode(create bool) (*yaml.Node, error) { + return findOrCreateKey(e.topMapping(), "pages", create, yaml.SequenceNode) +} + +// widgetSlot returns the sequence node holding the widget and the widget's +// position within it. Caller can read via seq.Content[pos] or replace by +// assigning to seq.Content[pos]. Use colIdx = -1 for head widgets. +func (e *configEditor) widgetSlot(pageIdx, colIdx, widgetIdx int) (*yaml.Node, int, error) { + pageNode, err := e.pageNodeAt(pageIdx) + if err != nil { + return nil, 0, err + } + + var seq *yaml.Node + if colIdx == -1 { + seq, err = findOrCreateKey(pageNode, "head-widgets", false, yaml.SequenceNode) + if err != nil { + return nil, 0, err + } + } else { + columns, err := columnsOf(pageNode) + if err != nil { + return nil, 0, err + } + if colIdx < 0 || colIdx >= len(columns.Content) { + return nil, 0, fmt.Errorf("column index out of range") + } + seq, err = widgetsOf(columns.Content[colIdx]) + if err != nil { + return nil, 0, err + } + } + + if widgetIdx < 0 || widgetIdx >= len(seq.Content) { + return nil, 0, fmt.Errorf("widget index out of range") + } + return seq, widgetIdx, nil +} + +func (e *configEditor) pageNodeAt(idx int) (*yaml.Node, error) { + pages, err := e.pagesNode(false) + if err != nil { + return nil, err + } + if pages.Kind != yaml.SequenceNode { + return nil, fmt.Errorf("pages is not a sequence") + } + if idx < 0 || idx >= len(pages.Content) { + return nil, fmt.Errorf("page index %d out of range", idx) + } + return pages.Content[idx], nil +} + +func columnsOf(pageNode *yaml.Node) (*yaml.Node, error) { + return findOrCreateKey(pageNode, "columns", false, yaml.SequenceNode) +} + +func widgetsOf(columnNode *yaml.Node) (*yaml.Node, error) { + return findOrCreateKey(columnNode, "widgets", true, yaml.SequenceNode) +} + +func newPageNode(title string) *yaml.Node { + return &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "name"}, + {Kind: yaml.ScalarNode, Value: title}, + {Kind: yaml.ScalarNode, Value: "columns"}, + {Kind: yaml.SequenceNode, Content: []*yaml.Node{ + {Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "size"}, + {Kind: yaml.ScalarNode, Value: "full"}, + {Kind: yaml.ScalarNode, Value: "widgets"}, + {Kind: yaml.SequenceNode}, + }}, + }}, + }, + } +} + +// adminError renders a minimal HTML error page so failed mutations don't +// leave the user staring at a blank screen. The hot-reload watcher will +// still pick up any successful intermediate save state. +func adminError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + w.Write([]byte("Edit error

Couldn't save

The change wasn't applied. Your config file is unchanged.

"))
+	w.Write([]byte(htmlEscape(msg)))
+	w.Write([]byte("

Back

")) +} + +func htmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'") + return r.Replace(s) +} + +// ---- handlers ---- + +func (a *application) handleAdminAddPage(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + if err := r.ParseForm(); err != nil { + adminError(w, http.StatusBadRequest, err.Error()) + return + } + title := strings.TrimSpace(r.FormValue("title")) + if title == "" { + adminError(w, http.StatusBadRequest, "page title is required") + return + } + + editor, err := loadConfigEditor(a.ConfigPath) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + pages, err := editor.pagesNode(true) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + pages.Content = append(pages.Content, newPageNode(title)) + + if err := editor.save(); err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + http.Redirect(w, r, a.Config.Server.BaseURL+"/edit", http.StatusSeeOther) +} + +func (a *application) handleAdminDeletePage(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + slug := r.PathValue("page") + idx, ok := a.freshPageIndexBySlug(slug) + if !ok { + a.handleNotFound(w, r) + return + } + + editor, err := loadConfigEditor(a.ConfigPath) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + pages, err := editor.pagesNode(false) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + if idx >= len(pages.Content) { + adminError(w, http.StatusInternalServerError, "page index out of range — config may have been edited externally") + return + } + if len(pages.Content) <= 1 { + adminError(w, http.StatusBadRequest, "cannot delete the last remaining page") + return + } + pages.Content = append(pages.Content[:idx], pages.Content[idx+1:]...) + + if err := editor.save(); err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + http.Redirect(w, r, a.Config.Server.BaseURL+"/edit", http.StatusSeeOther) +} + +func (a *application) handleAdminDeleteWidget(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + slug := r.PathValue("page") + pageIdx, ok := a.freshPageIndexBySlug(slug) + if !ok { + a.handleNotFound(w, r) + return + } + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil { + a.handleNotFound(w, r) + return + } + idx, err := strconv.Atoi(r.PathValue("idx")) + if err != nil { + a.handleNotFound(w, r) + return + } + + editor, err := loadConfigEditor(a.ConfigPath) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + pageNode, err := editor.pageNodeAt(pageIdx) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + columns, err := columnsOf(pageNode) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + if col < 0 || col >= len(columns.Content) { + adminError(w, http.StatusBadRequest, "column index out of range") + return + } + widgets, err := widgetsOf(columns.Content[col]) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + if idx < 0 || idx >= len(widgets.Content) { + adminError(w, http.StatusBadRequest, "widget index out of range") + return + } + widgets.Content = append(widgets.Content[:idx], widgets.Content[idx+1:]...) + + if err := editor.save(); err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/pages/"+slug, http.StatusSeeOther) +} + +func (a *application) handleAdminMoveWidget(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + slug := r.PathValue("page") + pageIdx, ok := a.freshPageIndexBySlug(slug) + if !ok { + a.handleNotFound(w, r) + return + } + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil { + a.handleNotFound(w, r) + return + } + idx, err := strconv.Atoi(r.PathValue("idx")) + if err != nil { + a.handleNotFound(w, r) + return + } + + dir := r.URL.Query().Get("dir") + delta := 0 + switch dir { + case "up": + delta = -1 + case "down": + delta = 1 + default: + adminError(w, http.StatusBadRequest, "dir must be 'up' or 'down'") + return + } + + editor, err := loadConfigEditor(a.ConfigPath) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + pageNode, err := editor.pageNodeAt(pageIdx) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + columns, err := columnsOf(pageNode) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + if col < 0 || col >= len(columns.Content) { + adminError(w, http.StatusBadRequest, "column index out of range") + return + } + widgets, err := widgetsOf(columns.Content[col]) + if err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + target := idx + delta + if idx < 0 || idx >= len(widgets.Content) || target < 0 || target >= len(widgets.Content) { + adminError(w, http.StatusBadRequest, "cannot move further in that direction") + return + } + widgets.Content[idx], widgets.Content[target] = widgets.Content[target], widgets.Content[idx] + + if err := editor.save(); err != nil { + adminError(w, http.StatusInternalServerError, err.Error()) + return + } + http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/pages/"+slug, http.StatusSeeOther) +} + +func (a *application) handleAdminEditWidget(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + if err := r.ParseForm(); err != nil { + adminError(w, http.StatusBadRequest, err.Error()) + return + } + slug := r.PathValue("page") + page, pageIdx, exists := findPageBySlug(a.freshPagesFromDisk(), slug) + if !exists { + a.handleNotFound(w, r) + return + } + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil { + a.handleNotFound(w, r) + return + } + idx, err := strconv.Atoi(r.PathValue("idx")) + if err != nil { + a.handleNotFound(w, r) + return + } + + yamlText := r.FormValue("yaml") + + rerenderError := func(msg string) { + a.renderWidgetEditor(w, r, editorRenderInput{ + pageTitle: page.Title, + pageSlug: page.Slug, + col: col, + idx: idx, + widgetType: widgetTypeFromYAML(yamlText), + yamlText: yamlText, + errorMessage: msg, + }) + } + + newNode, err := parseWidgetYAML(yamlText) + if err != nil { + rerenderError(err.Error()) + return + } + + editor, err := loadConfigEditor(a.ConfigPath) + if err != nil { + rerenderError(err.Error()) + return + } + seq, pos, err := editor.widgetSlot(pageIdx, col, idx) + if err != nil { + rerenderError(err.Error()) + return + } + seq.Content[pos] = newNode + + if err := editor.save(); err != nil { + rerenderError(err.Error()) + return + } + http.Redirect(w, r, + fmt.Sprintf("%s/edit/pages/%s/widgets/%d/%d", a.Config.Server.BaseURL, slug, col, idx), + http.StatusSeeOther) +} + +// parseWidgetYAML parses the editor's submitted text into a single mapping +// node suitable for splicing into the config tree. +func parseWidgetYAML(yamlText string) (*yaml.Node, error) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte(yamlText), &doc); err != nil { + return nil, fmt.Errorf("YAML parse error: %w", err) + } + if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { + return nil, fmt.Errorf("submitted YAML is empty") + } + node := doc.Content[0] + if node.Kind != yaml.MappingNode { + return nil, fmt.Errorf("widget YAML must be a mapping (key: value pairs)") + } + return node, nil +} + +func (a *application) handleAdminCreateWidget(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + if err := r.ParseForm(); err != nil { + adminError(w, http.StatusBadRequest, err.Error()) + return + } + slug := r.PathValue("page") + page, pageIdx, exists := findPageBySlug(a.freshPagesFromDisk(), slug) + if !exists { + a.handleNotFound(w, r) + return + } + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil { + a.handleNotFound(w, r) + return + } + + yamlText := r.FormValue("yaml") + + rerenderError := func(msg string) { + a.renderWidgetEditor(w, r, editorRenderInput{ + pageTitle: page.Title, + pageSlug: page.Slug, + isNew: true, + col: col, + widgetType: widgetTypeFromYAML(yamlText), + yamlText: yamlText, + errorMessage: msg, + }) + } + + newNode, err := parseWidgetYAML(yamlText) + if err != nil { + rerenderError(err.Error()) + return + } + + editor, err := loadConfigEditor(a.ConfigPath) + if err != nil { + rerenderError(err.Error()) + return + } + pageNode, err := editor.pageNodeAt(pageIdx) + if err != nil { + rerenderError(err.Error()) + return + } + columns, err := columnsOf(pageNode) + if err != nil { + rerenderError("page has no columns; add a column first") + return + } + if col < 0 || col >= len(columns.Content) { + rerenderError("column index out of range") + return + } + widgets, err := widgetsOf(columns.Content[col]) + if err != nil { + rerenderError(err.Error()) + return + } + widgets.Content = append(widgets.Content, newNode) + + if err := editor.save(); err != nil { + rerenderError(err.Error()) + return + } + http.Redirect(w, r, a.Config.Server.BaseURL+"/edit/pages/"+slug, http.StatusSeeOther) +} diff --git a/internal/glance/admin.go b/internal/glance/admin.go new file mode 100644 index 0000000..7676f07 --- /dev/null +++ b/internal/glance/admin.go @@ -0,0 +1,537 @@ +package glance + +import ( + "bytes" + "html/template" + "net/http" + "strconv" + + "gopkg.in/yaml.v3" +) + +var ( + adminIndexTemplate = mustParseTemplate("admin.html", "document.html", "footer.html") + adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html") + adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html") +) + +// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted. +var allWidgetTypes = []string{ + "bookmarks", "calendar", "change-detection", "clock", "custom-api", + "dns-stats", "docker-containers", "extension", "group", "hacker-news", + "html", "iframe", "lobsters", "markets", "monitor", "reddit", + "releases", "repository", "rss", "search", "server-stats", + "split-column", "to-do", "twitch-channels", "twitch-top-games", + "videos", "weather", +} + +// widgetFieldReferences caches the marshaled YAML of an empty instance of +// each widget type, used to show users what fields are available when +// editing. Computed once at startup. +var widgetFieldReferences = func() map[string]string { + out := make(map[string]string, len(allWidgetTypes)) + for _, t := range allWidgetTypes { + w, err := newWidget(t) + if err != nil { + continue + } + b, err := yaml.Marshal(w) + if err != nil { + continue + } + out[t] = string(b) + } + return out +}() + +// widgetDocsAnchors maps a widget type to its anchor in upstream Glance docs. +// Anchors are GitHub-rendered from the headings in docs/configuration.md. +var widgetDocsAnchors = map[string]string{ + "bookmarks": "bookmarks", + "calendar": "calendar", + "calendar-legacy": "calendar-legacy", + "change-detection": "changedetectionio", + "clock": "clock", + "custom-api": "custom-api", + "dns-stats": "dns-stats", + "docker-containers": "docker-containers", + "extension": "extension", + "group": "group", + "hacker-news": "hacker-news", + "lobsters": "lobsters", + "markets": "markets", + "monitor": "monitor", + "reddit": "reddit", + "releases": "releases", + "repository": "repository", + "rss": "rss", + "search": "search-widget", + "server-stats": "server-stats", + "split-column": "split-column", + "to-do": "todo", + "twitch-channels": "twitch-channels", + "twitch-top-games": "twitch-top-games", + "videos": "videos", + "weather": "weather", +} + +func widgetDocsURL(widgetType string) string { + anchor, ok := widgetDocsAnchors[widgetType] + if !ok { + return "https://github.com/glanceapp/glance/blob/main/docs/configuration.md#widgets" + } + return "https://github.com/glanceapp/glance/blob/main/docs/configuration.md#" + anchor +} + +// widgetStarters provides hand-curated minimal-but-meaningful YAML for each +// widget type, drawn from the upstream docs. Used to pre-fill the editor +// when adding a new widget so users see a working shape they can adapt. +var widgetStarters = map[string]string{ + "bookmarks": `type: bookmarks +groups: + - title: Sites + links: + - title: Glance + url: https://github.com/glanceapp/glance +`, + "calendar": "type: calendar\n", + "calendar-legacy": "type: calendar-legacy\n", + "change-detection": `type: change-detection +instance-url: https://changedetection.example.com/ +token: ${CHANGE_DETECTION_TOKEN} +`, + "clock": "type: clock\n", + "custom-api": `type: custom-api +url: https://api.example.com/data +template: | +

Replace with a Go template that renders the JSON response.

+`, + "dns-stats": `type: dns-stats +service: pihole +url: http://pi.hole +allow-insecure: true +username: admin +password: ${DNS_PASSWORD} +`, + "docker-containers": "type: docker-containers\n", + "extension": `type: extension +url: https://example.com/extension +`, + "group": `type: group +widgets: + - type: hacker-news + - type: lobsters +`, + "hacker-news": "type: hacker-news\n", + "html": `type: html +source: | +

Hello from a custom HTML widget.

+`, + "iframe": `type: iframe +source: https://example.com +`, + "lobsters": "type: lobsters\n", + "markets": `type: markets +markets: + - symbol: SPY + name: S&P 500 + - symbol: BTC-USD + name: Bitcoin +`, + "monitor": `type: monitor +title: Services +sites: + - title: Example + url: https://example.com +`, + "reddit": `type: reddit +subreddit: technology +`, + "releases": `type: releases +repositories: + - glanceapp/glance +`, + "repository": `type: repository +repository: glanceapp/glance +`, + "rss": `type: rss +feeds: + - url: https://www.theverge.com/rss/index.xml + title: The Verge +`, + "search": "type: search\n", + "server-stats": "type: server-stats\n", + "split-column": `type: split-column +widgets: + - type: clock + - type: search +`, + "to-do": "type: to-do\n", + "twitch-channels": `type: twitch-channels +channels: + - shroud +`, + "twitch-top-games": "type: twitch-top-games\n", + "videos": `type: videos +channels: + - UCXuqSBlHAE6Xw-yeJA0Tunw +`, + "weather": `type: weather +location: London, GB +`, +} + +func widgetStarterYAML(widgetType string) string { + if s, ok := widgetStarters[widgetType]; ok { + return s + } + return "type: " + widgetType + "\n" +} + +type adminWidgetView struct { + ColumnIndex int + WidgetIndex int + ID uint64 + Title string + Type string + IsFirst bool + IsLast bool +} + +type adminColumnView struct { + Index int + Size string + Widgets []adminWidgetView +} + +type adminPageSummary struct { + Title string + Slug string + WidgetCount int +} + +type adminPageDetail struct { + Title string + Slug string + HeadWidgets []adminWidgetView + Columns []adminColumnView +} + +type adminTemplateData struct { + App *application + Request templateRequestData + + Pages []adminPageSummary + Page *adminPageDetail + Widget *adminWidgetView + WidgetTypes []string + WidgetYAML string + LoadError string + FieldReference string + WidgetExample string + DocsURL string + IsNew bool + ErrorMessage string +} + +func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + + pages := a.freshPagesFromDisk() + summaries := make([]adminPageSummary, 0, len(pages)) + for p := range pages { + page := &pages[p] + count := len(page.HeadWidgets) + for c := range page.Columns { + count += len(page.Columns[c].Widgets) + } + summaries = append(summaries, adminPageSummary{ + Title: page.Title, + Slug: page.Slug, + WidgetCount: count, + }) + } + + data := adminTemplateData{App: a, Pages: summaries} + a.populateTemplateRequestData(&data.Request, r) + renderAdminTemplate(w, adminIndexTemplate, data) +} + +func (a *application) handleAdminPage(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + + pages := a.freshPagesFromDisk() + page, _, exists := findPageBySlug(pages, r.PathValue("page")) + if !exists { + a.handleNotFound(w, r) + return + } + + detail := adminPageDetail{Title: page.Title, Slug: page.Slug} + + for i, widget := range page.HeadWidgets { + detail.HeadWidgets = append(detail.HeadWidgets, adminWidgetView{ + ColumnIndex: -1, + WidgetIndex: i, + ID: widget.GetID(), + Title: widget.GetTitle(), + Type: widget.GetType(), + }) + } + + for c := range page.Columns { + col := &page.Columns[c] + view := adminColumnView{Index: c, Size: col.Size} + last := len(col.Widgets) - 1 + for w, widget := range col.Widgets { + view.Widgets = append(view.Widgets, adminWidgetView{ + ColumnIndex: c, + WidgetIndex: w, + ID: widget.GetID(), + Title: widget.GetTitle(), + Type: widget.GetType(), + IsFirst: w == 0, + IsLast: w == last, + }) + } + detail.Columns = append(detail.Columns, view) + } + + data := adminTemplateData{App: a, Page: &detail, WidgetTypes: allWidgetTypes} + a.populateTemplateRequestData(&data.Request, r) + renderAdminTemplate(w, adminPageTemplate, data) +} + +func (a *application) handleAdminWidget(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + + pages := a.freshPagesFromDisk() + page, pageIdx, exists := findPageBySlug(pages, r.PathValue("page")) + if !exists { + a.handleNotFound(w, r) + return + } + + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil { + a.handleNotFound(w, r) + return + } + idx, err := strconv.Atoi(r.PathValue("idx")) + if err != nil { + a.handleNotFound(w, r) + return + } + + var widget widget + if col == -1 { + if idx < 0 || idx >= len(page.HeadWidgets) { + a.handleNotFound(w, r) + return + } + widget = page.HeadWidgets[idx] + } else { + if col < 0 || col >= len(page.Columns) { + a.handleNotFound(w, r) + return + } + column := &page.Columns[col] + if idx < 0 || idx >= len(column.Widgets) { + a.handleNotFound(w, r) + return + } + widget = column.Widgets[idx] + } + + yamlText, loadErr := loadWidgetYAML(a.ConfigPath, pageIdx, col, idx) + + a.renderWidgetEditor(w, r, editorRenderInput{ + pageTitle: page.Title, + pageSlug: page.Slug, + col: col, + idx: idx, + widgetType: widget.GetType(), + widgetTitle: widget.GetTitle(), + yamlText: yamlText, + loadError: loadErr, + }) +} + +func (a *application) handleAdminNewWidget(w http.ResponseWriter, r *http.Request) { + if !a.adminAccessAllowed(w, r) { + return + } + + pages := a.freshPagesFromDisk() + page, _, exists := findPageBySlug(pages, r.PathValue("page")) + if !exists { + a.handleNotFound(w, r) + return + } + col, err := strconv.Atoi(r.PathValue("col")) + if err != nil || col < 0 || col >= len(page.Columns) { + a.handleNotFound(w, r) + return + } + widgetType := r.URL.Query().Get("type") + if _, err := newWidget(widgetType); err != nil { + adminError(w, http.StatusBadRequest, "unknown widget type: "+widgetType) + return + } + + a.renderWidgetEditor(w, r, editorRenderInput{ + pageTitle: page.Title, + pageSlug: page.Slug, + isNew: true, + col: col, + widgetType: widgetType, + yamlText: widgetStarterYAML(widgetType), + }) +} + +type editorRenderInput struct { + pageTitle string + pageSlug string + isNew bool + col int + idx int + widgetType string + widgetTitle string + yamlText string + loadError string + errorMessage string +} + +func (a *application) renderWidgetEditor(w http.ResponseWriter, r *http.Request, in editorRenderInput) { + view := adminWidgetView{ + ColumnIndex: in.col, + WidgetIndex: in.idx, + Title: in.widgetTitle, + Type: in.widgetType, + } + detail := adminPageDetail{Title: in.pageTitle, Slug: in.pageSlug} + + data := adminTemplateData{ + App: a, + Page: &detail, + Widget: &view, + IsNew: in.isNew, + WidgetYAML: in.yamlText, + LoadError: in.loadError, + ErrorMessage: in.errorMessage, + FieldReference: widgetFieldReferences[in.widgetType], + WidgetExample: widgetStarters[in.widgetType], + DocsURL: widgetDocsURL(in.widgetType), + } + a.populateTemplateRequestData(&data.Request, r) + renderAdminTemplate(w, adminWidgetTemplate, data) +} + +// widgetTypeFromYAML extracts just the `type:` field from a YAML mapping. +// Used when re-rendering the editor after a save error so docs/reference +// match what the user is actually editing. +func widgetTypeFromYAML(yamlText string) string { + var probe struct { + Type string `yaml:"type"` + } + _ = yaml.Unmarshal([]byte(yamlText), &probe) + return probe.Type +} + +// freshPagesFromDisk reads the on-disk config and returns the parsed pages +// with slugs auto-generated. Admin views use this instead of a.Config.Pages +// because the fsnotify watcher debounces 500ms after a save, and during that +// window a.Config still reflects the pre-save state. Falls back to a copy +// of the cached config on any error. +func (a *application) freshPagesFromDisk() []page { + contents, _, err := parseYAMLIncludes(a.ConfigPath) + if err != nil { + return a.Config.Pages + } + cfg, err := newConfigFromYAML(contents) + if err != nil { + return a.Config.Pages + } + for p := range cfg.Pages { + if cfg.Pages[p].Slug == "" { + cfg.Pages[p].Slug = titleToSlug(cfg.Pages[p].Title) + } + } + return cfg.Pages +} + +func findPageBySlug(pages []page, slug string) (*page, int, bool) { + for i := range pages { + if pages[i].Slug == slug { + return &pages[i], i, true + } + } + return nil, -1, false +} + +// freshPageIndexBySlug looks up the on-disk page index by slug. Mutating +// handlers must use this rather than a.pageIndexBySlug because the cached +// a.Config can lag the file by up to ~500ms after a save. +func (a *application) freshPageIndexBySlug(slug string) (int, bool) { + _, idx, ok := findPageBySlug(a.freshPagesFromDisk(), slug) + return idx, ok +} + +// loadWidgetYAML reads the on-disk config and returns the marshaled YAML for +// one widget. Errors are returned as a string so the template can show them +// without preventing the page from rendering. +func loadWidgetYAML(path string, pageIdx, col, idx int) (string, string) { + editor, err := loadConfigEditor(path) + if err != nil { + return "", err.Error() + } + seq, pos, err := editor.widgetSlot(pageIdx, col, idx) + if err != nil { + return "", err.Error() + } + out, err := yaml.Marshal(seq.Content[pos]) + if err != nil { + return "", err.Error() + } + return string(out), "" +} + +// adminAccessAllowed enforces that admin endpoints either require auth, or +// have been explicitly opted into without auth via config.admin.allow-without-auth. +// Returns false if the response has been written and the caller should bail. +func (a *application) adminAccessAllowed(w http.ResponseWriter, r *http.Request) bool { + if !a.RequiresAuth && !a.Config.Admin.AllowWithoutAuth { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`Editing disabled` + + `` + + `

Editing is disabled

` + + `

The edit UI requires authentication. Either:

` + + `
    ` + + `
  • Configure auth.users in your config (recommended), or
  • ` + + `
  • Set admin.allow-without-auth: true in your config if you accept that anyone reachable on the network can edit it. (The legacy admin key still works for this option.)
  • ` + + `
` + + ``)) + return false + } + if a.handleUnauthorizedResponse(w, r, redirectToLogin) { + return false + } + return true +} + +func renderAdminTemplate(w http.ResponseWriter, t *template.Template, data adminTemplateData) { + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + w.Write(buf.Bytes()) +} diff --git a/internal/glance/config.go b/internal/glance/config.go index d4d6af0..31a8e07 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -65,6 +65,12 @@ type config struct { AppBackgroundColor string `yaml:"app-background-color"` } `yaml:"branding"` + Admin struct { + // AllowWithoutAuth permits access to /admin even when auth.users is empty. + // Off by default because admin can rewrite the config file. + AllowWithoutAuth bool `yaml:"allow-without-auth"` + } `yaml:"admin"` + Pages []page `yaml:"pages"` } diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 28771fa..dc46ade 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -28,9 +28,10 @@ const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour var reservedPageSlugs = []string{"login", "logout"} type application struct { - Version string - CreatedAt time.Time - Config config + Version string + CreatedAt time.Time + Config config + ConfigPath string parsedManifest []byte @@ -44,11 +45,12 @@ type application struct { failedAuthAttempts map[string]*failedAuthAttempt } -func newApplication(c *config) (*application, error) { +func newApplication(c *config, configPath string) (*application, error) { app := &application{ Version: buildVersion, CreatedAt: time.Now(), Config: *c, + ConfigPath: configPath, slugToPage: make(map[string]*page), widgetByID: make(map[uint64]widget), } @@ -450,6 +452,18 @@ func (a *application) server() (func() error, func() error) { w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("GET /edit", a.handleAdminIndex) + mux.HandleFunc("GET /edit/pages/{page}", a.handleAdminPage) + mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/{idx}", a.handleAdminWidget) + + mux.HandleFunc("POST /edit/api/pages", a.handleAdminAddPage) + mux.HandleFunc("POST /edit/api/pages/{page}/delete", a.handleAdminDeletePage) + mux.HandleFunc("GET /edit/pages/{page}/widgets/{col}/new", a.handleAdminNewWidget) + mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/new", a.handleAdminCreateWidget) + mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}", a.handleAdminEditWidget) + mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/delete", a.handleAdminDeleteWidget) + mux.HandleFunc("POST /edit/api/pages/{page}/widgets/{col}/{idx}/move", a.handleAdminMoveWidget) + if a.RequiresAuth { mux.HandleFunc("GET /login", a.handleLoginPageRequest) mux.HandleFunc("GET /logout", a.handleLogoutRequest) diff --git a/internal/glance/main.go b/internal/glance/main.go index 6d73a83..3bf5075 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -114,7 +114,7 @@ func serveApp(configPath string) error { return } - app, err := newApplication(config) + app, err := newApplication(config, configPath) if err != nil { log.Printf("Failed to create application: %v", err) @@ -165,7 +165,7 @@ func serveApp(configPath string) error { return fmt.Errorf("validating config file: %w", err) } - app, err := newApplication(config) + app, err := newApplication(config, configPath) if err != nil { return fmt.Errorf("creating application: %w", err) } diff --git a/internal/glance/templates/admin-page.html b/internal/glance/templates/admin-page.html new file mode 100644 index 0000000..7c74365 --- /dev/null +++ b/internal/glance/templates/admin-page.html @@ -0,0 +1,79 @@ +{{- template "document.html" . }} + +{{- define "document-title" }}Edit {{ .Page.Title }} - {{ .App.Config.Branding.AppName }}{{ end }} + +{{- define "document-body" }} +
+
+
+ ← Edit +
+

{{ .Page.Title }}

+

/{{ .Page.Slug }}

+ + {{- if .Page.HeadWidgets }} +

Head widgets

+ + {{- end }} + +

Columns

+ {{- if not .Page.Columns }} +

No columns configured on this page. Edit the YAML manually to add columns — column editing comes in a later phase.

+ {{- end }} + {{- range $colIdx, $col := .Page.Columns }} +
+

+ Column {{ $col.Index }} + ({{ if $col.Size }}{{ $col.Size }}{{ else }}default{{ end }}) +

+ {{- if not $col.Widgets }} +

No widgets in this column.

+ {{- else }} + + {{- end }} + +
+ + +
+
+ {{- end }} +
+ {{ template "footer.html" . }} +
+{{- end }} diff --git a/internal/glance/templates/admin-widget.html b/internal/glance/templates/admin-widget.html new file mode 100644 index 0000000..bc5aa84 --- /dev/null +++ b/internal/glance/templates/admin-widget.html @@ -0,0 +1,94 @@ +{{- template "document.html" . }} + +{{- define "document-title" }}{{ if .IsNew }}Add {{ .Widget.Type }}{{ else }}Edit {{ if .Widget.Title }}{{ .Widget.Title }}{{ else }}{{ .Widget.Type }}{{ end }}{{ end }} - {{ .App.Config.Branding.AppName }}{{ end }} + +{{- define "document-body" }} +
+
+ +

+ {{- if .IsNew -}} + Add widget — {{ .Widget.Type }} + {{- else -}} + {{ if .Widget.Title }}{{ .Widget.Title }}{{ else }}(untitled){{ end }} + — {{ .Widget.Type }} + {{- end }} +

+

+ {{- if .IsNew -}} + New widget for column {{ .Widget.ColumnIndex }} + {{- else if eq .Widget.ColumnIndex -1 -}} + head widget #{{ .Widget.WidgetIndex }} + {{- else -}} + column {{ .Widget.ColumnIndex }}, widget #{{ .Widget.WidgetIndex }} + {{- end }} +

+ + {{- if .ErrorMessage }} +
+ Couldn't save: +
{{ .ErrorMessage }}
+
+ {{- end }} + + {{- if .LoadError }} +
+ Can't load YAML: +
{{ .LoadError }}
+
+ {{- end }} + + {{- if or .WidgetYAML .IsNew }} +
+ How to edit a {{ if .Widget.Type }}{{ .Widget.Type }}{{ else }}widget{{ end }} +
+ {{- if .WidgetExample }} +

A working example of a {{ .Widget.Type }} widget:

+
{{- .WidgetExample -}}
+ {{- end }} + {{- if .FieldReference }} +

All fields supported by {{ .Widget.Type }} (defaults shown — fields with yaml:"-" tags or empty defaults still apply):

+
{{- .FieldReference -}}
+ {{- end }} +

YAML quick tips:

+
    +
  • Indentation matters — use two spaces per level, never tabs.
  • +
  • Strings, numbers, booleans go inline: limit: 10, hide-header: true.
  • +
  • List items start with - and align under the parent key.
  • +
  • Pull secrets from the environment: token: ${env:MY_TOKEN}.
  • +
+

Validation runs before saving — if the YAML is invalid, your file isn't touched and the error appears above.

+ {{- if .Widget.Type }} +

Full upstream reference: {{ .Widget.Type }} docs ↗

+ {{- end }} +
+
+ +
+ + +
+ + Cancel +
+ {{- if not .IsNew }} +

+ You can change type: here to convert this widget to any type. +

+ {{- end }} +
+ {{- end }} +
+ {{ template "footer.html" . }} +
+{{- end }} diff --git a/internal/glance/templates/admin.html b/internal/glance/templates/admin.html new file mode 100644 index 0000000..c54073d --- /dev/null +++ b/internal/glance/templates/admin.html @@ -0,0 +1,40 @@ +{{- template "document.html" . }} + +{{- define "document-title" }}Edit - {{ .App.Config.Branding.AppName }}{{ end }} + +{{- define "document-body" }} +
+
+

Edit

+

+ Editing {{ .App.ConfigPath }}. Changes take effect via hot-reload. +

+ +

Pages

+
    + {{- range .Pages }} +
  • +
    + + {{ .Title }} + + — {{ .WidgetCount }} widget{{ if ne .WidgetCount 1 }}s{{ end }} +
    /{{ .Slug }}
    +
    +
    + +
    +
  • + {{- end }} +
+ +

Add a page

+
+ + +
+
+ {{ template "footer.html" . }} +
+{{- end }} diff --git a/internal/glance/templates/page.html b/internal/glance/templates/page.html index 0f83183..e9ce64c 100644 --- a/internal/glance/templates/page.html +++ b/internal/glance/templates/page.html @@ -43,6 +43,13 @@ {{ end }} + {{- if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }} + + + + + + {{- end }} {{- if .App.RequiresAuth }} @@ -92,6 +99,15 @@ {{ end }} + {{ if or .App.RequiresAuth .App.Config.Admin.AllowWithoutAuth }} + +
Edit dashboard
+ + + +
+ {{ end }} + {{ if .App.RequiresAuth }}
Logout
diff --git a/internal/glance/widget.go b/internal/glance/widget.go index 50dc3cb..c0da673 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -128,6 +128,7 @@ type widget interface { Render() template.HTML GetType() string GetID() uint64 + GetTitle() string initialize() error requiresUpdate(*time.Time) bool @@ -210,6 +211,10 @@ func (w *widgetBase) GetType() string { return w.Type } +func (w *widgetBase) GetTitle() string { + return w.Title +} + func (w *widgetBase) setProviders(providers *widgetProviders) { w.Providers = providers }