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)
|
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) }
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue