feat(15-03): enforce JOURNAL-005 draft 404 and backend-Bearer writes
- Show unpublished or future posts as 404 without a data key unless owner or access_other_posts
- POST/PUT/DELETE posts and featured-images require a backend-audience JWT and PHP {error} strings
- Anonymous GET categories and tags return the PHP list keys; frontend-audience tokens cannot write
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,7 +14,9 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -103,25 +105,79 @@ func Index(app *backpack.App) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Show is GET /_journal/api/v1/posts/{slug}. Task 2 fills JOURNAL-005.
|
||||
// Show is GET /_journal/api/v1/posts/{slug}. Numeric slugs fall back to id
|
||||
// (PHP 192-201). Unpublished or future published_at is 404 with no data key
|
||||
// unless the caller is the owner or holds access_other_posts (JOURNAL-005).
|
||||
func Show(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r = optionalBackendPrincipal(app, r)
|
||||
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
db = db.WithContext(r.Context())
|
||||
slug := strings.TrimSpace(r.PathValue("slug"))
|
||||
post, err := findPostBySlugOrID(db, slug)
|
||||
if err != nil || post == nil {
|
||||
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
||||
return
|
||||
}
|
||||
pr, _ := bouncer.User(r.Context())
|
||||
if !postIsPublished(post) && !canViewDraft(post, pr) {
|
||||
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
||||
return
|
||||
}
|
||||
if err := loadPostAssocs(db, post); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
payload := serializePost(post, db)
|
||||
payload["previous_post"] = serializeSibling(previousPost(db, post))
|
||||
payload["next_post"] = serializeSibling(nextPost(db, post))
|
||||
related := relatedPosts(db, post, isEditor(pr))
|
||||
rows := make([]map[string]any, 0, len(related))
|
||||
for i := range related {
|
||||
rows = append(rows, serializePost(&related[i], db))
|
||||
}
|
||||
payload["related_posts"] = rows
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": payload})
|
||||
}
|
||||
}
|
||||
|
||||
// Categories is GET /_journal/api/v1/categories. Task 2 fills the PHP list.
|
||||
// Categories is GET /_journal/api/v1/categories. Empty categories are omitted
|
||||
// unless ?include_empty=1.
|
||||
func Categories(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": []any{}})
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
includeEmpty := queryBool(r.URL.Query().Get("include_empty"))
|
||||
data, err := serializeCategoryTree(db.WithContext(r.Context()), includeEmpty)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": data})
|
||||
}
|
||||
}
|
||||
|
||||
// Tags is GET /_journal/api/v1/tags. Task 2 fills the PHP list.
|
||||
// Tags is GET /_journal/api/v1/tags.
|
||||
func Tags(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": []any{}})
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
data, err := serializeTagList(db.WithContext(r.Context()))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": data})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,53 +190,248 @@ func RSS(app *backpack.App) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Store is POST /_journal/api/v1/posts. Task 2 fills field-by-field create.
|
||||
// Store is POST /_journal/api/v1/posts. Field-by-field create; never lagoon.Fill.
|
||||
func Store(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, _, ok := requireBackendPrincipal(app, w, r); !ok {
|
||||
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
|
||||
if !requireAccessPosts(w, pr) {
|
||||
return
|
||||
}
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
body, err := readJSONObject(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
||||
return
|
||||
}
|
||||
post, verrs := buildNewPost(r, db.WithContext(r.Context()), pr, body)
|
||||
if verrs != nil {
|
||||
writeValidation(w, verrs)
|
||||
return
|
||||
}
|
||||
if publishedFlag(body) && !cabana.Allows(pr, []string{permAccessPublish}) {
|
||||
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
||||
return
|
||||
}
|
||||
if err := db.WithContext(r.Context()).Create(post).Error; err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
||||
}
|
||||
}
|
||||
|
||||
// Update is PUT /_journal/api/v1/posts/{id}. Task 2 fills field-by-field update.
|
||||
// Update is PUT /_journal/api/v1/posts/{id}.
|
||||
func Update(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, _, ok := requireBackendPrincipal(app, w, r); !ok {
|
||||
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
|
||||
if !requireAccessPosts(w, pr) {
|
||||
return
|
||||
}
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
post, err := findPostByID(db.WithContext(r.Context()), r.PathValue("id"))
|
||||
if err != nil || post == nil {
|
||||
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
||||
return
|
||||
}
|
||||
if !post.CanEdit(pr) {
|
||||
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
||||
return
|
||||
}
|
||||
body, err := readJSONObject(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
||||
return
|
||||
}
|
||||
if verrs := applyPostUpdate(r, db.WithContext(r.Context()), post, body); verrs != nil {
|
||||
writeValidation(w, verrs)
|
||||
return
|
||||
}
|
||||
if _, has := body["published"]; has && asBool(body["published"]) && !cabana.Allows(pr, []string{permAccessPublish}) {
|
||||
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
||||
return
|
||||
}
|
||||
if err := db.WithContext(r.Context()).Save(post).Error; err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy is DELETE /_journal/api/v1/posts/{id}. Task 2 fills canEdit delete.
|
||||
// Destroy is DELETE /_journal/api/v1/posts/{id}.
|
||||
func Destroy(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, _, ok := requireBackendPrincipal(app, w, r); !ok {
|
||||
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
|
||||
if !requireAccessPosts(w, pr) {
|
||||
return
|
||||
}
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
post, err := findPostByID(db.WithContext(r.Context()), r.PathValue("id"))
|
||||
if err != nil || post == nil {
|
||||
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
||||
return
|
||||
}
|
||||
if !post.CanEdit(pr) {
|
||||
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
||||
return
|
||||
}
|
||||
if err := db.WithContext(r.Context()).Delete(post).Error; err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgPostDeleted})
|
||||
}
|
||||
}
|
||||
|
||||
// UploadFeaturedImage is POST /_journal/api/v1/posts/{id}/featured-images.
|
||||
func UploadFeaturedImage(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, _, ok := requireBackendPrincipal(app, w, r); !ok {
|
||||
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
post, err := findPostByID(db.WithContext(r.Context()), r.PathValue("id"))
|
||||
if err != nil || post == nil {
|
||||
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
||||
return
|
||||
}
|
||||
if !post.CanEdit(pr) {
|
||||
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeValidation(w, map[string][]string{"file": {"The file field is required."}})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
||||
if !ok || bucket == nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
stored, err := attach.Store(r.Context(), db.WithContext(r.Context()), bucket, attach.Upload{
|
||||
FileName: header.Filename,
|
||||
Body: file,
|
||||
Public: true,
|
||||
}, attach.Limits{
|
||||
MaxBytes: 10240 << 10,
|
||||
Image: true,
|
||||
MIMETypes: []string{"image/jpeg", "image/png", "image/gif", "image/webp"},
|
||||
})
|
||||
if err != nil {
|
||||
writeValidation(w, map[string][]string{"file": {err.Error()}})
|
||||
return
|
||||
}
|
||||
idStr := strconv.FormatUint(uint64(post.ID), 10)
|
||||
if err := db.WithContext(r.Context()).Model(stored).Updates(map[string]any{
|
||||
"attachment_type": post.MorphName(),
|
||||
"attachment_id": idStr,
|
||||
"field": "featured_images",
|
||||
}).Error; err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
stored.AttachmentType = post.MorphName()
|
||||
stored.AttachmentID = idStr
|
||||
stored.Field = "featured_images"
|
||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{
|
||||
"data": map[string]any{
|
||||
"id": stored.ID,
|
||||
"url": stored.URL(),
|
||||
"file_name": stored.FileName,
|
||||
"file_size": stored.FileSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteFeaturedImage is DELETE /_journal/api/v1/posts/{id}/featured-images/{fileId}.
|
||||
func DeleteFeaturedImage(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, _, ok := requireBackendPrincipal(app, w, r); !ok {
|
||||
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
post, err := findPostByID(db.WithContext(r.Context()), r.PathValue("id"))
|
||||
if err != nil || post == nil {
|
||||
writeAPIError(w, http.StatusNotFound, errPostNotFound)
|
||||
return
|
||||
}
|
||||
if !post.CanEdit(pr) {
|
||||
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
||||
return
|
||||
}
|
||||
fileID, err := strconv.ParseUint(strings.TrimSpace(r.PathValue("fileId")), 10, 64)
|
||||
if err != nil || fileID == 0 {
|
||||
writeAPIError(w, http.StatusNotFound, errImageNotFound)
|
||||
return
|
||||
}
|
||||
var file attach.File
|
||||
err = db.WithContext(r.Context()).Where(
|
||||
"id = ? AND attachment_type = ? AND attachment_id = ? AND field = ?",
|
||||
fileID, post.MorphName(), strconv.FormatUint(uint64(post.ID), 10), "featured_images",
|
||||
).Take(&file).Error
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusNotFound, errImageNotFound)
|
||||
return
|
||||
}
|
||||
keys := attach.BlobKeys(file)
|
||||
if err := db.WithContext(r.Context()).Delete(&file).Error; err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
if bucket, ok := app.Lookup[*blob.Bucket](); ok && bucket != nil {
|
||||
_ = attach.DeleteKeys(r.Context(), bucket, keys)
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgImageRemoved})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +563,7 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
|
||||
"tags": tags,
|
||||
"featured_images": featured,
|
||||
"featured_image_url": featuredURL,
|
||||
"translations": []any{},
|
||||
"translations": translationsOf(db, p),
|
||||
"created_at": carbonTime(p.CreatedAt),
|
||||
"updated_at": carbonTime(p.UpdatedAt),
|
||||
}
|
||||
@@ -331,8 +582,27 @@ func serializeAuthor(user *cabana.BackendUser) any {
|
||||
}
|
||||
}
|
||||
|
||||
func featuredImages(_ *gorm.DB, _ *models.Post) []map[string]any {
|
||||
return []map[string]any{}
|
||||
func featuredImages(db *gorm.DB, p *models.Post) []map[string]any {
|
||||
if db == nil || p == nil || p.ID == 0 {
|
||||
return []map[string]any{}
|
||||
}
|
||||
var files []attach.File
|
||||
if err := db.Where(
|
||||
"attachment_type = ? AND attachment_id = ? AND field = ?",
|
||||
p.MorphName(), strconv.FormatUint(uint64(p.ID), 10), "featured_images",
|
||||
).Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(files))
|
||||
for i := range files {
|
||||
out = append(out, map[string]any{
|
||||
"id": files[i].ID,
|
||||
"url": files[i].URL(),
|
||||
"file_name": files[i].FileName,
|
||||
"file_size": files[i].FileSize,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func jsonField(raw *string) any {
|
||||
|
||||
Reference in New Issue
Block a user