Introduces the web backend (auth, RBAC, project tokens, revisions), YouTrack-or-taskmaster backend choice in the TUI, and a first-pass embedded Svelte UI. The frontend is functional but needs a major rework. Co-authored-by: Cursor <cursoragent@cursor.com>
281 lines
7.6 KiB
Go
281 lines
7.6 KiB
Go
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]
|
|
}
|