Files
MindSift/internal/storage/users.go
T
sandrodachandClaude Opus 5.5 6b56a33500
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
fix(db): report write errors, multi-step changes all or nothing (FLAW-14)
Store.execInTx runs related statements in one transaction and returns
the first error. Used for deleting and moving folders (with the trash),
shared folders, folder shares and mounts, deleting task lists and
accounts, pinning, and the password reset (new password and the end of
all other links together). Writes whose success the API reports now
pass their error to the client (logout, new password, setup, sharing
mode, new group, restore, unlinking a note block), follow-up tasks and
alarm markers log failures. Remaining ignored writes are listed as
intentional in flaws.md.

Found on the way: DeleteTaskList unlinked all tasks before checking
ownership, so a stranger with the list ID could empty it; viewers could
unlink tasks from note blocks. Both fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:59:23 +01:00

430 lines
14 KiB
Go

package storage
import (
"errors"
"fmt"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- USERS ---
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, organization_id, created_at`
func parseAlarmChannels(raw string) []string {
parts := strings.Split(raw, ",")
res := make([]string, 0, len(parts))
seen := make(map[string]bool)
for _, p := range parts {
p = strings.ToLower(strings.TrimSpace(p))
if (p == "email" || p == "discord" || p == "webhook") && !seen[p] {
seen[p] = true
res = append(res, p)
}
}
return res
}
func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
u := &domain.User{}
var isActiveInt int
var channelsRaw string
err := row.Scan(
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
&u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail,
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.OrganizationID, &u.CreatedAt,
)
if err != nil {
return nil, err
}
u.IsActive = isActiveInt != 0
if u.TokenVersion <= 0 {
u.TokenVersion = 1
}
if u.Role == "" {
u.Role = "member"
}
u.AlarmChannels = parseAlarmChannels(channelsRaw)
if u.AlarmEmail == "" {
u.AlarmEmail = u.Email
}
return u, nil
}
func (s *Store) CountUsers() (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
return count, err
}
func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) {
cleanEmail := strings.ToLower(strings.TrimSpace(email))
username := strings.Split(cleanEmail, "@")[0]
return s.CreateUserWithUsername(cleanEmail, username, passwordHash, displayName, role)
}
func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) {
return s.createUserWithID(domain.NewID("usr"), email, username, passwordHash, displayName, role)
}
func (s *Store) createUserWithID(id, email, username, passwordHash, displayName, role string) (*domain.User, error) {
if role != "admin" && role != "member" {
role = "member"
}
cleanEmail := strings.ToLower(strings.TrimSpace(email))
cleanUsername := strings.ToLower(strings.TrimSpace(username))
if cleanUsername == "" {
cleanUsername = strings.Split(cleanEmail, "@")[0]
}
u := &domain.User{
ID: id,
Email: cleanEmail,
Username: cleanUsername,
PasswordHash: passwordHash,
DisplayName: strings.TrimSpace(displayName),
Role: role,
IsActive: true,
TokenVersion: 1,
AlarmChannels: []string{},
AlarmEmail: cleanEmail,
DiscordWebhookURL: "",
CustomWebhookURL: "",
NotifyBeforeMinutes: 30,
AvatarURL: "",
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at)
VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`,
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
)
if err != nil {
return nil, err
}
return u, nil
}
func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
return s.GetUserByLogin(email)
}
func (s *Store) GetUserByLogin(login string) (*domain.User, error) {
cleanLogin := strings.ToLower(strings.TrimSpace(login))
if cleanLogin == "" {
return nil, errors.New("benutzername oder e-mail darf nicht leer sein")
}
row := s.db.QueryRow(
`SELECT `+userSelectCols+` FROM users
WHERE LOWER(email) = ?
OR (username != '' AND LOWER(username) = ?)
OR LOWER(display_name) = ?
OR (INSTR(email, '@') > 0 AND LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) = ?)
LIMIT 1`,
cleanLogin, cleanLogin, cleanLogin, cleanLogin,
)
return scanUser(row)
}
// EnsureDemoAccounts seeds admin/admin and user/user accounts for demo evaluation
// Demo accounts keep the same IDs across resets, so the sessions of people trying the demo stay valid
// when the database is reset underneath them.
const (
DemoAdminID = "usr_demo_admin"
DemoUserID = "usr_demo_user"
)
func (s *Store) EnsureDemoAccounts() error {
s.initMu.Lock()
defer s.initMu.Unlock()
adminHash, err := auth.HashPassword("admin")
if err != nil {
return err
}
userHash, err := auth.HashPassword("user")
if err != nil {
return err
}
// 1. Admin user (admin / admin)
adminCreated := false
adminUser, err := s.GetUserByLogin("admin")
if err != nil || adminUser == nil {
adminUser, err = s.createUserWithID(DemoAdminID, "admin@mindsift.local", "admin", adminHash, "Admin", "admin")
if err != nil {
return fmt.Errorf("failed to create demo admin: %w", err)
}
adminCreated = true
} else {
_ = s.UpdateUserPassword(adminUser.ID, adminHash)
_, _ = s.db.Exec(`UPDATE users SET is_active = 1, role = 'admin' WHERE id = ?`, adminUser.ID)
}
// 2. Regular user (user / user)
normalUser, err := s.GetUserByLogin("user")
if err != nil || normalUser == nil {
normalUser, err = s.createUserWithID(DemoUserID, "user@mindsift.local", "user", userHash, "User", "member")
if err != nil {
return fmt.Errorf("failed to create demo user: %w", err)
}
} else {
_ = s.UpdateUserPassword(normalUser.ID, userHash)
_, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID)
}
// 3. Sample data for the admin, partly shared with the demo user (only on first start). Both accounts
// share an organization so they can see each other in the default sharing mode.
if adminCreated {
if org, err := s.CreateOrganization("Demo"); err == nil {
_ = s.SetUserOrganization(adminUser.ID, org.ID)
_ = s.SetUserOrganization(normalUser.ID, org.ID)
}
_ = s.SeedStarterContent(adminUser)
s.seedDemoShares(adminUser, normalUser)
}
return nil
}
func (s *Store) GetUserByID(id string) (*domain.User, error) {
row := s.db.QueryRow(
`SELECT `+userSelectCols+` FROM users WHERE id = ?`,
id,
)
return scanUser(row)
}
func (s *Store) GetFirstUser() (*domain.User, error) {
row := s.db.QueryRow(
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC LIMIT 1`,
)
return scanUser(row)
}
func (s *Store) GetAllUsers() ([]domain.User, error) {
rows, err := s.db.Query(
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC`,
)
if err != nil {
return nil, err
}
users := make([]domain.User, 0)
for rows.Next() {
if u, err := scanUser(rows); err == nil {
users = append(users, *u)
}
}
_ = rows.Close()
return users, nil
}
func (s *Store) UpdateUser(
userID string,
displayName, email, role *string,
isActive *bool,
discordWebhookURL *string,
notifyBeforeMinutes *int,
) (*domain.User, error) {
return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes, nil)
}
func (s *Store) UpdateUserFull(
userID string,
displayName, email, role *string,
isActive *bool,
alarmChannels *[]string,
alarmEmail, discordWebhookURL, customWebhookURL *string,
notifyBeforeMinutes *int,
avatarURL *string,
) (*domain.User, error) {
u, err := s.GetUserByID(userID)
if err != nil {
return nil, err
}
if displayName != nil {
u.DisplayName = strings.TrimSpace(*displayName)
}
if email != nil {
u.Email = strings.ToLower(strings.TrimSpace(*email))
}
if role != nil && (*role == "admin" || *role == "member") {
u.Role = *role
}
if isActive != nil {
u.IsActive = *isActive
}
if alarmChannels != nil {
u.AlarmChannels = parseAlarmChannels(strings.Join(*alarmChannels, ","))
}
if alarmEmail != nil {
u.AlarmEmail = strings.TrimSpace(*alarmEmail)
}
if discordWebhookURL != nil {
u.DiscordWebhookURL = strings.TrimSpace(*discordWebhookURL)
}
if customWebhookURL != nil {
u.CustomWebhookURL = strings.TrimSpace(*customWebhookURL)
}
if notifyBeforeMinutes != nil {
u.NotifyBeforeMinutes = *notifyBeforeMinutes
}
if avatarURL != nil {
u.AvatarURL = strings.TrimSpace(*avatarURL)
}
if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) {
u.TokenVersion++
}
activeInt := 0
if u.IsActive {
activeInt = 1
}
channelsStr := strings.Join(u.AlarmChannels, ",")
_, err = s.db.Exec(
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`,
u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
)
if err != nil {
return nil, err
}
return u, nil
}
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
_, err := s.db.Exec(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID)
return err
}
func (s *Store) DeleteUser(userID string) error {
// Remove owned data properly first (other people's tasks in the user's lists stay theirs)
if err := s.PurgeUserData(userID); err != nil {
return err
}
if err := s.execInTx(
stmt(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID),
stmt(`DELETE FROM task_assignees WHERE user_id = ?`, userID),
stmt(`DELETE FROM password_reset_tokens WHERE user_id = ?`, userID),
stmt(`DELETE FROM users WHERE id = ?`, userID),
); err != nil {
return err
}
// Shares pointing at the deleted account are no longer visible to anyone
s.pruneSharingNow()
return nil
}
// RevokeToken adds an individual JWT to the blacklist until its expiration date
func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error {
hash := auth.HashAPIKey(tokenStr)
_, err := s.db.Exec(
`INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`,
hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
)
// Idempotently prune old expired tokens
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
return err
}
// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted
func (s *Store) IsTokenRevoked(tokenStr string) bool {
hash := auth.HashAPIKey(tokenStr)
var count int
err := s.db.QueryRow(
`SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`,
hash, time.Now().UTC().Format(time.RFC3339),
).Scan(&count)
return err == nil && count > 0
}
// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user
func (s *Store) RevokeAllUserTokens(userID string) error {
_, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID)
return err
}
// --- USER INVITES ---
func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) {
if role != "admin" && role != "member" {
role = "member"
}
if validDays <= 0 || validDays > 365 {
validDays = 7
}
code := "INV-" + strings.ToUpper(domain.NewID("c")[2:])
now := time.Now().UTC()
inv := &domain.UserInvite{
ID: domain.NewID("inv"),
Code: code,
Email: strings.ToLower(strings.TrimSpace(email)),
Role: role,
OrganizationID: strings.TrimSpace(orgID),
CreatedBy: createdBy,
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
CreatedAt: now.Format(time.RFC3339),
}
_, err := s.db.Exec(
`INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
)
if err != nil {
return nil, err
}
return inv, nil
}
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
rows, err := s.db.Query(
`SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
FROM user_invites i
LEFT JOIN organizations o ON o.id = i.organization_id
ORDER BY i.created_at DESC`,
)
if err != nil {
return []domain.UserInvite{}, err
}
defer rows.Close()
invites := make([]domain.UserInvite, 0)
for rows.Next() {
var inv domain.UserInvite
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
invites = append(invites, inv)
}
}
return invites, nil
}
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
code = strings.ToUpper(strings.TrimSpace(code))
var inv domain.UserInvite
err := s.db.QueryRow(
`SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at
FROM user_invites WHERE UPPER(code) = ?`,
code,
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
if err != nil {
return nil, errors.New("Ungültiger Einladungscode")
}
if inv.UsedBy != "" {
return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst")
}
if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) {
return nil, errors.New("Dieser Einladungscode ist abgelaufen")
}
return &inv, nil
}
func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error {
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID)
return err
}
func (s *Store) DeleteUserInvite(inviteID string) error {
_, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID)
return err
}