some extra make target
This commit is contained in:
parent
dac02c61c3
commit
e454940dfa
2 changed files with 182 additions and 0 deletions
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue