MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
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>
430 lines
14 KiB
Go
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
|
|
}
|