package sqlite import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "fmt" "strings" "time" "github.com/google/uuid" "golang.org/x/crypto/argon2" ) // Role constants for project membership. const ( RoleOwner = "owner" RoleAdmin = "admin" RoleMember = "member" RoleViewer = "viewer" ) // User is a local account. type User struct { ID string `json:"id"` Username string `json:"username"` PasswordHash string `json:"-"` IsAdmin bool `json:"isAdmin"` CreatedAt time.Time `json:"createdAt"` } // Member is a project membership. type Member struct { ProjectID string `json:"projectId"` UserID string `json:"userId"` Username string `json:"username,omitempty"` Role string `json:"role"` } // HashPassword hashes a password with argon2id. func HashPassword(password string) (string, error) { salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return "", err } hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) return fmt.Sprintf("%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash), ), nil } // CheckPassword verifies a password against a stored hash. func CheckPassword(password, encoded string) bool { parts := strings.SplitN(encoded, "$", 2) if len(parts) != 2 { return false } salt, err := base64.RawStdEncoding.DecodeString(parts[0]) if err != nil { return false } want, err := base64.RawStdEncoding.DecodeString(parts[1]) if err != nil { return false } got := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) if len(got) != len(want) { return false } var v byte for i := range got { v |= got[i] ^ want[i] } return v == 0 } // CreateUser inserts a user. func (db *DB) CreateUser(username, password string, isAdmin bool) (User, error) { username = strings.TrimSpace(username) if username == "" || password == "" { return User{}, fmt.Errorf("username and password required") } hash, err := HashPassword(password) if err != nil { return User{}, err } u := User{ ID: uuid.NewString(), Username: username, PasswordHash: hash, IsAdmin: isAdmin, CreatedAt: time.Now().UTC(), } _, err = db.SQL.Exec(`INSERT INTO users(id, username, password_hash, is_admin, created_at) VALUES(?,?,?,?,?)`, u.ID, u.Username, u.PasswordHash, boolInt(isAdmin), formatTime(u.CreatedAt)) if err != nil { return User{}, fmt.Errorf("create user: %w", err) } return u, nil } // CountUsers returns the number of users. func (db *DB) CountUsers() (int, error) { var n int err := db.SQL.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) return n, err } // GetUserByUsername looks up a user. func (db *DB) GetUserByUsername(username string) (User, error) { return db.scanUser(db.SQL.QueryRow( `SELECT id, username, password_hash, is_admin, created_at FROM users WHERE username = ? COLLATE NOCASE`, username)) } // GetUserByID looks up a user by id. func (db *DB) GetUserByID(id string) (User, error) { return db.scanUser(db.SQL.QueryRow( `SELECT id, username, password_hash, is_admin, created_at FROM users WHERE id = ?`, id)) } // ListUsers returns all users. func (db *DB) ListUsers() ([]User, error) { rows, err := db.SQL.Query(`SELECT id, username, password_hash, is_admin, created_at FROM users ORDER BY username COLLATE NOCASE`) if err != nil { return nil, err } defer rows.Close() var out []User for rows.Next() { u, err := db.scanUser(rows) if err != nil { return nil, err } u.PasswordHash = "" out = append(out, u) } if out == nil { out = []User{} } return out, rows.Err() } func (db *DB) scanUser(scanner interface{ Scan(dest ...any) error }) (User, error) { var u User var admin int var created string err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &created) if err == sql.ErrNoRows { return User{}, fmt.Errorf("user not found") } if err != nil { return User{}, err } u.IsAdmin = admin != 0 u.CreatedAt = parseTime(created) return u, nil } func boolInt(b bool) int { if b { return 1 } return 0 } // CreateSession creates a session and returns the raw token (only once). func (db *DB) CreateSession(userID string, ttl time.Duration) (rawToken string, expiresAt time.Time, err error) { if ttl <= 0 { ttl = 7 * 24 * time.Hour } buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", time.Time{}, err } rawToken = base64.RawURLEncoding.EncodeToString(buf) sum := sha256.Sum256([]byte(rawToken)) tokenHash := base64.RawStdEncoding.EncodeToString(sum[:]) expiresAt = time.Now().UTC().Add(ttl) _, err = db.SQL.Exec(`INSERT INTO sessions(id, user_id, token_hash, expires_at, created_at) VALUES(?,?,?,?,?)`, uuid.NewString(), userID, tokenHash, formatTime(expiresAt), formatTime(time.Now().UTC())) return rawToken, expiresAt, err } // UserFromSession resolves a session token to a user. func (db *DB) UserFromSession(rawToken string) (User, error) { sum := sha256.Sum256([]byte(rawToken)) tokenHash := base64.RawStdEncoding.EncodeToString(sum[:]) var userID, expires string err := db.SQL.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token_hash = ?`, tokenHash). Scan(&userID, &expires) if err == sql.ErrNoRows { return User{}, fmt.Errorf("invalid session") } if err != nil { return User{}, err } if parseTime(expires).Before(time.Now().UTC()) { _, _ = db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash) return User{}, fmt.Errorf("session expired") } return db.GetUserByID(userID) } // DeleteSession removes a session by raw token. func (db *DB) DeleteSession(rawToken string) error { sum := sha256.Sum256([]byte(rawToken)) tokenHash := base64.RawStdEncoding.EncodeToString(sum[:]) _, err := db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash) return err } // SetMember sets or updates a project membership. func (db *DB) SetMember(projectID, userID, role string) error { switch role { case RoleOwner, RoleAdmin, RoleMember, RoleViewer: default: return fmt.Errorf("invalid role %q", role) } _, err := db.SQL.Exec(` INSERT INTO project_members(project_id, user_id, role) VALUES(?,?,?) ON CONFLICT(project_id, user_id) DO UPDATE SET role=excluded.role`, projectID, userID, role) return err } // RemoveMember deletes a membership. func (db *DB) RemoveMember(projectID, userID string) error { _, err := db.SQL.Exec(`DELETE FROM project_members WHERE project_id = ? AND user_id = ?`, projectID, userID) return err } // ListMembers lists members of a project. func (db *DB) ListMembers(projectID string) ([]Member, error) { rows, err := db.SQL.Query(` SELECT m.project_id, m.user_id, u.username, m.role FROM project_members m JOIN users u ON u.id = m.user_id WHERE m.project_id = ? ORDER BY u.username COLLATE NOCASE`, projectID) if err != nil { return nil, err } defer rows.Close() var out []Member for rows.Next() { var m Member if err := rows.Scan(&m.ProjectID, &m.UserID, &m.Username, &m.Role); err != nil { return nil, err } out = append(out, m) } if out == nil { out = []Member{} } return out, rows.Err() } // MemberRole returns the role for a user on a project, or empty if none. func (db *DB) MemberRole(projectID, userID string) (string, error) { var role string err := db.SQL.QueryRow(`SELECT role FROM project_members WHERE project_id = ? AND user_id = ?`, projectID, userID).Scan(&role) if err == sql.ErrNoRows { return "", nil } return role, err } // RoleAtLeast reports whether role meets the required minimum. func RoleAtLeast(role, required string) bool { rank := map[string]int{ RoleViewer: 1, RoleMember: 2, RoleAdmin: 3, RoleOwner: 4, } return rank[role] >= rank[required] }