Add tm_server with SQLite, Svelte UI, and task-master TUI sync.
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>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user