feat(15-03): serve anonymous GET /_journal/api/v1/posts as a published list
- Public and write groups under /_journal/api/v1 with journal-public-api and journal-api buckets (Max 120)
- Index omits drafts and future published_at; per_page default 9 max 30
- Optional backend JWT on GET; missing Bearer on writes returns PHP {error:Authentication required}
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
120
controllers/api/auth.go
Normal file
120
controllers/api/auth.go
Normal file
@@ -0,0 +1,120 @@
|
||||
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"
|
||||
errValidationFailed = "Validation failed"
|
||||
|
||||
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 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
|
||||
}
|
||||
Reference in New Issue
Block a user