package controllers import ( "errors" "net/http" "slices" "strconv" "strings" "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/wire" "gorm.io/gorm" ) // APITokenStore mints one shared personal API token for the authenticated // user. The clear-text token appears only in this response. func APITokenStore(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { db, user, ok := apiTokenDeps(w, r, app) if !ok { return } fields, err := readFields(r) if err != nil { writeOpaque500(w) return } name := strings.TrimSpace(strField(fields, "name")) if name == "" || len(name) > 255 { writeValidation(w, map[string][]string{"name": {"The name field is required and may not be longer than 255 characters."}}) return } scopes, err := apiTokenScopes(fields["scopes"]) if err != nil { writeValidation(w, map[string][]string{"scopes": {err.Error()}}) return } var expiresAt *time.Time if raw := strings.TrimSpace(strField(fields, "expires_at")); raw != "" { parsed, err := time.Parse(time.RFC3339, raw) if err != nil { writeValidation(w, map[string][]string{"expires_at": {"The expires_at field must be a valid RFC3339 date."}}) return } expiresAt = &parsed } prefix, err := classes.APITokenPrefix(app) if err != nil { writeOpaque500(w) return } secret, token, err := classes.MintAPIToken(prefix, user.ID, name, scopes, expiresAt, nil) if err != nil || db.WithContext(r.Context()).Create(token).Error != nil { writeOpaque500(w) return } writeJSON(w, http.StatusCreated, map[string]any{"token": secret, "meta": apiTokenJSON(token)}) } } // APITokenIndex lists the caller's live personal tokens without hashes. func APITokenIndex(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { db, user, ok := apiTokenDeps(w, r, app) if !ok { return } var rows []models.APIToken if err := db.WithContext(r.Context()). Where("user_id = ? AND oauth_client_id IS NULL AND revoked_at IS NULL", user.ID). Order("created_at DESC").Find(&rows).Error; err != nil { writeOpaque500(w) return } data := make([]map[string]any, 0, len(rows)) for i := range rows { data = append(data, apiTokenJSON(&rows[i])) } writeJSON(w, http.StatusOK, map[string]any{"data": wire.Slice(data)}) } } // APITokenDestroy revokes one live personal token owned by the caller. func APITokenDestroy(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { db, user, ok := apiTokenDeps(w, r, app) if !ok { return } id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) if err != nil || id == 0 { writeJSON(w, http.StatusNotFound, map[string]any{"error": "Token not found"}) return } var token models.APIToken err = db.WithContext(r.Context()).Where("user_id = ? AND oauth_client_id IS NULL", user.ID).First(&token, id).Error if errors.Is(err, gorm.ErrRecordNotFound) { writeJSON(w, http.StatusNotFound, map[string]any{"error": "Token not found"}) return } if err != nil || classes.RevokeAPIToken(db.WithContext(r.Context()), &token) != nil { writeOpaque500(w) return } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"revoked": true}}) } } func apiTokenDeps(w http.ResponseWriter, r *http.Request, app *backpack.App) (*gorm.DB, *bouncer.Principal, bool) { if app == nil { writeOpaque500(w) return nil, nil, false } db, ok := app.Lookup[*gorm.DB]() if !ok || db == nil { writeOpaque500(w) return nil, nil, false } user, ok := bouncer.User(r.Context()) if !ok || user == nil || user.ID == 0 { writeUnauthorized(w) return nil, nil, false } return db, user, true } func apiTokenScopes(raw any) ([]string, error) { if raw == nil { return nil, nil } values, ok := raw.([]any) if !ok { return nil, errors.New("The scopes field must be an array.") } out := make([]string, 0, len(values)) for _, rawScope := range values { scope, ok := rawScope.(string) if !ok || !slices.Contains(classes.AllowedAPITokenScopes, scope) { return nil, errors.New("The selected scope is invalid.") } out = append(out, scope) } return out, nil } func apiTokenJSON(token *models.APIToken) map[string]any { name := "" if token.Name != nil { name = *token.Name } return map[string]any{ "id": token.ID, "name": name, "scopes": wire.Slice(token.Scopes.Get()), "last_used_at": token.LastUsedAt, "last_used_ip": token.LastUsedIP, "expires_at": token.ExpiresAt, "revoked_at": token.RevokedAt, "created_at": token.CreatedAt, } }