Add authenticated content editing
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
fac7588d23
commit
fd2877da9e
15 changed files with 581 additions and 37 deletions
|
|
@ -6,6 +6,8 @@ type User struct {
|
|||
ID uint
|
||||
Email, Name string
|
||||
Provider, ProviderSubject string
|
||||
Role string
|
||||
Editor bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
type Admin struct {
|
||||
|
|
@ -20,3 +22,22 @@ type Session struct {
|
|||
AdminID *uint
|
||||
ExpiresAt, CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
ID uint
|
||||
Slug, Title, Summary, Body string
|
||||
Status string
|
||||
Published bool
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
CreatedBy, UpdatedBy uint
|
||||
}
|
||||
|
||||
type ContentHistory struct {
|
||||
ID uint
|
||||
ContentID uint
|
||||
Operation string
|
||||
ActorID *uint
|
||||
ActorAdminID *uint
|
||||
CreatedAt time.Time
|
||||
Snapshot string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@ package persistence
|
|||
import "github.com/example/cms/internal/domain"
|
||||
|
||||
func UserToDomain(m UserModel) domain.User {
|
||||
return domain.User{ID: m.ID, Email: m.Email, Name: m.Name, Provider: m.Provider, ProviderSubject: m.ProviderSubject, CreatedAt: m.CreatedAt}
|
||||
return domain.User{ID: m.ID, Email: m.Email, Name: m.Name, Provider: m.Provider, ProviderSubject: m.ProviderSubject, Role: m.Role, Editor: m.Editor, CreatedAt: m.CreatedAt}
|
||||
}
|
||||
func UserFromDomain(d domain.User) UserModel {
|
||||
return UserModel{ID: d.ID, Email: d.Email, Name: d.Name, Provider: d.Provider, ProviderSubject: d.ProviderSubject, CreatedAt: d.CreatedAt}
|
||||
return UserModel{ID: d.ID, Email: d.Email, Name: d.Name, Provider: d.Provider, ProviderSubject: d.ProviderSubject, Role: d.Role, Editor: d.Editor, CreatedAt: d.CreatedAt}
|
||||
}
|
||||
func ContentToDomain(m ContentModel) domain.Content {
|
||||
return domain.Content{ID: m.ID, Slug: m.Slug, Title: m.Title, Summary: m.Summary, Body: m.Body, Status: m.Status, Published: m.Published, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, CreatedBy: m.CreatedBy, UpdatedBy: m.UpdatedBy}
|
||||
}
|
||||
func ContentFromDomain(d domain.Content) ContentModel {
|
||||
return ContentModel{ID: d.ID, Slug: d.Slug, Title: d.Title, Summary: d.Summary, Body: d.Body, Status: d.Status, Published: d.Published, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, CreatedBy: d.CreatedBy, UpdatedBy: d.UpdatedBy}
|
||||
}
|
||||
func AdminToDomain(m AdminModel) domain.Admin {
|
||||
return domain.Admin{ID: m.ID, Email: m.Email, Name: m.Name, CreatedAt: m.CreatedAt}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,37 @@ type UserModel struct {
|
|||
Name string
|
||||
Provider string `gorm:"not null"`
|
||||
ProviderSubject string `gorm:"uniqueIndex;not null"`
|
||||
Role string `gorm:"not null;default:''"`
|
||||
Editor bool `gorm:"not null;default:false"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ContentModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Slug string `gorm:"uniqueIndex;not null"`
|
||||
Title string `gorm:"not null"`
|
||||
Summary string
|
||||
Body string `gorm:"not null"`
|
||||
Status string `gorm:"not null;default:'draft'"`
|
||||
Published bool `gorm:"not null;default:false"`
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
CreatedBy, UpdatedBy uint
|
||||
}
|
||||
|
||||
func (ContentModel) TableName() string { return "content" }
|
||||
|
||||
type ContentHistoryModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ContentID uint `gorm:"index;not null"`
|
||||
Operation string `gorm:"not null"`
|
||||
ActorID *uint
|
||||
ActorAdminID *uint
|
||||
CreatedAt time.Time
|
||||
Snapshot string `gorm:"type:jsonb;not null"`
|
||||
}
|
||||
|
||||
func (ContentHistoryModel) TableName() string { return "content_history" }
|
||||
|
||||
func (UserModel) TableName() string { return "users" }
|
||||
|
||||
type AdminModel struct {
|
||||
|
|
|
|||
|
|
@ -27,15 +27,21 @@ func (r *GORMUserRepository) FindOrCreateByIdentity(ctx context.Context, d domai
|
|||
}
|
||||
return persistence.UserToDomain(m), nil
|
||||
}
|
||||
func (r *GORMUserRepository) FindByID(ctx context.Context, id uint) (domain.User, error) {
|
||||
var m persistence.UserModel
|
||||
if err := r.db.WithContext(ctx).First(&m, id).Error; err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return persistence.UserToDomain(m), nil
|
||||
}
|
||||
|
||||
type GORMAdminRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewAdminRepository(db *gorm.DB) IAdminRepository { return &GORMAdminRepository{db} }
|
||||
func (r *GORMAdminRepository) FindByEmail(ctx context.Context, email string) (domain.Admin, error) {
|
||||
var m persistence.AdminModel
|
||||
e := r.db.WithContext(ctx).Where("email = ?", email).First(&m).Error
|
||||
if e != nil {
|
||||
return domain.Admin{}, e
|
||||
if err := r.db.WithContext(ctx).Where("email = ?", email).First(&m).Error; err != nil {
|
||||
return domain.Admin{}, err
|
||||
}
|
||||
return persistence.AdminToDomain(m), nil
|
||||
}
|
||||
|
|
@ -44,14 +50,40 @@ type GORMSessionRepository struct{ db *gorm.DB }
|
|||
|
||||
func NewSessionRepository(db *gorm.DB) ISessionRepository { return &GORMSessionRepository{db} }
|
||||
func (r *GORMSessionRepository) Create(ctx context.Context, d domain.Session) error {
|
||||
model := persistence.SessionFromDomain(d)
|
||||
return r.db.WithContext(ctx).Create(&model).Error
|
||||
return r.db.WithContext(ctx).Create(&persistence.SessionModel{ID: d.ID, Token: d.Token, UserID: d.UserID, AdminID: d.AdminID, ExpiresAt: d.ExpiresAt, CreatedAt: d.CreatedAt}).Error
|
||||
}
|
||||
func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain.Session, error) {
|
||||
var m persistence.SessionModel
|
||||
e := r.db.WithContext(ctx).Where("token = ? AND expires_at > ?", t, time.Now()).First(&m).Error
|
||||
if e != nil {
|
||||
return domain.Session{}, e
|
||||
if err := r.db.WithContext(ctx).Where("token = ? AND expires_at > ?", t, time.Now()).First(&m).Error; err != nil {
|
||||
return domain.Session{}, err
|
||||
}
|
||||
return persistence.SessionToDomain(m), nil
|
||||
}
|
||||
|
||||
type GORMContentRepository struct{ db *gorm.DB }
|
||||
|
||||
func NewContentRepository(db *gorm.DB) IContentRepository { return &GORMContentRepository{db} }
|
||||
func (r *GORMContentRepository) List(ctx context.Context, limit, offset int) ([]domain.Content, error) {
|
||||
var rows []persistence.ContentModel
|
||||
err := r.db.WithContext(ctx).Order("updated_at DESC, created_at DESC, id DESC").Limit(limit).Offset(offset).Find(&rows).Error
|
||||
out := make([]domain.Content, len(rows))
|
||||
for i := range rows {
|
||||
out[i] = persistence.ContentToDomain(rows[i])
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
func (r *GORMContentRepository) FindBySlug(ctx context.Context, slug string) (domain.Content, error) {
|
||||
var row persistence.ContentModel
|
||||
if err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&row).Error; err != nil {
|
||||
return domain.Content{}, err
|
||||
}
|
||||
return persistence.ContentToDomain(row), nil
|
||||
}
|
||||
func (r *GORMContentRepository) Update(ctx context.Context, d domain.Content, h domain.ContentHistory) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&persistence.ContentModel{}).Where("id = ?", d.ID).Updates(map[string]any{"slug": d.Slug, "title": d.Title, "summary": d.Summary, "body": d.Body, "status": d.Status, "published": d.Published, "updated_at": d.UpdatedAt, "updated_by": d.UpdatedBy}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&persistence.ContentHistoryModel{ContentID: h.ContentID, Operation: h.Operation, ActorID: h.ActorID, ActorAdminID: h.ActorAdminID, CreatedAt: h.CreatedAt, Snapshot: h.Snapshot}).Error
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import (
|
|||
type IUserRepository interface {
|
||||
FindOrCreateByIdentity(context.Context, domain.User) (domain.User, error)
|
||||
}
|
||||
type IUserLookup interface {
|
||||
FindByID(context.Context, uint) (domain.User, error)
|
||||
}
|
||||
type IAdminRepository interface {
|
||||
FindByEmail(context.Context, string) (domain.Admin, error)
|
||||
}
|
||||
|
|
@ -15,3 +18,8 @@ type ISessionRepository interface {
|
|||
Create(context.Context, domain.Session) error
|
||||
FindValid(context.Context, string) (domain.Session, error)
|
||||
}
|
||||
type IContentRepository interface {
|
||||
List(context.Context, int, int) ([]domain.Content, error)
|
||||
FindBySlug(context.Context, string) (domain.Content, error)
|
||||
Update(context.Context, domain.Content, domain.ContentHistory) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,22 @@ func (s *AuthService) AdminLogin(ctx context.Context, email string) (domain.Admi
|
|||
func (s *AuthService) AdminAuthenticate(ctx context.Context, t string) (domain.Session, error) {
|
||||
return s.adminSessions.FindValid(ctx, t)
|
||||
}
|
||||
func (s *AuthService) UserByID(ctx context.Context, id uint) (domain.User, error) {
|
||||
if lookup, ok := s.users.(repository.IUserLookup); ok {
|
||||
return lookup.FindByID(ctx, id)
|
||||
}
|
||||
return domain.User{}, errors.New("user lookup unavailable")
|
||||
}
|
||||
func (s *AuthService) CanEdit(ctx context.Context, session domain.Session) bool {
|
||||
if session.AdminID != nil {
|
||||
return true
|
||||
}
|
||||
if session.UserID == nil {
|
||||
return false
|
||||
}
|
||||
u, err := s.UserByID(ctx, *session.UserID)
|
||||
return err == nil && (u.Editor || u.Role == "editor" || u.Role == "admin")
|
||||
}
|
||||
func newToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, e := rand.Read(b); e != nil {
|
||||
|
|
|
|||
73
internal/service/content.go
Normal file
73
internal/service/content.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/example/cms/internal/domain"
|
||||
"github.com/example/cms/internal/repository"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IContentService interface {
|
||||
List(context.Context, int, int) ([]domain.Content, error)
|
||||
Get(context.Context, string) (domain.Content, error)
|
||||
Update(context.Context, domain.Content, domain.Session) (domain.Content, error)
|
||||
}
|
||||
type ContentService struct {
|
||||
repo repository.IContentRepository
|
||||
auth *AuthService
|
||||
}
|
||||
|
||||
func NewContentService(r repository.IContentRepository, a *AuthService) *ContentService {
|
||||
return &ContentService{repo: r, auth: a}
|
||||
}
|
||||
func (s *ContentService) List(ctx context.Context, limit, offset int) ([]domain.Content, error) {
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.repo.List(ctx, limit, offset)
|
||||
}
|
||||
func (s *ContentService) Get(ctx context.Context, slug string) (domain.Content, error) {
|
||||
if !validSlug(slug) {
|
||||
return domain.Content{}, errors.New("invalid slug")
|
||||
}
|
||||
return s.repo.FindBySlug(ctx, slug)
|
||||
}
|
||||
func (s *ContentService) Update(ctx context.Context, c domain.Content, session domain.Session) (domain.Content, error) {
|
||||
if s.auth == nil || !s.auth.CanEdit(ctx, session) {
|
||||
return domain.Content{}, errors.New("forbidden")
|
||||
}
|
||||
if !validSlug(c.Slug) || strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
|
||||
return domain.Content{}, errors.New("invalid content")
|
||||
}
|
||||
old, err := s.repo.FindBySlug(ctx, c.Slug)
|
||||
if err != nil {
|
||||
return domain.Content{}, err
|
||||
}
|
||||
c.ID = old.ID
|
||||
c.CreatedAt = old.CreatedAt
|
||||
c.CreatedBy = old.CreatedBy
|
||||
c.UpdatedAt = time.Now()
|
||||
if session.UserID != nil {
|
||||
c.UpdatedBy = *session.UserID
|
||||
}
|
||||
snapshot, _ := json.Marshal(c)
|
||||
h := domain.ContentHistory{ContentID: c.ID, Operation: "updated", CreatedAt: c.UpdatedAt, Snapshot: string(snapshot), ActorID: session.UserID, ActorAdminID: session.AdminID}
|
||||
if err = s.repo.Update(ctx, c, h); err != nil {
|
||||
return domain.Content{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
var slugRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
func validSlug(s string) bool { return len(s) <= 200 && slugRE.MatchString(s) }
|
||||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/example/cms/internal/domain"
|
||||
"github.com/example/cms/internal/identity"
|
||||
"github.com/example/cms/internal/service"
|
||||
"github.com/example/cms/internal/web/mapping"
|
||||
|
|
@ -13,13 +15,17 @@ import (
|
|||
)
|
||||
|
||||
type Handler struct {
|
||||
auth *service.AuthService
|
||||
google identity.GoogleProvider
|
||||
auth *service.AuthService
|
||||
google identity.GoogleProvider
|
||||
content service.IContentService
|
||||
}
|
||||
|
||||
func NewHandler(a *service.AuthService, google identity.GoogleProvider) *Handler {
|
||||
return &Handler{auth: a, google: google}
|
||||
}
|
||||
func NewHandlerWithContent(a *service.AuthService, google identity.GoogleProvider, content service.IContentService) *Handler {
|
||||
return &Handler{auth: a, google: google, content: content}
|
||||
}
|
||||
func (h *Handler) Routes(origins string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 200, map[string]string{"status": "ok"}) })
|
||||
|
|
@ -29,6 +35,9 @@ func (h *Handler) Routes(origins string) http.Handler {
|
|||
mux.HandleFunc("/api/auth/session", h.session)
|
||||
mux.HandleFunc("/admin/login", h.adminLogin)
|
||||
mux.HandleFunc("/api/test/protected", h.protected)
|
||||
mux.HandleFunc("/api/protected", h.protected)
|
||||
mux.HandleFunc("/api/content", h.contentList)
|
||||
mux.HandleFunc("/api/content/", h.contentDetail)
|
||||
return cors(mux, origins)
|
||||
}
|
||||
func (h *Handler) googleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -77,7 +86,12 @@ func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
|
|||
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
|
||||
return
|
||||
}
|
||||
jsonWrite(w, http.StatusOK, map[string]bool{"authenticated": true})
|
||||
u, err := h.auth.UserByID(r.Context(), *s.UserID)
|
||||
if err != nil {
|
||||
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
|
||||
return
|
||||
}
|
||||
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": true, "user": u, "canEdit": h.auth.CanEdit(r.Context(), s)})
|
||||
}
|
||||
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
|
|
@ -128,6 +142,87 @@ func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
jsonWrite(w, 200, map[string]string{"status": "authenticated"})
|
||||
}
|
||||
func (h *Handler) sessionForRequest(r *http.Request) (domain.Session, bool) {
|
||||
t := bearerOrCookie(r)
|
||||
if t == "" {
|
||||
return domain.Session{}, false
|
||||
}
|
||||
if s, e := h.auth.Authenticate(r.Context(), t); e == nil {
|
||||
return s, true
|
||||
}
|
||||
if s, e := h.auth.AdminAuthenticate(r.Context(), t); e == nil {
|
||||
return s, true
|
||||
}
|
||||
return domain.Session{}, false
|
||||
}
|
||||
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
|
||||
if h.content == nil {
|
||||
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
||||
return
|
||||
}
|
||||
if _, ok := h.sessionForRequest(r); !ok {
|
||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
if r.Method != "GET" {
|
||||
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
||||
return
|
||||
}
|
||||
limit, offset := 20, 0
|
||||
_, _ = fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit)
|
||||
_, _ = fmt.Sscanf(r.URL.Query().Get("offset"), "%d", &offset)
|
||||
items, e := h.content.List(r.Context(), limit, offset)
|
||||
if e != nil {
|
||||
jsonWrite(w, 500, map[string]string{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
jsonWrite(w, 200, map[string]any{"items": items, "limit": limit, "offset": offset})
|
||||
}
|
||||
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if h.content == nil {
|
||||
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
||||
return
|
||||
}
|
||||
slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
|
||||
if r.Method == "GET" {
|
||||
if _, ok := h.sessionForRequest(r); !ok {
|
||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
c, e := h.content.Get(r.Context(), slug)
|
||||
if e != nil {
|
||||
jsonWrite(w, 404, map[string]string{"error": "content not found"})
|
||||
return
|
||||
}
|
||||
jsonWrite(w, 200, c)
|
||||
return
|
||||
}
|
||||
if r.Method != "PUT" {
|
||||
jsonWrite(w, 405, nil)
|
||||
return
|
||||
}
|
||||
s, ok := h.sessionForRequest(r)
|
||||
if !ok {
|
||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
if !h.auth.CanEdit(r.Context(), s) {
|
||||
jsonWrite(w, 403, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
var c domain.Content
|
||||
if !decode(r, &c) {
|
||||
jsonWrite(w, 400, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
c.Slug = slug
|
||||
out, e := h.content.Update(r.Context(), c, s)
|
||||
if e != nil {
|
||||
jsonWrite(w, 400, map[string]string{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
jsonWrite(w, 200, out)
|
||||
}
|
||||
func bearerOrCookie(r *http.Request) string {
|
||||
header := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if header != "" {
|
||||
|
|
@ -155,7 +250,7 @@ func cors(next http.Handler, origin string) http.Handler {
|
|||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue