package main import ( "encoding/csv" "errors" "net/http" "runtime" "strconv" "strings" "time" "github.com/jkaninda/okapi" "gorm.io/gorm" ) const maxBannerLen = 200 type loginRequest struct { Token string `json:"token"` } // adminEntry exposes the fields public responses omit. type adminEntry struct { Entry IP string `json:"ip,omitempty"` } func toAdmin(e Entry) adminEntry { return adminEntry{Entry: e, IP: e.IP} } type flagsRequest struct { Pinned *bool `json:"pinned"` Hidden *bool `json:"hidden"` } // AdminSession reports whether the console is enabled and the caller signed in. func (h *Handler) AdminSession(c *okapi.Context) error { return c.OK(okapi.M{ "enabled": h.auth.Enabled(), "authenticated": h.auth.Authenticated(c), }) } // AdminLogin exchanges ADMIN_TOKEN for a session cookie. func (h *Handler) AdminLogin(c *okapi.Context) error { if !h.auth.Enabled() { return c.JSON(http.StatusNotFound, okapi.M{"error": "admin console is disabled (set ADMIN_TOKEN)"}) } var req loginRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"}) } if !h.auth.CheckToken(strings.TrimSpace(req.Token)) { // Slows down guessing without needing shared rate-limit state. time.Sleep(700 * time.Millisecond) return c.JSON(http.StatusUnauthorized, okapi.M{"error": "invalid admin token"}) } h.auth.SetSession(c, h.auth.Issue(time.Now())) return c.OK(okapi.M{"authenticated": true}) } // AdminLogout clears the session cookie. func (h *Handler) AdminLogout(c *okapi.Context) error { h.auth.SetSession(c, "") return c.NoContent() } // AdminOverview returns wall statistics plus runtime details of the replica // that answered, which is what makes scaling visible in the console. func (h *Handler) AdminOverview(c *okapi.Context) error { ctx := c.Request().Context() stats, err := h.store.Stats(ctx, 14) if err != nil { return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not compute stats"}) } settings, _ := h.store.Settings(ctx) var mem runtime.MemStats runtime.ReadMemStats(&mem) return c.OK(okapi.M{ "stats": stats, "settings": settings, "online": h.broker.Online(), "visitors": h.visitorsActive(ctx), "replicas": h.broker.Replicas(), "debug": h.debug, "runtime": okapi.M{ "app": h.appName, "version": h.version, "host": h.host, "db": dbDisplayName(h.dbDriver), "broker": h.broker.Mode(), "redis": h.broker.RedisStatus(ctx), "healthy": !h.unhealthy.Load(), "started_at": h.started.UTC().Format(time.RFC3339), "uptime_s": int64(time.Since(h.started).Seconds()), "requests": h.requests.Load(), "go": runtime.Version(), "cpus": runtime.NumCPU(), "goroutines": runtime.NumGoroutine(), "heap_mb": float64(mem.HeapAlloc) / (1 << 20), "sys_mb": float64(mem.Sys) / (1 << 20), }, }) } // AdminListEntries lists every entry, hidden ones included, with filters. func (h *Handler) AdminListEntries(c *okapi.Context) error { limit := atoiDefault(c.Query("limit"), 25) if limit <= 0 || limit > 200 { limit = 25 } offset := max(atoiDefault(c.Query("offset"), 0), 0) entries, total, err := h.store.AdminList(c.Request().Context(), EntryFilter{ Status: c.Query("status"), Query: c.Query("q"), Limit: limit, Offset: offset, }) if err != nil { return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load entries"}) } out := make([]adminEntry, len(entries)) for i, e := range entries { out[i] = toAdmin(e) } return c.OK(okapi.M{"entries": out, "total": total, "limit": limit, "offset": offset}) } // AdminVisitors lists the most recently active visitor identities, so the // console can show the cookies the app has issued and who is around now. func (h *Handler) AdminVisitors(c *okapi.Context) error { limit := atoiDefault(c.Query("limit"), 50) visitors, err := h.store.AdminVisitors(c.Request().Context(), limit) if err != nil { return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load visitors"}) } fresh := time.Now().Add(-visitorFreshWindow) return c.OK(okapi.M{"visitors": visitors, "active_since": fresh.UTC().Format(time.RFC3339)}) } // AdminUpdateEntry pins/unpins or hides/unhides an entry and broadcasts it. func (h *Handler) AdminUpdateEntry(c *okapi.Context) error { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid id"}) } var req flagsRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"}) } entry, err := h.store.SetFlags(c.Request().Context(), id, req.Pinned, req.Hidden) if errors.Is(err, gorm.ErrRecordNotFound) { return c.JSON(http.StatusNotFound, okapi.M{"error": "entry not found"}) } if err != nil { return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not update entry"}) } h.broker.Publish(Event{Type: "updated", Entry: &entry}) return c.OK(toAdmin(entry)) } // AdminSettings returns the shared wall settings. func (h *Handler) AdminSettings(c *okapi.Context) error { st, err := h.store.Settings(c.Request().Context()) if err != nil { return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load settings"}) } return c.OK(st) } // AdminSaveSettings persists the wall settings and broadcasts them so every // open tab on every replica reacts immediately. func (h *Handler) AdminSaveSettings(c *okapi.Context) error { var st Settings if err := c.Bind(&st); err != nil { return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"}) } st.Banner = strings.TrimSpace(st.Banner) if len(st.Banner) > maxBannerLen { return c.JSON(http.StatusBadRequest, okapi.M{"error": "banner is too long (max 200 characters)"}) } switch st.Theme { case "light", "dark": // forced appearance for every visitor case "", "system": st.Theme = "" // follow each visitor's preference default: return c.JSON(http.StatusBadRequest, okapi.M{"error": "theme must be system, light or dark"}) } if err := h.store.SaveSettings(c.Request().Context(), st); err != nil { return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save settings"}) } h.broker.Publish(Event{Type: "settings", Settings: &st}) return c.OK(st) } // AdminExport streams every entry as CSV. func (h *Handler) AdminExport(c *okapi.Context) error { entries, err := h.store.All(c.Request().Context()) if err != nil { return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not export entries"}) } w := c.ResponseWriter() w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="guestbook-`+time.Now().UTC().Format("20060102-150405")+`.csv"`) cw := csv.NewWriter(w) _ = cw.Write([]string{"id", "name", "message", "pinned", "hidden", "replica", "ip", "created_at"}) for _, e := range entries { _ = cw.Write([]string{ strconv.FormatUint(uint64(e.ID), 10), csvSafe(e.Name), csvSafe(e.Message), strconv.FormatBool(e.Pinned), strconv.FormatBool(e.Hidden), e.Replica, e.IP, e.CreatedAt.UTC().Format(time.RFC3339), }) } cw.Flush() return cw.Error() } // csvSafe neutralises values a spreadsheet would evaluate as a formula. func csvSafe(s string) string { if s != "" && strings.ContainsRune("=+-@\t\r", rune(s[0])) { return "'" + s } return s }