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 }