- 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>
131 lines
4.0 KiB
Go
131 lines
4.0 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/wire"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
errAuthenticationRequired = "Authentication required"
|
|
errInsufficientPerms = "Insufficient permissions"
|
|
errPublishPermission = "You do not have permission to publish posts"
|
|
errEditPermission = "You do not have permission to edit this post"
|
|
errPostNotFound = "Post not found"
|
|
errImageNotFound = "Image not found"
|
|
errValidationFailed = "Validation failed"
|
|
msgPostDeleted = "Post deleted"
|
|
msgImageRemoved = "Image removed"
|
|
|
|
permAccessPosts = "golem15.journal.access_posts"
|
|
permAccessOtherPosts = "golem15.journal.access_other_posts"
|
|
permAccessPublish = "golem15.journal.access_publish"
|
|
|
|
backendJWTBlacklistTable = "backend_jwt_blacklist"
|
|
)
|
|
|
|
func writeAPIError(w http.ResponseWriter, status int, message string) {
|
|
wire.WriteJSON(w, status, map[string]any{"error": message})
|
|
}
|
|
|
|
func writeValidation(w http.ResponseWriter, errs map[string][]string) {
|
|
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
|
"error": errValidationFailed,
|
|
"errors": errs,
|
|
})
|
|
}
|
|
|
|
func writeAuthRequired(w http.ResponseWriter, _ error) {
|
|
writeAPIError(w, http.StatusUnauthorized, errAuthenticationRequired)
|
|
}
|
|
|
|
func hasBearer(r *http.Request) bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
h := strings.TrimSpace(r.Header.Get("Authorization"))
|
|
token, ok := strings.CutPrefix(h, "Bearer ")
|
|
return ok && strings.TrimSpace(token) != ""
|
|
}
|
|
|
|
func newBackendGuard(app *backpack.App, write func(http.ResponseWriter, error)) bouncer.Guard {
|
|
secret := ""
|
|
if app != nil && app.Config != nil {
|
|
secret = strings.TrimSpace(app.Config.String("admin.jwt.secret"))
|
|
}
|
|
var gdb *gorm.DB
|
|
var sqlDB *sql.DB
|
|
var reg *cabana.Registry
|
|
if app != nil {
|
|
gdb, _ = app.Lookup[*gorm.DB]()
|
|
sqlDB, _ = app.Lookup[*sql.DB]()
|
|
reg, _ = app.Lookup[*cabana.Registry]()
|
|
if sqlDB == nil && gdb != nil {
|
|
if db, err := gdb.DB(); err == nil {
|
|
sqlDB = db
|
|
}
|
|
}
|
|
}
|
|
var bl bouncer.BlacklistStore
|
|
if sqlDB != nil {
|
|
bl = bouncer.NewPostgresBlacklist(sqlDB, backendJWTBlacklistTable)
|
|
}
|
|
return bouncer.NewBackendJWTGuard(secret, cabana.BackendUsers{DB: gdb, Registry: reg}, bl, write)
|
|
}
|
|
|
|
// optionalBackendPrincipal verifies a backend JWT only when Authorization
|
|
// Bearer is present. Failure is anonymous: public GET must not 401.
|
|
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
|
|
if r == nil || !hasBearer(r) {
|
|
return r
|
|
}
|
|
pr, err := newBackendGuard(app, nil).Authenticate(r)
|
|
if err != nil || pr == nil {
|
|
return r
|
|
}
|
|
return r.WithContext(bouncer.WithUser(r.Context(), pr))
|
|
}
|
|
|
|
// requireBackendPrincipal authenticates a backend-audience JWT and writes the
|
|
// PHP {error:"Authentication required"} body when the token is missing or
|
|
// invalid (D-15). It does not use cabana's admin unauthenticated envelope.
|
|
func requireBackendPrincipal(app *backpack.App, w http.ResponseWriter, r *http.Request) (*http.Request, *bouncer.Principal, bool) {
|
|
pr, err := newBackendGuard(app, writeAuthRequired).Authenticate(r)
|
|
if err != nil || pr == nil {
|
|
writeAuthRequired(w, err)
|
|
return r, nil, false
|
|
}
|
|
r = r.WithContext(bouncer.WithUser(r.Context(), pr))
|
|
return r, pr, true
|
|
}
|
|
|
|
func isEditor(pr *bouncer.Principal) bool {
|
|
return cabana.Allows(pr, []string{permAccessPosts})
|
|
}
|
|
|
|
func requireAccessPosts(w http.ResponseWriter, pr *bouncer.Principal) bool {
|
|
if !cabana.Allows(pr, []string{permAccessPosts}) {
|
|
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func gdb(app *backpack.App) (*gorm.DB, error) {
|
|
if app == nil {
|
|
return nil, errors.New("journal api: application is not configured")
|
|
}
|
|
db, ok := app.Lookup[*gorm.DB]()
|
|
if !ok || db == nil {
|
|
return nil, errors.New("journal api: database is not configured")
|
|
}
|
|
return db, nil
|
|
}
|