diff --git a/internal/service/content.go b/internal/service/content.go index e69e2b4..6c68fa6 100644 --- a/internal/service/content.go +++ b/internal/service/content.go @@ -62,6 +62,9 @@ type IContentService interface { ListBadges(context.Context) ([]Badge, error) Get(context.Context, string) (Content, error) Update(context.Context, Content) (Content, error) + Create(context.Context, Content) (Content, error) + Delete(context.Context, string) error + Reset(context.Context) error } // ContentService keeps the demo content in memory behind a mutex. When file is @@ -279,6 +282,99 @@ func (s *ContentService) update(c Content, actor string) (Content, error) { return cloneContent(c), nil } +func (s *ContentService) Create(_ context.Context, c Content) (Content, error) { + c.Title = strings.TrimSpace(c.Title) + c.Body = strings.TrimSpace(c.Body) + if c.Title == "" || c.Body == "" { + return Content{}, errors.New("invalid content") + } + c.Status = normalizeStatus(c.Status) + if c.Slug == "" { + c.Slug = generateSlug(c.Title) + } + c.Slug = strings.ToLower(strings.TrimSpace(c.Slug)) + if !validSlug(c.Slug) { + return Content{}, errors.New("invalid slug") + } + c.Category = normalizeCategory(c.Category) + c.Badges = normalizeBadges(c.Badges) + + now := time.Now() + c.CreatedAt = now + c.UpdatedAt = now + if c.Status == "published" { + if c.PublishedAt == nil { + t := now + c.PublishedAt = &t + } + } else { + c.PublishedAt = nil + } + c.Published = (c.Status == "published") + + s.mu.Lock() + defer s.mu.Unlock() + if s.nextID == 0 { + max := uint(0) + for _, it := range s.items { + if it.ID > max { + max = it.ID + } + } + s.nextID = max + 1 + } + c.ID = s.nextID + s.nextID++ + for _, it := range s.items { + if it.Slug == c.Slug { + s.mu.Unlock() + return Content{}, errors.New("slug already exists") + } + } + created := cloneContent(c) + s.items = append(s.items, created) + sortContent(s.items) + snapshot, _ := json.Marshal(created) + s.history = append(s.history, contentRevision{ContentID: created.ID, Operation: "created", CreatedAt: created.UpdatedAt, Snapshot: string(snapshot)}) + if err := s.persistLocked(); err != nil { + return Content{}, err + } + return cloneContent(created), nil +} + +func (s *ContentService) Delete(_ context.Context, slug string) error { + slug = strings.ToLower(strings.TrimSpace(slug)) + if !validSlug(slug) { + return errors.New("invalid slug") + } + s.mu.Lock() + defer s.mu.Unlock() + for i, it := range s.items { + if it.Slug == slug { + s.items = append(s.items[:i], s.items[i+1:]...) + snapshot, _ := json.Marshal(it) + s.history = append(s.history, contentRevision{ContentID: it.ID, Operation: "deleted", CreatedAt: time.Now(), Snapshot: string(snapshot)}) + if err := s.persistLocked(); err != nil { + return err + } + return nil + } + } + return errors.New("content not found") +} + +func (s *ContentService) Reset(_ context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + s.items = nil + s.nextID = 1 + s.history = nil + if err := s.persistLocked(); err != nil { + return err + } + return nil +} + // storedContent is the on-disk shape of an article. Identifiers are positional // so the JSON file stays readable and diff-friendly. type storedContent struct { @@ -469,6 +565,40 @@ func categoryDisplay(slug string) string { return strings.ToUpper(slug[:1]) + slug[1:] } +func generateSlug(title string) string { + title = strings.TrimSpace(strings.ToLower(title)) + if title == "" { + return "post" + } + // simple slugification + parts := strings.Fields(title) + var out strings.Builder + for i, p := range parts { + cleaned := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + return r + } + return '-' + }, p) + cleaned = strings.Trim(cleaned, "-") + if cleaned == "" { + continue + } + if i > 0 { + out.WriteByte('-') + } + out.WriteString(cleaned) + } + if out.Len() == 0 { + return "post" + } + res := out.String() + if len(res) > 60 { + res = res[:60] + } + return strings.Trim(res, "-") +} + var slugRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) func validSlug(s string) bool { return len(s) > 0 && len(s) <= 200 && slugRE.MatchString(s) } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index fa0fd66..c13c4b0 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -52,6 +52,7 @@ func (h *Handler) Routes(origins string) http.Handler { mux.HandleFunc("/api/content/categories", h.contentCategories) mux.HandleFunc("/api/content/badges", h.contentBadges) mux.HandleFunc("/api/content/", h.contentDetail) + mux.HandleFunc("/api/admin/reset", h.adminReset) mux.HandleFunc("/", h.singlePageApp) return cors(mux, origins, h.sso.HeaderName()) } @@ -164,6 +165,29 @@ func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) { jsonWrite(w, http.StatusOK, map[string]any{"items": items, "limit": limit, "offset": offset}) } +func (h *Handler) contentCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonWrite(w, http.StatusMethodNotAllowed, nil) + return + } + _, authenticated := h.identity(r) + if !authenticated { + jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + var item service.Content + if !decode(r, &item) { + jsonWrite(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + created, err := h.content.Create(r.Context(), item) + if err != nil { + jsonWrite(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + jsonWrite(w, http.StatusCreated, created) +} + func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { jsonWrite(w, http.StatusMethodNotAllowed, nil) @@ -227,6 +251,17 @@ func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) { } jsonWrite(w, http.StatusOK, saved) + case http.MethodDelete: + _, authenticated := h.identity(r) + if !authenticated { + jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + if err := h.content.Delete(r.Context(), slug); err != nil { + jsonWrite(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + jsonWrite(w, http.StatusNoContent, nil) default: jsonWrite(w, http.StatusMethodNotAllowed, nil) } @@ -251,6 +286,23 @@ func (h *Handler) identity(r *http.Request) (string, bool) { return "", false } +func (h *Handler) adminReset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonWrite(w, http.StatusMethodNotAllowed, nil) + return + } + _, authenticated := h.identity(r) + if !authenticated { + jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + if err := h.content.Reset(r.Context()); err != nil { + jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + jsonWrite(w, http.StatusOK, map[string]string{"status": "reset"}) +} + // browserUser returns the Go Loose user carried by the request session, if any. func (h *Handler) browserUser(r *http.Request) (goloose.User, bool) { if h.browser == nil {