Files
summercms/cabana/http.go
Jakub Zych aab4398ce4 feat(09-04): compile ordered Winter list schemas
- Typed columns, actions, default sort, search term, and page sizes
- Omitted sortable defaults to true and empty collections marshal as arrays
- Unknown keys, bad defaults, and path escape fail before routes are served
2026-09-24 18:46:53 +02:00

290 lines
7.6 KiB
Go

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) {
schema := cc.List
if schema == nil {
schema = &ListSchema{}
}
WriteData(w, http.StatusOK, schema, 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)
}