642 lines
18 KiB
Go
642 lines
18 KiB
Go
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, "<div class='preview-body'>%s</div>", 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":""}`, 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: "",
|
|
})
|
|
}
|
|
}
|
|
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()
|
|
}
|