Files
sm-user-plugin/controllers/api_tokens.go
2026-10-04 22:30:20 +02:00

162 lines
4.7 KiB
Go

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,
}
}