Files
MindSift/internal/storage/apikeys.go
T
sandrodachandClaude Opus 5.5 1e3ac9786f
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m33s
feat: edit API keys (name, rights); quick dump text above its buttons; inline note badges
- PATCH /keys/{id} renames a key or changes its scopes; the secret stays. Settings → API gets an
  edit button per key with the same switches as for a new key.
- Quick dump: a multiline text gets the full width, the buttons move to their own row below; the
  bar only goes back to one line once the text is clearly shorter (no flip-flopping).
- Notes: the "Erweitert"/"Unteraufgabe" badges sit right after the checkbox text.

v0.38.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:34:27 +01:00

154 lines
4.8 KiB
Go

package storage
import (
"errors"
"strings"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- API KEYS & WEBHOOKS ---
// Rights of an API key. They hold the same over every access: REST (curl, smart home) and the MCP
// endpoint for AI agents. The ids of the first three come from the time they only delivered data
// (webhooks) and stay for existing keys. Notes are never reachable with a key.
const (
ScopeTriage = "webhook:triage" // thoughts into the inbox
ScopeTask = "webhook:task" // create tasks
ScopeList = "webhook:list" // add items to lists
ScopeTasksRead = "tasks:read" // task lists and tasks
ScopeTasksWrite = "tasks:write" // create, change, complete and delete tasks
ScopeListsRead = "lists:read" // lists and their items
ScopeListsWrite = "lists:write" // add, change and check off items
)
// AllScopes in display order.
var AllScopes = []string{ScopeTriage, ScopeTask, ScopeList, ScopeTasksRead, ScopeTasksWrite, ScopeListsRead, ScopeListsWrite}
// NormalizeScopes keeps the known scopes in a fixed order; none at all means triage only.
func NormalizeScopes(scopes []string) []string {
want := map[string]bool{}
for _, sc := range scopes {
want[strings.ToLower(strings.TrimSpace(sc))] = true
}
out := []string{}
for _, sc := range AllScopes {
if want[sc] {
out = append(out, sc)
}
}
if len(out) == 0 {
out = []string{ScopeTriage}
}
return out
}
func (s *Store) CreateAPIKey(userID, name string, scopes []string) (*domain.APIKey, error) {
if strings.TrimSpace(name) == "" {
name = "Smarthome / Webhook Key"
}
rawKey, prefix, hash := auth.GenerateAPIKey()
k := &domain.APIKey{
ID: domain.NewID("key"),
UserID: userID,
Name: strings.TrimSpace(name),
KeyPrefix: prefix,
KeyHash: hash,
RawKey: rawKey,
Scopes: strings.Join(NormalizeScopes(scopes), ","),
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
k.ID, k.UserID, k.Name, k.KeyPrefix, k.KeyHash, k.Scopes, k.CreatedAt,
)
if err != nil {
return nil, err
}
return k, nil
}
func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at
FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
keys := make([]domain.APIKey, 0)
for rows.Next() {
var k domain.APIKey
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil {
keys = append(keys, k)
}
}
return keys, nil
}
// UpdateAPIKey changes name and/or rights of an own key; the secret itself never changes. nil keeps
// a value. Returns ErrAPIKeyNotFound for keys of other users.
func (s *Store) UpdateAPIKey(userID, keyID string, name *string, scopes *[]string) (*domain.APIKey, error) {
if name != nil {
n := strings.TrimSpace(*name)
if n == "" {
return nil, errors.New("Der Name darf nicht leer sein")
}
if res, err := s.db.Exec(`UPDATE api_keys SET name = ? WHERE id = ? AND user_id = ?`, n, keyID, userID); err != nil {
return nil, err
} else if c, _ := res.RowsAffected(); c == 0 {
return nil, ErrAPIKeyNotFound
}
}
if scopes != nil {
joined := strings.Join(NormalizeScopes(*scopes), ",")
if res, err := s.db.Exec(`UPDATE api_keys SET scopes = ? WHERE id = ? AND user_id = ?`, joined, keyID, userID); err != nil {
return nil, err
} else if c, _ := res.RowsAffected(); c == 0 {
return nil, ErrAPIKeyNotFound
}
}
keys, err := s.GetAPIKeys(userID)
if err != nil {
return nil, err
}
for i := range keys {
if keys[i].ID == keyID {
return &keys[i], nil
}
}
return nil, ErrAPIKeyNotFound
}
var ErrAPIKeyNotFound = errors.New("API-Schlüssel nicht gefunden")
func (s *Store) DeleteAPIKey(userID, keyID string) error {
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID)
return err
}
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
u, _, _, err := s.ValidateAPIKey(rawKey)
return u, err
}
// ValidateAPIKey returns the owner of a key, the key's scopes and its id.
func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, string, error) {
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
var keyID, userID, scopes string
err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID, &scopes)
if err != nil {
return nil, nil, "", errors.New("invalid API key")
}
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
u, err := s.GetUserByID(userID)
return u, NormalizeScopes(strings.Split(scopes, ",")), keyID, err
}