package glance import ( "bytes" "fmt" "html/template" "net/http" "os" "strconv" "time" "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") adminPageSettingsTemplate = mustParseTemplate("admin-page-settings.html", "document.html", "footer.html") adminSiteSettingsTemplate = mustParseTemplate("admin-site-settings.html", "document.html", "footer.html") adminThemeSettingsTemplate = mustParseTemplate("admin-theme-settings.html", "document.html", "footer.html") adminThemePresetTemplate = mustParseTemplate("admin-theme-preset.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 IsFirst bool IsLast bool } type adminBackupSummary struct { N int Time string // human-readable, e.g. "2 minutes ago" SizeKB int Present bool } type adminPageDetail struct { Title string Slug string HeadWidgets []adminWidgetView Columns []adminColumnView } type adminPageSettings struct { Name string Slug string Width string DesktopNavigationWidth string ShowMobileHeader bool HideDesktopNavigation bool CenterVertically bool } 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 PageSettings *adminPageSettings IsFirstPage bool IsLastPage bool SiteSettings *adminSiteSettings ThemeSettings *adminThemeSettings PresetForm *adminPresetFormData Backups []adminBackupSummary } type adminThemeSettings struct { BackgroundColorHex string PrimaryColorHex string PositiveColorHex string NegativeColorHex string Light bool DisablePicker bool ContrastMultiplier float32 TextSaturationMultiplier float32 CustomCSSFile string Presets []adminPresetItem CatalogThemes []catalogTheme } type adminPresetItem struct { Key string BackgroundColorHex string PrimaryColorHex string PositiveColorHex string NegativeColorHex string Light bool ContrastMultiplier float32 TextSaturationMultiplier float32 PreviewHTML template.HTML } type adminPresetFormData struct { Key string BackgroundColorHex string PrimaryColorHex string PositiveColorHex string NegativeColorHex string Light bool ContrastMultiplier float32 TextSaturationMultiplier float32 IsNew bool ErrorMessage string } type catalogTheme struct { Name string Key string PreviewHTML template.HTML BgHex string PrimaryHex string PositiveHex string NegativeHex string Light bool CM float32 TSM float32 } var builtinThemeCatalog []catalogTheme func parseHSLStr(s string) *hslColorField { if s == "" { return nil } matches := hslColorFieldPattern.FindStringSubmatch(s) if len(matches) != 4 { return nil } h, _ := strconv.ParseFloat(matches[1], 64) sat, _ := strconv.ParseFloat(matches[2], 64) l, _ := strconv.ParseFloat(matches[3], 64) return &hslColorField{H: h, S: sat, L: l} } func safeHex(c *hslColorField) string { if c == nil { return "" } return c.ToHex() } func init() { type rawEntry struct { name, key, bg, primary, positive, negative string cm, tsm float32 light bool } entries := []rawEntry{ {"Teal City", "teal-city", "225 14 15", "157 47 65", "", "", 1.1, 0, false}, {"Catppuccin Frappe", "catppuccin-frappe", "229 19 23", "222 74 74", "96 44 68", "359 68 71", 1.2, 0, false}, {"Catppuccin Macchiato", "catppuccin-macchiato", "232 23 18", "220 83 75", "105 48 72", "351 74 73", 1.2, 0, false}, {"Catppuccin Mocha", "catppuccin-mocha", "240 21 15", "217 92 83", "115 54 76", "347 70 65", 1.2, 0, false}, {"Camouflage", "camouflage", "186 21 20", "97 13 80", "", "", 1.2, 0, false}, {"Gruvbox Dark", "gruvbox-dark", "0 0 16", "43 59 81", "61 66 44", "6 96 59", 0, 0, false}, {"Kanagawa Dark", "kanagawa-dark", "240 13 14", "51 33 68", "", "358 100 68", 1.2, 0, false}, {"Tucan", "tucan", "50 1 6", "24 97 58", "", "209 88 54", 0, 0, false}, {"Dracula", "dracula", "231 15 21", "265 89 79", "135 94 66", "0 100 67", 1.2, 0, false}, {"Shades of Purple", "shades-of-purple", "243 33 25", "50 100 49", "98 82 71", "12 77 52", 1.2, 0, false}, {"Neon Pink", "neon-pink", "240 27 11", "321 100 71", "165 78 51", "360 100 71", 1.5, 0, false}, {"Catppuccin Latte", "catppuccin-latte", "220 23 95", "220 91 54", "109 58 40", "347 87 44", 1.0, 0, true}, {"Peachy", "peachy", "28 40 77", "155 100 20", "", "0 100 60", 1.1, 0.5, true}, {"Zebra", "zebra", "0 0 95", "0 0 10", "", "0 90 50", 0, 0, true}, } for _, e := range entries { p := themeProperties{ BackgroundColor: parseHSLStr(e.bg), PrimaryColor: parseHSLStr(e.primary), PositiveColor: parseHSLStr(e.positive), NegativeColor: parseHSLStr(e.negative), ContrastMultiplier: e.cm, TextSaturationMultiplier: e.tsm, Light: e.light, Key: e.key, } _ = p.init() builtinThemeCatalog = append(builtinThemeCatalog, catalogTheme{ Name: e.name, Key: e.key, PreviewHTML: p.PreviewHTML, BgHex: safeHex(p.BackgroundColor), PrimaryHex: safeHex(p.PrimaryColor), PositiveHex: safeHex(p.PositiveColor), NegativeHex: safeHex(p.NegativeColor), Light: e.light, CM: e.cm, TSM: e.tsm, }) } } type adminSiteSettings struct { AppName string LogoText string LogoURL string FaviconURL string AppIconURL string AppBackgroundColor string HideFooter bool CustomFooter string } func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) { if !a.adminAccessAllowed(w, r) { return } pages := a.freshPagesFromDisk() last := len(pages) - 1 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, IsFirst: p == 0, IsLast: p == last, }) } data := adminTemplateData{App: a, Pages: summaries, Backups: a.collectBackups()} a.populateTemplateRequestData(&data.Request, r) renderAdminTemplate(w, adminIndexTemplate, data) } func (a *application) collectBackups() []adminBackupSummary { out := make([]adminBackupSummary, 0, maxBackups) now := time.Now() for n := 1; n <= maxBackups; n++ { info, err := os.Stat(backupPath(a.ConfigPath, n)) if err != nil { out = append(out, adminBackupSummary{N: n, Present: false}) continue } out = append(out, adminBackupSummary{ N: n, Time: humanizeDuration(now.Sub(info.ModTime())) + " ago", SizeKB: int(info.Size() / 1024), Present: true, }) } return out } func humanizeDuration(d time.Duration) string { switch { case d < time.Minute: return fmt.Sprintf("%ds", int(d.Seconds())) case d < time.Hour: return fmt.Sprintf("%dm", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%dh", int(d.Hours())) default: return fmt.Sprintf("%dd", int(d.Hours()/24)) } } 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 hexOf(c *hslColorField) string { if c == nil { return "" } return c.ToHex() } func (a *application) freshPresetsFromDisk() []adminPresetItem { contents, _, err := parseYAMLIncludes(a.ConfigPath) if err != nil { return nil } cfg, err := newConfigFromYAML(contents) if err != nil { return nil } var keys []string for k := range cfg.Theme.Presets.Items() { keys = append(keys, k) } items := make([]adminPresetItem, 0, len(keys)) for _, key := range keys { p, _ := cfg.Theme.Presets.Get(key) p.Key = key _ = p.init() items = append(items, adminPresetItem{ Key: key, BackgroundColorHex: safeHex(p.BackgroundColor), PrimaryColorHex: safeHex(p.PrimaryColor), PositiveColorHex: safeHex(p.PositiveColor), NegativeColorHex: safeHex(p.NegativeColor), Light: p.Light, ContrastMultiplier: p.ContrastMultiplier, TextSaturationMultiplier: p.TextSaturationMultiplier, PreviewHTML: p.PreviewHTML, }) } return items } func (a *application) handleAdminThemeSettings(w http.ResponseWriter, r *http.Request) { if !a.adminAccessAllowed(w, r) { return } t := a.Config.Theme settings := &adminThemeSettings{ BackgroundColorHex: hexOf(t.BackgroundColor), PrimaryColorHex: hexOf(t.PrimaryColor), PositiveColorHex: hexOf(t.PositiveColor), NegativeColorHex: hexOf(t.NegativeColor), Light: t.Light, DisablePicker: t.DisablePicker, ContrastMultiplier: t.ContrastMultiplier, TextSaturationMultiplier: t.TextSaturationMultiplier, CustomCSSFile: t.CustomCSSFile, Presets: a.freshPresetsFromDisk(), CatalogThemes: builtinThemeCatalog, } data := adminTemplateData{App: a, ThemeSettings: settings} a.populateTemplateRequestData(&data.Request, r) renderAdminTemplate(w, adminThemeSettingsTemplate, data) } func (a *application) handleAdminThemePreset(w http.ResponseWriter, r *http.Request) { if !a.adminAccessAllowed(w, r) { return } key := r.PathValue("key") var form adminPresetFormData if key == "" || key == "new" { form.IsNew = true q := r.URL.Query() form.Key = q.Get("name") form.BackgroundColorHex = q.Get("bg") form.PrimaryColorHex = q.Get("primary") form.PositiveColorHex = q.Get("positive") form.NegativeColorHex = q.Get("negative") form.Light = q.Get("light") == "true" if cm := q.Get("cm"); cm != "" { if v, err := strconv.ParseFloat(cm, 32); err == nil { form.ContrastMultiplier = float32(v) } } if tsm := q.Get("tsm"); tsm != "" { if v, err := strconv.ParseFloat(tsm, 32); err == nil { form.TextSaturationMultiplier = float32(v) } } } else { presets := a.freshPresetsFromDisk() found := false for _, p := range presets { if p.Key == key { form = adminPresetFormData{ Key: p.Key, BackgroundColorHex: p.BackgroundColorHex, PrimaryColorHex: p.PrimaryColorHex, PositiveColorHex: p.PositiveColorHex, NegativeColorHex: p.NegativeColorHex, Light: p.Light, ContrastMultiplier: p.ContrastMultiplier, TextSaturationMultiplier: p.TextSaturationMultiplier, IsNew: false, } found = true break } } if !found { a.handleNotFound(w, r) return } } data := adminTemplateData{App: a, PresetForm: &form} a.populateTemplateRequestData(&data.Request, r) renderAdminTemplate(w, adminThemePresetTemplate, data) } func (a *application) handleAdminSiteSettings(w http.ResponseWriter, r *http.Request) { if !a.adminAccessAllowed(w, r) { return } b := a.Config.Branding settings := &adminSiteSettings{ AppName: b.AppName, LogoText: b.LogoText, LogoURL: b.LogoURL, FaviconURL: b.FaviconURL, AppIconURL: b.AppIconURL, AppBackgroundColor: b.AppBackgroundColor, HideFooter: b.HideFooter, CustomFooter: string(b.CustomFooter), } data := adminTemplateData{App: a, SiteSettings: settings} a.populateTemplateRequestData(&data.Request, r) renderAdminTemplate(w, adminSiteSettingsTemplate, data) } func (a *application) handleAdminPageSettings(w http.ResponseWriter, r *http.Request) { if !a.adminAccessAllowed(w, r) { return } pages := a.freshPagesFromDisk() page, idx, exists := findPageBySlug(pages, r.PathValue("page")) if !exists { a.handleNotFound(w, r) return } settings := &adminPageSettings{ Name: page.Title, Slug: page.Slug, Width: page.Width, DesktopNavigationWidth: page.DesktopNavigationWidth, ShowMobileHeader: page.ShowMobileHeader, HideDesktopNavigation: page.HideDesktopNavigation, CenterVertically: page.CenterVertically, } data := adminTemplateData{ App: a, Page: &adminPageDetail{Title: page.Title, Slug: page.Slug}, PageSettings: settings, IsFirstPage: idx == 0, IsLastPage: idx == len(pages)-1, } a.populateTemplateRequestData(&data.Request, r) renderAdminTemplate(w, adminPageSettingsTemplate, data) } 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:

` + `` + ``)) 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()) }