package handler import ( "fmt" "html/template" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "ridgwaysystems.org/website/internal/blog" "ridgwaysystems.org/website/internal/changelog" "ridgwaysystems.org/website/internal/gitea" "ridgwaysystems.org/website/internal/newsletter" "ridgwaysystems.org/website/internal/status" ) // AdminRouter dispatches /admin/* paths. func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) { path := r.URL.Path switch { case path == "/admin/login": if r.Method == http.MethodPost { h.adminLoginPost(w, r) } else { h.adminLoginGet(w, r) } case path == "/admin/logout": h.requireAuth(h.adminLogout)(w, r) case path == "/admin/new": if r.Method == http.MethodPost { h.requireAuth(h.adminNewPost)(w, r) } else { h.requireAuth(h.adminNewGet)(w, r) } case strings.HasPrefix(path, "/admin/edit/"): if r.Method == http.MethodPost { h.requireAuth(h.adminEditPost)(w, r) } else { h.requireAuth(h.adminEditGet)(w, r) } case strings.HasPrefix(path, "/admin/delete/"): h.requireAuth(h.adminDeletePost)(w, r) case path == "/admin/status": if r.Method == http.MethodPost { h.requireAuth(h.adminStatusPost)(w, r) } else { h.requireAuth(h.adminStatusGet)(w, r) } case path == "/admin/changelog": h.requireAuth(h.adminChangelogList)(w, r) case path == "/admin/changelog/new": if r.Method == http.MethodPost { h.requireAuth(h.adminChangelogNewPost)(w, r) } else { h.requireAuth(h.adminChangelogNewGet)(w, r) } case strings.HasPrefix(path, "/admin/changelog/edit/"): if r.Method == http.MethodPost { h.requireAuth(h.adminChangelogEditPost)(w, r) } else { h.requireAuth(h.adminChangelogEditGet)(w, r) } case strings.HasPrefix(path, "/admin/changelog/delete/"): h.requireAuth(h.adminChangelogDelete)(w, r) case path == "/admin/preview": h.requireAuth(h.adminPreview)(w, r) case path == "/admin/upload": h.requireAuth(h.adminUpload)(w, r) case path == "/admin/uploads": h.requireAuth(h.adminUploads)(w, r) case path == "/admin/newsletter": if r.Method == http.MethodPost { h.requireAuth(h.adminNewsletterDelete)(w, r) } else { h.requireAuth(h.adminNewsletter)(w, r) } case path == "/admin/outages": if r.Method == http.MethodPost { h.requireAuth(h.adminOutagesPost)(w, r) } else { h.requireAuth(h.adminOutagesGet)(w, r) } default: h.renderErr(w, http.StatusNotFound, "Admin page not found.") } } // AdminDashboard handles GET /admin. func (h *Handler) AdminDashboard(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/admin" && r.URL.Path != "/admin/" { // Fall through to router h.AdminRouter(w, r) return } h.requireAuth(h.adminDashboard)(w, r) } type dashboardData struct { Posts []*blog.Post Flash string } func (h *Handler) adminDashboard(w http.ResponseWriter, r *http.Request) { flash := r.URL.Query().Get("flash") posts, err := h.store.All(true) if err != nil { h.renderErr(w, http.StatusInternalServerError, "Could not load posts.") return } h.render(w, "admin-dashboard", dashboardData{Posts: posts, Flash: flash}) } // --- Login --- func (h *Handler) adminLoginGet(w http.ResponseWriter, r *http.Request) { if isAuthenticated(r) { http.Redirect(w, r, "/admin", http.StatusSeeOther) return } h.render(w, "admin-login", map[string]string{"Error": ""}) } func (h *Handler) adminLoginPost(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { h.renderErr(w, http.StatusBadRequest, "Bad form data.") return } password := r.FormValue("password") if !checkPassword(password) { h.render(w, "admin-login", map[string]string{"Error": "Invalid password."}) return } setSession(w) http.Redirect(w, r, "/admin", http.StatusSeeOther) } func (h *Handler) adminLogout(w http.ResponseWriter, r *http.Request) { clearSession(w) http.Redirect(w, r, "/admin/login", http.StatusSeeOther) } // --- New post --- type editorData struct { Post *blog.Post Raw string IsNew bool Error string } func (h *Handler) adminNewGet(w http.ResponseWriter, r *http.Request) { now := time.Now().Format("2006-01-02") raw := fmt.Sprintf("---\ntitle: New Post\ndate: %s\ntags: []\ndraft: true\ndescription: \"\"\n---\n\n", now) h.render(w, "admin-editor", editorData{Raw: raw, IsNew: true}) } func (h *Handler) adminNewPost(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { h.renderErr(w, http.StatusBadRequest, "Bad form data.") return } slug := sanitizeSlug(r.FormValue("slug")) content := r.FormValue("content") if slug == "" { h.render(w, "admin-editor", editorData{Raw: content, IsNew: true, Error: "Slug is required."}) return } if err := h.store.Save(slug, content); err != nil { h.render(w, "admin-editor", editorData{Raw: content, IsNew: true, Error: "Failed to save: " + err.Error()}) return } http.Redirect(w, r, "/admin?flash=Post+created", http.StatusSeeOther) } // --- Edit post --- func (h *Handler) adminEditGet(w http.ResponseWriter, r *http.Request) { slug := strings.TrimPrefix(r.URL.Path, "/admin/edit/") raw, err := h.store.RawContent(slug) if err != nil { h.renderErr(w, http.StatusNotFound, "Post not found.") return } post, _ := h.store.Get(slug) h.render(w, "admin-editor", editorData{Post: post, Raw: raw, IsNew: false}) } func (h *Handler) adminEditPost(w http.ResponseWriter, r *http.Request) { slug := strings.TrimPrefix(r.URL.Path, "/admin/edit/") if err := r.ParseForm(); err != nil { h.renderErr(w, http.StatusBadRequest, "Bad form data.") return } content := r.FormValue("content") if err := h.store.Save(slug, content); err != nil { h.render(w, "admin-editor", editorData{Raw: content, Error: "Failed to save: " + err.Error()}) return } http.Redirect(w, r, "/admin?flash=Post+saved", http.StatusSeeOther) } // --- Delete post --- func (h *Handler) adminDeletePost(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { h.renderErr(w, http.StatusMethodNotAllowed, "POST required.") return } slug := strings.TrimPrefix(r.URL.Path, "/admin/delete/") if err := h.store.Delete(slug); err != nil { h.renderErr(w, http.StatusInternalServerError, "Delete failed: "+err.Error()) return } http.Redirect(w, r, "/admin?flash=Post+deleted", http.StatusSeeOther) } // --- Status editor --- type adminStatusData struct { Page *status.Page Error string Flash string } func (h *Handler) adminStatusGet(w http.ResponseWriter, r *http.Request) { p, err := status.Load(filepath.Join(h.dataDir, "status.json")) if err != nil { h.render(w, "admin-status", adminStatusData{Error: "Could not load status.json: " + err.Error()}) return } h.render(w, "admin-status", adminStatusData{Page: p, Flash: r.URL.Query().Get("flash")}) } func (h *Handler) adminStatusPost(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { h.renderErr(w, http.StatusBadRequest, "Bad form data.") return } p, err := status.Load(filepath.Join(h.dataDir, "status.json")) if err != nil { h.render(w, "admin-status", adminStatusData{Error: "Could not load status: " + err.Error()}) return } for i := range p.Services { if s := r.FormValue(fmt.Sprintf("status_%d", i)); s != "" { p.Services[i].Status = s } p.Services[i].Note = strings.TrimSpace(r.FormValue(fmt.Sprintf("note_%d", i))) } if err := status.Save(filepath.Join(h.dataDir, "status.json"), p); err != nil { h.render(w, "admin-status", adminStatusData{Page: p, Error: "Save failed: " + err.Error()}) return } http.Redirect(w, r, "/admin/status?flash=Saved", http.StatusSeeOther) } // --- Preview --- func (h *Handler) adminPreview(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { h.renderErr(w, http.StatusMethodNotAllowed, "POST required.") return } content := r.FormValue("content") html, err := blog.RenderMarkdown(content) if err != nil { http.Error(w, "render error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, "
%s
", template.HTML(html)) } // --- Image upload --- var allowedImageTypes = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", } const maxUploadSize = 8 << 20 // 8 MB func (h *Handler) adminUpload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, `{"error":"POST required"}`, http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) if err := r.ParseMultipartForm(maxUploadSize); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, `{"error":"file too large (max 8 MB)"}`) return } file, header, err := r.FormFile("image") if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, `{"error":"no image field in form"}`) return } defer file.Close() // Read first 512 bytes to detect MIME type buf := make([]byte, 512) n, _ := file.Read(buf) mimeType := http.DetectContentType(buf[:n]) ext, ok := allowedImageTypes[mimeType] if !ok { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, `{"error":"unsupported image type: %s"}`, mimeType) return } // Build a safe filename: sanitize original name + timestamp base := sanitizeSlug(strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))) if base == "" { base = "image" } filename := fmt.Sprintf("%s-%d%s", base, time.Now().UnixMilli(), ext) dest := filepath.Join("static", "uploads", filename) if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, `{"error":"could not create upload directory"}`) return } out, err := os.Create(dest) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, `{"error":"could not save file"}`) return } defer out.Close() // Write the already-read bytes, then the rest out.Write(buf[:n]) if _, err := io.Copy(out, file); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, `{"error":"write failed"}`) return } url := "/static/uploads/" + filename w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"url":"%s","markdown":"![image](%s)"}`, url, url) } // --- Uploads browser --- type uploadFile struct { Name string URL string Markdown string } type uploadsData struct { Files []uploadFile Flash string } func (h *Handler) adminUploads(w http.ResponseWriter, r *http.Request) { const dir = "static/uploads" entries, err := os.ReadDir(dir) var files []uploadFile if err == nil { for _, e := range entries { if e.IsDir() { continue } name := e.Name() url := "/static/uploads/" + name files = append(files, uploadFile{ Name: name, URL: url, Markdown: "![image](" + url + ")", }) } } h.render(w, "admin-uploads", uploadsData{Files: files, Flash: r.URL.Query().Get("flash")}) } // --- Newsletter admin --- type adminNewsletterData struct { Subscribers []newsletter.Subscriber Flash string Count int } func (h *Handler) adminNewsletter(w http.ResponseWriter, r *http.Request) { subs, err := h.news.All() if err != nil { h.renderErr(w, http.StatusInternalServerError, "Could not load subscribers.") return } h.render(w, "admin-newsletter", adminNewsletterData{ Subscribers: subs, Count: len(subs), Flash: r.URL.Query().Get("flash"), }) } func (h *Handler) adminNewsletterDelete(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { h.renderErr(w, http.StatusBadRequest, "Bad form data.") return } email := r.FormValue("email") if err := h.news.Remove(email); err != nil { h.renderErr(w, http.StatusInternalServerError, "Remove failed: "+err.Error()) return } http.Redirect(w, r, "/admin/newsletter?flash=Removed", http.StatusSeeOther) } // --- Changelog admin --- type adminChangelogData struct { Log *changelog.Log Entry *changelog.Entry Categories []string Error string Flash string IsNew bool } func (h *Handler) adminChangelogList(w http.ResponseWriter, r *http.Request) { l, err := changelog.Load(filepath.Join(h.dataDir, "changelog.json")) if err != nil { l = &changelog.Log{} } h.render(w, "admin-changelog", adminChangelogData{ Log: l, Flash: r.URL.Query().Get("flash"), }) } func (h *Handler) adminChangelogNewGet(w http.ResponseWriter, r *http.Request) { h.render(w, "admin-changelog-editor", adminChangelogData{ Categories: changelog.Categories, IsNew: true, Entry: &changelog.Entry{Date: time.Now().Format("2006-01-02")}, }) } func (h *Handler) adminChangelogNewPost(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { h.renderErr(w, http.StatusBadRequest, "Bad form data.") return } e := changelog.Entry{ Date: r.FormValue("date"), Title: strings.TrimSpace(r.FormValue("title")), Description: strings.TrimSpace(r.FormValue("description")), Category: r.FormValue("category"), } if e.Title == "" || e.Date == "" { h.render(w, "admin-changelog-editor", adminChangelogData{ Entry: &e, Categories: changelog.Categories, IsNew: true, Error: "Date and title are required.", }) return } if err := changelog.Add(filepath.Join(h.dataDir, "changelog.json"), e); err != nil { h.render(w, "admin-changelog-editor", adminChangelogData{ Entry: &e, Categories: changelog.Categories, IsNew: true, Error: "Failed to save: " + err.Error(), }) return } http.Redirect(w, r, "/admin/changelog?flash=Entry+created", http.StatusSeeOther) } func (h *Handler) adminChangelogEditGet(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/admin/changelog/edit/") e, err := changelog.Get(filepath.Join(h.dataDir, "changelog.json"), id) if err != nil { h.renderErr(w, http.StatusNotFound, "Entry not found.") return } h.render(w, "admin-changelog-editor", adminChangelogData{ Entry: e, Categories: changelog.Categories, IsNew: false, }) } func (h *Handler) adminChangelogEditPost(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/admin/changelog/edit/") if err := r.ParseForm(); err != nil { h.renderErr(w, http.StatusBadRequest, "Bad form data.") return } e := changelog.Entry{ ID: id, Date: r.FormValue("date"), Title: strings.TrimSpace(r.FormValue("title")), Description: strings.TrimSpace(r.FormValue("description")), Category: r.FormValue("category"), } if e.Title == "" || e.Date == "" { h.render(w, "admin-changelog-editor", adminChangelogData{ Entry: &e, Categories: changelog.Categories, Error: "Date and title are required.", }) return } if err := changelog.Update(filepath.Join(h.dataDir, "changelog.json"), e); err != nil { h.render(w, "admin-changelog-editor", adminChangelogData{ Entry: &e, Categories: changelog.Categories, Error: "Failed to save: " + err.Error(), }) return } http.Redirect(w, r, "/admin/changelog?flash=Entry+saved", http.StatusSeeOther) } func (h *Handler) adminChangelogDelete(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { h.renderErr(w, http.StatusMethodNotAllowed, "POST required.") return } id := strings.TrimPrefix(r.URL.Path, "/admin/changelog/delete/") if err := changelog.Delete(filepath.Join(h.dataDir, "changelog.json"), id); err != nil { h.renderErr(w, http.StatusInternalServerError, "Delete failed: "+err.Error()) return } http.Redirect(w, r, "/admin/changelog?flash=Entry+deleted", http.StatusSeeOther) } // --- Outage tagger --- type adminOutagesData struct { Issues []gitea.Issue Label string Flash string Error string } func (h *Handler) adminOutagesGet(w http.ResponseWriter, r *http.Request) { if h.gitea == nil || h.giteaOwner == "" || h.giteaRepo == "" { h.render(w, "admin-outages", adminOutagesData{Error: "Gitea not configured. Set GITEA_URL, GITEA_TOKEN, GITEA_OWNER, and GITEA_REPO."}) return } issues, err := h.gitea.ListOpenIssues(h.giteaOwner, h.giteaRepo) if err != nil { h.render(w, "admin-outages", adminOutagesData{Error: "Could not load issues: " + err.Error()}) return } h.render(w, "admin-outages", adminOutagesData{ Issues: issues, Label: h.giteaLabel, Flash: r.URL.Query().Get("flash"), }) } func (h *Handler) adminOutagesPost(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { h.renderErr(w, http.StatusBadRequest, "Bad form data.") return } if h.gitea == nil || h.giteaOwner == "" || h.giteaRepo == "" { h.renderErr(w, http.StatusServiceUnavailable, "Gitea not configured.") return } action := r.FormValue("action") // "add" or "remove" issueNum, err := strconv.Atoi(r.FormValue("issue")) if err != nil || issueNum <= 0 { h.renderErr(w, http.StatusBadRequest, "Invalid issue number.") return } label, err := h.gitea.GetOrCreateLabel(h.giteaOwner, h.giteaRepo, h.giteaLabel, "#e11d48") if err != nil { h.renderErr(w, http.StatusInternalServerError, "Label error: "+err.Error()) return } switch action { case "add": if err := h.gitea.AddLabel(h.giteaOwner, h.giteaRepo, issueNum, label.ID); err != nil { h.renderErr(w, http.StatusInternalServerError, "Could not add label: "+err.Error()) return } case "remove": if err := h.gitea.RemoveLabel(h.giteaOwner, h.giteaRepo, issueNum, label.ID); err != nil { h.renderErr(w, http.StatusInternalServerError, "Could not remove label: "+err.Error()) return } default: h.renderErr(w, http.StatusBadRequest, "Unknown action.") return } http.Redirect(w, r, "/admin/outages?flash=Updated+#"+strconv.Itoa(issueNum), http.StatusSeeOther) } // sanitizeSlug ensures a slug is filesystem-safe. func sanitizeSlug(s string) string { s = strings.ToLower(strings.TrimSpace(s)) var b strings.Builder for _, r := range s { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { b.WriteRune(r) } else if r == ' ' { b.WriteRune('-') } } return b.String() }