package cabana import ( "context" "errors" "fmt" "net/http" "reflect" "strconv" "strings" "time" "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/bouncer" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" "gorm.io/gorm" ) // Routes is the raw admin API mounted by surf.BuildRouter. type Routes struct { Middleware pact.Middleware Mount func(r pact.Router) } type service struct { app *backpack.App reg *Registry secret string ttl time.Duration refreshTTL time.Duration grace time.Duration bcryptCost int loginMax int loginDecay int issuer string bl bouncer.BlacklistStore } // Activate compiles admin controllers and, when any exist, requires // admin.jwt.secret. No controllers means no admin routes and no secret check. func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { items, err := collectControllers(plugins) if err != nil { return nil, err } if len(items) == 0 { return nil, nil } secret, err := adminSecret(app) if err != nil { return nil, err } reg, err := compileRegistry(items) if err != nil { return nil, err } if app == nil { return nil, errors.New("cabana: app is nil") } guards, ok := app.Lookup[*bouncer.Registry]() if !ok || guards == nil { guards = bouncer.NewRegistry() if err := app.Publish(guards); err != nil { return nil, err } } bl := adminBlacklist(app) guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app}, bl, writeUnauthenticated) if _, err := guards.Middleware("backend"); err != nil { if err := guards.Register("summercms.cabana", "backend", guard); err != nil { return nil, err } } mw, err := guards.Middleware("backend") if err != nil { return nil, err } loginMax, loginDecay := adminLoginWindow(app) svc := &service{ app: app, reg: reg, secret: secret, ttl: adminTTL(app), refreshTTL: adminRefreshTTL(app), grace: adminGrace(app), bcryptCost: adminBcryptCost(app), loginMax: loginMax, loginDecay: loginDecay, issuer: adminIssuer(app), bl: bl, } return &Routes{Middleware: mw, Mount: svc.mount}, nil } func writeUnauthenticated(w http.ResponseWriter, _ error) { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) } type lazyBackendUsers struct { app *backpack.App } func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) { if p.app == nil { return nil, errors.New("cabana: database is not configured") } db, ok := p.app.Lookup[*gorm.DB]() if !ok || db == nil { return nil, errors.New("cabana: database is not configured") } return (BackendUsers{DB: db}).FindByID(ctx, id) } func (s *service) mount(r pact.Router) { throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay) r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) { g.Post("/login", s.login, throttle) g.Post("/refresh", s.refresh) }) r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) { g.Post("/auth/logout", s.logout) g.Get("/auth/me", s.me) g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema) constrainController(g) g.Get("/{vendor}/{plugin}/{controller}", s.list) constrainController(g) }) } func constrainController(g pact.Router) { g.Where("vendor", "[A-Za-z0-9_-]+") g.Where("plugin", "[A-Za-z0-9_-]+") g.Where("controller", "[A-Za-z0-9_-]+") } func (s *service) listSchema(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { cols := cc.List.Columns if cols == nil { cols = []ListColumn{} } WriteData(w, http.StatusOK, &ListSchema{ Title: cc.List.Title, RecordsPerPage: cc.List.RecordsPerPage, ShowSearch: cc.List.ShowSearch, Columns: cols, }, map[string]any{}) }) } func (s *service) list(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { db, err := s.db() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } rows, total, err := queryList(r.Context(), db, cc) if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } data := make([]map[string]any, 0, len(rows)) for _, row := range rows { data = append(data, projectRow(row, cc.List.Columns)) } per := cc.List.RecordsPerPage if per < 1 { per = 20 } last := int((total + int64(per) - 1) / int64(per)) if last < 1 { last = 1 } WriteData(w, http.StatusOK, data, map[string]any{ "page": 1, "per_page": per, "total": total, "last_page": last, }) }) } // protect runs after the backend guard. Controller lookup precedes permission // evaluation, and schema/SQL run only inside fn. func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*CompiledController)) { id := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller") cc, ok := s.reg.Get(id) if !ok { WriteError(w, http.StatusNotFound, "not_found", msgNotFound) return } principal, _ := bouncer.User(r.Context()) if principal == nil { WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated) return } if !Allows(principal, requiredOf(cc.Controller)) { s.logAuth(r, "denied", principal.ID) WriteError(w, http.StatusForbidden, "forbidden", msgForbidden) return } fn(cc) } func queryList(ctx context.Context, db *gorm.DB, cc *CompiledController) ([]any, int64, error) { src, ok := cc.Controller.(pact.AdminRecordSource) if !ok || src == nil { return nil, 0, errors.New("cabana: admin controller has no record source") } model := src.NewRecord() mt := reflect.TypeOf(model) if mt == nil || mt.Kind() != reflect.Pointer { return nil, 0, errors.New("cabana: admin model must be a pointer") } q := db.WithContext(ctx).Model(model) if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil { if next := ext.ListExtendQuery(ctx, q); next != nil { q = next } } var total int64 if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil { return nil, 0, err } per := 20 if cc.List != nil && cc.List.RecordsPerPage > 0 { per = cc.List.RecordsPerPage } slice := reflect.New(reflect.SliceOf(mt.Elem())) if err := q.Session(&gorm.Session{}).Limit(per).Find(slice.Interface()).Error; err != nil { return nil, 0, err } values := slice.Elem() out := make([]any, values.Len()) for i := 0; i < values.Len(); i++ { out[i] = values.Index(i).Addr().Interface() } return out, total, nil } func projectRow(row any, cols []ListColumn) map[string]any { v := reflect.ValueOf(row) for v.Kind() == reflect.Pointer { if v.IsNil() { return map[string]any{} } v = v.Elem() } out := make(map[string]any, len(cols)+1) if id := fieldByColumn(v, "id"); id.IsValid() && id.CanInterface() { out["id"] = id.Interface() } for _, col := range cols { field := fieldByColumn(v, col.Key) if !field.IsValid() || !field.CanInterface() { continue } out[col.Key] = field.Interface() } return out } func fieldByColumn(v reflect.Value, column string) reflect.Value { if v.Kind() != reflect.Struct { return reflect.Value{} } t := v.Type() for i := 0; i < t.NumField(); i++ { field := t.Field(i) if field.PkgPath != "" { continue } if gormColumn(field) == column || strings.EqualFold(field.Name, column) { return v.Field(i) } } return reflect.Value{} } func gormColumn(field reflect.StructField) string { for _, part := range strings.Split(field.Tag.Get("gorm"), ";") { part = strings.TrimSpace(part) if name, ok := strings.CutPrefix(part, "column:"); ok { return name } } return "" } func uitoa(id uint) string { return strconv.FormatUint(uint64(id), 10) }