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:
Jakub Zych
2026-10-06 18:59:12 +02:00
parent f2e5b3d072
commit 3a1dda45ec
4 changed files with 1351 additions and 22 deletions

View File

@@ -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 {