refactor: split sqlite.go and server.go by domain (FLAW-20)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
Pure moves, no behaviour change: the storage layer now has users, apikeys, thoughts, tasklists, documents, folders, tags, lists, teams and system files next to sqlite.go (store, schema, migrations); the API has handlers_*.go per area plus realtime.go and alarms.go next to server.go (server, router, middleware). Declarations were moved verbatim with an AST-based tool; imports pruned with goimports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7bba174454
commit
74c394efaa
26 files changed
+7702
-7467
No files matched your search
@@ -0,0 +1,413 @@
|
||||
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) {
|
||||
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: domain.NewID("usr"),
|
||||
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
|
||||
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.CreateUserWithUsername("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.CreateUserWithUsername("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
|
||||
}
|
||||
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
|
||||
_, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID)
|
||||
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
||||
// Shares pointing at the deleted account are no longer visible to anyone
|
||||
s.pruneSharingNow()
|
||||
return err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user