some extra make target

This commit is contained in:
Daniel Noulet 2026-10-03 12:25:10 +02:00
parent dac02c61c3
commit e454940dfa
2 changed files with 182 additions and 0 deletions

View file

@ -62,6 +62,9 @@ type IContentService interface {
ListBadges(context.Context) ([]Badge, error) ListBadges(context.Context) ([]Badge, error)
Get(context.Context, string) (Content, error) Get(context.Context, string) (Content, error)
Update(context.Context, Content) (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 // 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 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 // storedContent is the on-disk shape of an article. Identifiers are positional
// so the JSON file stays readable and diff-friendly. // so the JSON file stays readable and diff-friendly.
type storedContent struct { type storedContent struct {
@ -469,6 +565,40 @@ func categoryDisplay(slug string) string {
return strings.ToUpper(slug[:1]) + slug[1:] 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]+)*$`) 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) } func validSlug(s string) bool { return len(s) > 0 && len(s) <= 200 && slugRE.MatchString(s) }

View file

@ -52,6 +52,7 @@ func (h *Handler) Routes(origins string) http.Handler {
mux.HandleFunc("/api/content/categories", h.contentCategories) mux.HandleFunc("/api/content/categories", h.contentCategories)
mux.HandleFunc("/api/content/badges", h.contentBadges) mux.HandleFunc("/api/content/badges", h.contentBadges)
mux.HandleFunc("/api/content/", h.contentDetail) mux.HandleFunc("/api/content/", h.contentDetail)
mux.HandleFunc("/api/admin/reset", h.adminReset)
mux.HandleFunc("/", h.singlePageApp) mux.HandleFunc("/", h.singlePageApp)
return cors(mux, origins, h.sso.HeaderName()) 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}) 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) { func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
jsonWrite(w, http.StatusMethodNotAllowed, nil) jsonWrite(w, http.StatusMethodNotAllowed, nil)
@ -227,6 +251,17 @@ func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
} }
jsonWrite(w, http.StatusOK, saved) 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: default:
jsonWrite(w, http.StatusMethodNotAllowed, nil) jsonWrite(w, http.StatusMethodNotAllowed, nil)
} }
@ -251,6 +286,23 @@ func (h *Handler) identity(r *http.Request) (string, bool) {
return "", false 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. // browserUser returns the Go Loose user carried by the request session, if any.
func (h *Handler) browserUser(r *http.Request) (goloose.User, bool) { func (h *Handler) browserUser(r *http.Request) (goloose.User, bool) {
if h.browser == nil { if h.browser == nil {