- journal.Plugin.MCP: list_posts, show_post, list_categories, list_tags, create_post, update_post, delete_post and the draft_post prompt, guard backend, listed with golem15.journal.access_posts; update fields are optional so only sent keys reach the route - optionalBackendPrincipal honours the MCP dispatch marker (no bearer short-circuit; anonymous reads unchanged) - require summercms v0.4.4 - TestJournalMCPSmoke, TestOptionalBackendPrincipalDispatch
118 lines
3.7 KiB
Go
118 lines
3.7 KiB
Go
package api
|
|
|
|
import (
|
|
"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"
|
|
|
|
journalCover = "golem15.journal.*"
|
|
)
|
|
|
|
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) != ""
|
|
}
|
|
|
|
// resolveError maps cabana.ResolveBackend errors to the PHP Journal API bodies.
|
|
func resolveError(w http.ResponseWriter, err error) {
|
|
if errors.Is(err, cabana.ErrTokenScope) {
|
|
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
|
|
return
|
|
}
|
|
writeAuthRequired(w, err)
|
|
}
|
|
|
|
// optionalBackendPrincipal verifies a backend JWT or personal API token when
|
|
// one is presented, and honours the in-process dispatch marker the MCP
|
|
// endpoint sets (no Authorization header then, D-17). Failure is anonymous:
|
|
// public GET must not 401 (ResolveBackend answers ErrUnauthenticated for a
|
|
// request without a bearer). Uses cabana.ResolveBackend with cover
|
|
// golem15.journal.*.
|
|
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
|
|
if r == nil {
|
|
return r
|
|
}
|
|
out, _, err := cabana.ResolveBackend(app, r, journalCover)
|
|
if err != nil || out == nil {
|
|
return r
|
|
}
|
|
return out
|
|
}
|
|
|
|
// requireBackendPrincipal authenticates a backend JWT or personal API token
|
|
// and writes the PHP {error:"Authentication required"} or
|
|
// {error:"Insufficient permissions"} body (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) {
|
|
out, pr, err := cabana.ResolveBackend(app, r, journalCover)
|
|
if err != nil || pr == nil {
|
|
resolveError(w, err)
|
|
return r, nil, false
|
|
}
|
|
return out, 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
|
|
}
|