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]
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Connection is a remote backend credential (instance or project scope).
|
||||
type Connection struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Scope string `json:"scope"` // instance | project
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Token string `json:"-"` // only populated when decrypted for sync
|
||||
}
|
||||
|
||||
// CreateConnection stores an encrypted token.
|
||||
func (db *DB) CreateConnection(name, kind, baseURL, scope, token, createdBy string) (Connection, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
kind = strings.TrimSpace(kind)
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
scope = strings.TrimSpace(scope)
|
||||
if name == "" || kind == "" || baseURL == "" || token == "" {
|
||||
return Connection{}, fmt.Errorf("name, kind, baseUrl and token required")
|
||||
}
|
||||
if scope == "" {
|
||||
scope = "instance"
|
||||
}
|
||||
enc, err := db.EncryptToken(token)
|
||||
if err != nil {
|
||||
return Connection{}, err
|
||||
}
|
||||
c := Connection{
|
||||
ID: uuid.NewString(),
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
BaseURL: baseURL,
|
||||
Scope: scope,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
_, err = db.SQL.Exec(`
|
||||
INSERT INTO connections(id, name, kind, base_url, scope, encrypted_token, created_by, created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`,
|
||||
c.ID, c.Name, c.Kind, c.BaseURL, c.Scope, enc, c.CreatedBy, formatTime(c.CreatedAt))
|
||||
if err != nil {
|
||||
return Connection{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListConnections returns connections without tokens.
|
||||
func (db *DB) ListConnections() ([]Connection, error) {
|
||||
rows, err := db.SQL.Query(`
|
||||
SELECT id, name, kind, base_url, scope, created_by, created_at
|
||||
FROM connections ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Connection
|
||||
for rows.Next() {
|
||||
var c Connection
|
||||
var created string
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Kind, &c.BaseURL, &c.Scope, &c.CreatedBy, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt = parseTime(created)
|
||||
out = append(out, c)
|
||||
}
|
||||
if out == nil {
|
||||
out = []Connection{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetConnection loads a connection and decrypts its token.
|
||||
func (db *DB) GetConnection(id string) (Connection, error) {
|
||||
var c Connection
|
||||
var created string
|
||||
var blob []byte
|
||||
err := db.SQL.QueryRow(`
|
||||
SELECT id, name, kind, base_url, scope, encrypted_token, created_by, created_at
|
||||
FROM connections WHERE id = ?`, id).
|
||||
Scan(&c.ID, &c.Name, &c.Kind, &c.BaseURL, &c.Scope, &blob, &c.CreatedBy, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
return Connection{}, fmt.Errorf("connection %s: not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return Connection{}, err
|
||||
}
|
||||
c.CreatedAt = parseTime(created)
|
||||
c.Token, err = db.DecryptToken(blob)
|
||||
if err != nil {
|
||||
return Connection{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DeleteConnection removes a connection.
|
||||
func (db *DB) DeleteConnection(id string) error {
|
||||
res, err := db.SQL.Exec(`DELETE FROM connections WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("connection %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func normalizeSecret(secret []byte) []byte {
|
||||
if len(secret) == 0 {
|
||||
// Deterministic fallback for local/dev; production should set TASK_MASTER_SECRET.
|
||||
sum := sha256.Sum256([]byte("task-master-dev-secret"))
|
||||
return sum[:]
|
||||
}
|
||||
sum := sha256.Sum256(secret)
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// EncryptToken seals plaintext with AES-GCM.
|
||||
func (db *DB) EncryptToken(plain string) ([]byte, error) {
|
||||
block, err := aes.NewCipher(db.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, []byte(plain), nil), nil
|
||||
}
|
||||
|
||||
// DecryptToken opens an AES-GCM blob.
|
||||
func (db *DB) DecryptToken(blob []byte) (string, error) {
|
||||
block, err := aes.NewCipher(db.Secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(blob) < gcm.NonceSize() {
|
||||
return "", fmt.Errorf("token ciphertext too short")
|
||||
}
|
||||
nonce, ct := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// DB wraps a SQLite connection and domain helpers.
|
||||
type DB struct {
|
||||
SQL *sql.DB
|
||||
Secret []byte // AES-256 key material for connection tokens (derived/padded)
|
||||
}
|
||||
|
||||
// Open opens (or creates) the database at path and runs migrations.
|
||||
func Open(path string, secret []byte) (*DB, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("sqlite: path required")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("sqlite: create dir: %w", err)
|
||||
}
|
||||
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sqlite: open: %w", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1) // SQLite writer safety
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("sqlite: ping: %w", err)
|
||||
}
|
||||
db := &DB{SQL: sqlDB, Secret: normalizeSecret(secret)}
|
||||
if err := db.migrate(); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying connection.
|
||||
func (db *DB) Close() error {
|
||||
if db == nil || db.SQL == nil {
|
||||
return nil
|
||||
}
|
||||
return db.SQL.Close()
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
if _, err := db.SQL.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("sqlite: schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
entries, err := migrationFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("sqlite: list migrations: %w", err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
var exists string
|
||||
err := db.SQL.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&exists)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return fmt.Errorf("sqlite: check migration %s: %w", name, err)
|
||||
}
|
||||
body, err := migrationFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sqlite: read migration %s: %w", name, err)
|
||||
}
|
||||
tx, err := db.SQL.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(string(body)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("sqlite: apply %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO schema_migrations(name, applied_at) VALUES(?, ?)`,
|
||||
name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("sqlite: record %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
-- schema version tracked in app_meta
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_members (
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_members_user ON project_members(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
external_url TEXT NOT NULL DEFAULT '',
|
||||
synced_at TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_project ON tickets(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_external ON tickets(external_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
ticket_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL,
|
||||
duration_ns INTEGER NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_ticket ON time_entries(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_time_project ON time_entries(project_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS active_timer (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
ticket_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
encrypted_token BLOB NOT NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_backends (
|
||||
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL DEFAULT 'none',
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
project_key TEXT NOT NULL DEFAULT '',
|
||||
query TEXT NOT NULL DEFAULT '',
|
||||
connection_id TEXT REFERENCES connections(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL DEFAULT '',
|
||||
rev INTEGER NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
actor_user_id TEXT,
|
||||
op TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_revisions_entity ON revisions(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_revisions_project ON revisions(project_id);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS project_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
scope TEXT NOT NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_tokens_project ON project_tokens(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_tokens_hash ON project_tokens(token_hash);
|
||||
@@ -0,0 +1,323 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"task/internal/project"
|
||||
)
|
||||
|
||||
// ProjectStore implements project.Store on SQLite.
|
||||
type ProjectStore struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewProjectStore returns a project store.
|
||||
func NewProjectStore(db *DB) *ProjectStore {
|
||||
return &ProjectStore{db: db}
|
||||
}
|
||||
|
||||
// List returns all projects sorted by name.
|
||||
func (s *ProjectStore) List() ([]project.Project, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT id, name FROM projects ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []project.Project
|
||||
for rows.Next() {
|
||||
var p project.Project
|
||||
if err := rows.Scan(&p.ID, &p.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if out == nil {
|
||||
out = []project.Project{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Ensure creates the project if missing.
|
||||
func (s *ProjectStore) Ensure(id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("project id is required")
|
||||
}
|
||||
now := formatTime(time.Now().UTC())
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`, id, id, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure project %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create creates a project. Empty id is replaced with a new UUID.
|
||||
func (s *ProjectStore) Create(id, name string) (project.Project, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return project.Project{}, fmt.Errorf("project name is required")
|
||||
}
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
now := formatTime(time.Now().UTC())
|
||||
_, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)`,
|
||||
id, name, now, now)
|
||||
if err != nil {
|
||||
return project.Project{}, fmt.Errorf("create project: %w", err)
|
||||
}
|
||||
return project.Project{ID: id, Name: name}, nil
|
||||
}
|
||||
|
||||
// Rename updates the display name.
|
||||
func (s *ProjectStore) Rename(id, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if id == "" || name == "" {
|
||||
return fmt.Errorf("id and name are required")
|
||||
}
|
||||
res, err := s.db.SQL.Exec(`UPDATE projects SET name = ?, updated_at = ? WHERE id = ?`,
|
||||
name, formatTime(time.Now().UTC()), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("project %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a project and cascaded rows.
|
||||
func (s *ProjectStore) Delete(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("project id is required")
|
||||
}
|
||||
res, err := s.db.SQL.Exec(`DELETE FROM projects WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete project %s: %w", id, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("project %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns one project.
|
||||
func (s *ProjectStore) Get(id string) (project.Project, error) {
|
||||
var p project.Project
|
||||
err := s.db.SQL.QueryRow(`SELECT id, name FROM projects WHERE id = ?`, id).Scan(&p.ID, &p.Name)
|
||||
if err == sql.ErrNoRows {
|
||||
return project.Project{}, fmt.Errorf("project %s: not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return project.Project{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// LoadMeta maps sync_backends (+ connection token) into project.Meta.
|
||||
func (s *ProjectStore) LoadMeta(id string) (project.Meta, error) {
|
||||
if id == "" {
|
||||
return project.Meta{}, fmt.Errorf("project id is required")
|
||||
}
|
||||
var kind, baseURL, projectKey, query string
|
||||
var connID sql.NullString
|
||||
err := s.db.SQL.QueryRow(`
|
||||
SELECT kind, base_url, project_key, query, connection_id
|
||||
FROM sync_backends WHERE project_id = ?`, id).
|
||||
Scan(&kind, &baseURL, &projectKey, &query, &connID)
|
||||
if err == sql.ErrNoRows {
|
||||
return project.Meta{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return project.Meta{}, fmt.Errorf("load meta %s: %w", id, err)
|
||||
}
|
||||
if kind != "youtrack" {
|
||||
return project.Meta{}, nil
|
||||
}
|
||||
yt := &project.YouTrack{
|
||||
BaseURL: baseURL,
|
||||
ProjectKey: projectKey,
|
||||
Query: query,
|
||||
}
|
||||
if connID.Valid && connID.String != "" {
|
||||
token, err := s.db.connectionToken(connID.String)
|
||||
if err != nil {
|
||||
return project.Meta{}, err
|
||||
}
|
||||
yt.Token = token
|
||||
}
|
||||
return project.Meta{YouTrack: yt}, nil
|
||||
}
|
||||
|
||||
// SaveMeta writes YouTrack settings into sync_backends (creates inline connection if token set).
|
||||
func (s *ProjectStore) SaveMeta(id string, meta project.Meta) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("project id is required")
|
||||
}
|
||||
if err := s.Ensure(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.YouTrack == nil || !meta.YouTrack.Configured() && strings.TrimSpace(meta.YouTrack.BaseURL) == "" {
|
||||
_, err := s.db.SQL.Exec(`DELETE FROM sync_backends WHERE project_id = ?`, id)
|
||||
return err
|
||||
}
|
||||
yt := meta.YouTrack
|
||||
var connID sql.NullString
|
||||
if strings.TrimSpace(yt.Token) != "" {
|
||||
cid, err := s.db.ensureProjectConnection(id, yt.BaseURL, yt.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
connID = sql.NullString{String: cid, Valid: true}
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id)
|
||||
VALUES(?, 'youtrack', ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id) DO UPDATE SET
|
||||
kind=excluded.kind,
|
||||
base_url=excluded.base_url,
|
||||
project_key=excluded.project_key,
|
||||
query=excluded.query,
|
||||
connection_id=COALESCE(excluded.connection_id, sync_backends.connection_id)`,
|
||||
id, yt.BaseURL, yt.ProjectKey, yt.Query, connID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncBackend is the persisted per-project sync config (API-facing).
|
||||
type SyncBackend struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
Kind string `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
ProjectKey string `json:"projectKey"`
|
||||
Query string `json:"query,omitempty"`
|
||||
ConnectionID string `json:"connectionId,omitempty"`
|
||||
}
|
||||
|
||||
// GetSyncBackend returns sync config for a project.
|
||||
func (s *ProjectStore) GetSyncBackend(projectID string) (SyncBackend, error) {
|
||||
var b SyncBackend
|
||||
var connID sql.NullString
|
||||
err := s.db.SQL.QueryRow(`
|
||||
SELECT project_id, kind, base_url, project_key, query, connection_id
|
||||
FROM sync_backends WHERE project_id = ?`, projectID).
|
||||
Scan(&b.ProjectID, &b.Kind, &b.BaseURL, &b.ProjectKey, &b.Query, &connID)
|
||||
if err == sql.ErrNoRows {
|
||||
return SyncBackend{ProjectID: projectID, Kind: "none"}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return SyncBackend{}, err
|
||||
}
|
||||
if connID.Valid {
|
||||
b.ConnectionID = connID.String
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// SetSyncBackend upserts sync config.
|
||||
func (s *ProjectStore) SetSyncBackend(b SyncBackend) error {
|
||||
if b.ProjectID == "" {
|
||||
return fmt.Errorf("projectId required")
|
||||
}
|
||||
if b.Kind == "" {
|
||||
b.Kind = "none"
|
||||
}
|
||||
var connID sql.NullString
|
||||
if b.ConnectionID != "" {
|
||||
connID = sql.NullString{String: b.ConnectionID, Valid: true}
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id)
|
||||
VALUES(?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id) DO UPDATE SET
|
||||
kind=excluded.kind,
|
||||
base_url=excluded.base_url,
|
||||
project_key=excluded.project_key,
|
||||
query=excluded.query,
|
||||
connection_id=excluded.connection_id`,
|
||||
b.ProjectID, b.Kind, b.BaseURL, b.ProjectKey, b.Query, connID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListIDsForUser returns project IDs the user can access (all if admin).
|
||||
func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Project, error) {
|
||||
if isAdmin {
|
||||
return s.List()
|
||||
}
|
||||
rows, err := s.db.SQL.Query(`
|
||||
SELECT p.id, p.name FROM projects p
|
||||
INNER JOIN project_members m ON m.project_id = p.id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY p.name COLLATE NOCASE`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []project.Project
|
||||
for rows.Next() {
|
||||
var p project.Project
|
||||
if err := rows.Scan(&p.ID, &p.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if out == nil {
|
||||
out = []project.Project{}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) ensureProjectConnection(projectID, baseURL, token string) (string, error) {
|
||||
// Reuse existing sync_backends connection if present.
|
||||
var existing sql.NullString
|
||||
_ = db.SQL.QueryRow(`SELECT connection_id FROM sync_backends WHERE project_id = ?`, projectID).Scan(&existing)
|
||||
if existing.Valid && existing.String != "" {
|
||||
enc, err := db.EncryptToken(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = db.SQL.Exec(`UPDATE connections SET encrypted_token = ?, base_url = ? WHERE id = ?`,
|
||||
enc, baseURL, existing.String)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return existing.String, nil
|
||||
}
|
||||
id := "conn_" + projectID
|
||||
enc, err := db.EncryptToken(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = db.SQL.Exec(`
|
||||
INSERT INTO connections(id, name, kind, base_url, scope, encrypted_token, created_by, created_at)
|
||||
VALUES(?, ?, 'youtrack', ?, 'project', ?, NULL, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET encrypted_token=excluded.encrypted_token, base_url=excluded.base_url`,
|
||||
id, "project:"+projectID, baseURL, enc, formatTime(time.Now().UTC()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (db *DB) connectionToken(id string) (string, error) {
|
||||
var blob []byte
|
||||
err := db.SQL.QueryRow(`SELECT encrypted_token FROM connections WHERE id = ?`, id).Scan(&blob)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("connection %s not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return db.DecryptToken(blob)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Revision is a versioned snapshot of an entity.
|
||||
type Revision struct {
|
||||
ID int64 `json:"id"`
|
||||
EntityType string `json:"entityType"`
|
||||
EntityID string `json:"entityId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Rev int `json:"rev"`
|
||||
At time.Time `json:"at"`
|
||||
ActorUserID string `json:"actorUserId,omitempty"`
|
||||
Op string `json:"op"`
|
||||
Snapshot json.RawMessage `json:"snapshot"`
|
||||
}
|
||||
|
||||
// AppendRevision stores a new revision snapshot.
|
||||
func (db *DB) AppendRevision(entityType, entityID, projectID, op, actorUserID string, snapshot any) (Revision, error) {
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
var maxRev sql.NullInt64
|
||||
_ = db.SQL.QueryRow(`SELECT MAX(rev) FROM revisions WHERE entity_type = ? AND entity_id = ?`,
|
||||
entityType, entityID).Scan(&maxRev)
|
||||
rev := 1
|
||||
if maxRev.Valid {
|
||||
rev = int(maxRev.Int64) + 1
|
||||
}
|
||||
at := time.Now().UTC()
|
||||
var actor any
|
||||
if actorUserID != "" {
|
||||
actor = actorUserID
|
||||
}
|
||||
res, err := db.SQL.Exec(`
|
||||
INSERT INTO revisions(entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json)
|
||||
VALUES(?,?,?,?,?,?,?,?)`,
|
||||
entityType, entityID, projectID, rev, formatTime(at), actor, op, string(data))
|
||||
if err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return Revision{
|
||||
ID: id,
|
||||
EntityType: entityType,
|
||||
EntityID: entityID,
|
||||
ProjectID: projectID,
|
||||
Rev: rev,
|
||||
At: at,
|
||||
ActorUserID: actorUserID,
|
||||
Op: op,
|
||||
Snapshot: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListRevisions returns revisions for an entity, newest first.
|
||||
func (db *DB) ListRevisions(entityType, entityID string) ([]Revision, error) {
|
||||
rows, err := db.SQL.Query(`
|
||||
SELECT id, entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json
|
||||
FROM revisions WHERE entity_type = ? AND entity_id = ?
|
||||
ORDER BY rev DESC`, entityType, entityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRevisions(rows)
|
||||
}
|
||||
|
||||
// GetRevision returns one revision by id.
|
||||
func (db *DB) GetRevision(id int64) (Revision, error) {
|
||||
row := db.SQL.QueryRow(`
|
||||
SELECT id, entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json
|
||||
FROM revisions WHERE id = ?`, id)
|
||||
var r Revision
|
||||
var at string
|
||||
var actor sql.NullString
|
||||
var snap string
|
||||
err := row.Scan(&r.ID, &r.EntityType, &r.EntityID, &r.ProjectID, &r.Rev, &at, &actor, &r.Op, &snap)
|
||||
if err == sql.ErrNoRows {
|
||||
return Revision{}, fmt.Errorf("revision %d: not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
r.At = parseTime(at)
|
||||
if actor.Valid {
|
||||
r.ActorUserID = actor.String
|
||||
}
|
||||
r.Snapshot = json.RawMessage(snap)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func scanRevisions(rows *sql.Rows) ([]Revision, error) {
|
||||
var out []Revision
|
||||
for rows.Next() {
|
||||
var r Revision
|
||||
var at string
|
||||
var actor sql.NullString
|
||||
var snap string
|
||||
if err := rows.Scan(&r.ID, &r.EntityType, &r.EntityID, &r.ProjectID, &r.Rev, &at, &actor, &r.Op, &snap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.At = parseTime(at)
|
||||
if actor.Valid {
|
||||
r.ActorUserID = actor.String
|
||||
}
|
||||
r.Snapshot = json.RawMessage(snap)
|
||||
out = append(out, r)
|
||||
}
|
||||
if out == nil {
|
||||
out = []Revision{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestStoresRoundTrip(t *testing.T) {
|
||||
db, err := Open(filepath.Join(t.TempDir(), "test.db"), []byte("test-secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ps := NewProjectStore(db)
|
||||
if err := ps.Ensure("inbox"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := NewTicketStore(db)
|
||||
created, err := ts.Create(ticket.Ticket{Title: "Hello", ProjectID: "inbox", Status: ticket.StatusTodo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ts.Get(created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Title != "Hello" {
|
||||
t.Fatalf("title=%q", got.Title)
|
||||
}
|
||||
got.Title = "Updated"
|
||||
if err := ts.Update(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.MarkSynced(got.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
synced, _ := ts.Get(got.ID)
|
||||
if synced.SyncedAt.IsZero() || !synced.SyncedAt.Equal(synced.UpdatedAt) {
|
||||
t.Fatalf("syncedAt=%v updatedAt=%v", synced.SyncedAt, synced.UpdatedAt)
|
||||
}
|
||||
|
||||
timeStore := NewTimeStore(db)
|
||||
_, err = timeStore.Create(ticket.TimeEntry{
|
||||
TicketID: created.ID,
|
||||
ProjectID: "inbox",
|
||||
Duration: time.Hour,
|
||||
Source: ticket.SourceManual,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum, err := timeStore.SumByTicket(created.ID)
|
||||
if err != nil || sum != time.Hour {
|
||||
t.Fatalf("sum=%v err=%v", sum, err)
|
||||
}
|
||||
|
||||
u, err := db.CreateUser("admin", "secret", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _, err := db.CreateSession(u.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
me, err := db.UserFromSession(token)
|
||||
if err != nil || me.Username != "admin" {
|
||||
t.Fatalf("session user=%v err=%v", me, err)
|
||||
}
|
||||
if err := db.SetMember("inbox", u.ID, RoleOwner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role, _ := db.MemberRole("inbox", u.ID)
|
||||
if role != RoleOwner {
|
||||
t.Fatalf("role=%s", role)
|
||||
}
|
||||
|
||||
_, err = db.AppendRevision("ticket", created.ID, "inbox", "update", u.ID, synced)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revs, err := db.ListRevisions("ticket", created.ID)
|
||||
if err != nil || len(revs) != 1 {
|
||||
t.Fatalf("revs=%v err=%v", revs, err)
|
||||
}
|
||||
|
||||
conn, err := db.CreateConnection("YT", "youtrack", "https://yt.example", "instance", "tok-123", u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := db.GetConnection(conn.ID)
|
||||
if err != nil || loaded.Token != "tok-123" {
|
||||
t.Fatalf("token=%q err=%v", loaded.Token, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// TicketStore implements ticket.Store on SQLite.
|
||||
type TicketStore struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewTicketStore returns a ticket store.
|
||||
func NewTicketStore(db *DB) *TicketStore {
|
||||
return &TicketStore{db: db}
|
||||
}
|
||||
|
||||
func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, error) {
|
||||
var t ticket.Ticket
|
||||
var created, updated, synced string
|
||||
var status string
|
||||
err := scanner.Scan(
|
||||
&t.ID, &t.Title, &t.Description, &status, &t.ProjectID,
|
||||
&created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced,
|
||||
)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
t.Status = ticket.Status(status)
|
||||
t.CreatedAt = parseTime(created)
|
||||
t.UpdatedAt = parseTime(updated)
|
||||
t.SyncedAt = parseTime(synced)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at`
|
||||
|
||||
// List returns tickets for a project.
|
||||
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT `+ticketCols+` FROM tickets WHERE project_id = ? ORDER BY updated_at DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ticket.Ticket
|
||||
for rows.Next() {
|
||||
t, err := scanTicket(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ticket.Ticket{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Get finds a ticket by ID.
|
||||
func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
|
||||
row := s.db.SQL.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id)
|
||||
t, err := scanTicket(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Create inserts a ticket.
|
||||
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
|
||||
if t.ProjectID == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("projectId is required")
|
||||
}
|
||||
if t.Title == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("title is required")
|
||||
}
|
||||
if t.ID == "" {
|
||||
t.ID = uuid.NewString()
|
||||
}
|
||||
if t.Status == "" {
|
||||
t.Status = ticket.StatusTodo
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = now
|
||||
}
|
||||
if t.UpdatedAt.IsZero() {
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.Title, t.Description, string(t.Status), t.ProjectID,
|
||||
formatTime(t.CreatedAt), formatTime(t.UpdatedAt), t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Update replaces a ticket and bumps UpdatedAt.
|
||||
func (s *TicketStore) Update(t ticket.Ticket) error {
|
||||
if t.ID == "" || t.ProjectID == "" {
|
||||
return fmt.Errorf("id and projectId are required")
|
||||
}
|
||||
var created string
|
||||
err := s.db.SQL.QueryRow(`SELECT created_at FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID).Scan(&created)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("ticket %s: not found", t.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.CreatedAt = parseTime(created)
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
res, err := s.db.SQL.Exec(`
|
||||
UPDATE tickets SET title=?, description=?, status=?, project_id=?, created_at=?, updated_at=?,
|
||||
source=?, external_id=?, external_url=?, synced_at=?
|
||||
WHERE id=?`,
|
||||
t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt),
|
||||
t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), t.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("ticket %s: not found", t.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt.
|
||||
func (s *TicketStore) MarkSynced(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("ticket id is required")
|
||||
}
|
||||
res, err := s.db.SQL.Exec(`UPDATE tickets SET synced_at = updated_at WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Move relocates a ticket to another project.
|
||||
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
|
||||
if id == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("ticket id is required")
|
||||
}
|
||||
if toProjectID == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("projectId is required")
|
||||
}
|
||||
t, err := s.Get(id)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
t.ProjectID = toProjectID
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
_, err = s.db.SQL.Exec(`UPDATE tickets SET project_id=?, updated_at=? WHERE id=?`,
|
||||
toProjectID, formatTime(t.UpdatedAt), id)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Delete removes a ticket.
|
||||
func (s *TicketStore) Delete(id string) error {
|
||||
res, err := s.db.SQL.Exec(`DELETE FROM tickets WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// TimeStore implements ticket.TimeStore on SQLite.
|
||||
type TimeStore struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewTimeStore returns a time store.
|
||||
func NewTimeStore(db *DB) *TimeStore {
|
||||
return &TimeStore{db: db}
|
||||
}
|
||||
|
||||
func scanEntry(scanner interface{ Scan(dest ...any) error }) (ticket.TimeEntry, error) {
|
||||
var e ticket.TimeEntry
|
||||
var start, end string
|
||||
var dur int64
|
||||
err := scanner.Scan(&e.ID, &e.TicketID, &e.ProjectID, &start, &end, &dur, &e.Source, &e.Note, &e.ExternalID)
|
||||
if err != nil {
|
||||
return ticket.TimeEntry{}, err
|
||||
}
|
||||
e.Start = parseTime(start)
|
||||
e.End = parseTime(end)
|
||||
e.Duration = time.Duration(dur)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
const entryCols = `id, ticket_id, project_id, start, end, duration_ns, source, note, external_id`
|
||||
|
||||
// List returns all time entries.
|
||||
func (s *TimeStore) List() ([]ticket.TimeEntry, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT ` + entryCols + ` FROM time_entries ORDER BY start DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectEntries(rows)
|
||||
}
|
||||
|
||||
// ListByTicket returns entries for one ticket.
|
||||
func (s *TimeStore) ListByTicket(ticketID string) ([]ticket.TimeEntry, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE ticket_id = ? ORDER BY start DESC`, ticketID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectEntries(rows)
|
||||
}
|
||||
|
||||
// ListByProject returns entries for a project.
|
||||
func (s *TimeStore) ListByProject(projectID string) ([]ticket.TimeEntry, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE project_id = ? ORDER BY start DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectEntries(rows)
|
||||
}
|
||||
|
||||
func collectEntries(rows *sql.Rows) ([]ticket.TimeEntry, error) {
|
||||
var out []ticket.TimeEntry
|
||||
for rows.Next() {
|
||||
e, err := scanEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ticket.TimeEntry{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Create adds a time entry.
|
||||
func (s *TimeStore) Create(e ticket.TimeEntry) (ticket.TimeEntry, error) {
|
||||
if e.TicketID == "" {
|
||||
return ticket.TimeEntry{}, fmt.Errorf("ticketId is required")
|
||||
}
|
||||
if e.Duration <= 0 && !e.End.IsZero() && !e.Start.IsZero() {
|
||||
e.Duration = e.End.Sub(e.Start)
|
||||
}
|
||||
if e.Duration <= 0 {
|
||||
return ticket.TimeEntry{}, fmt.Errorf("duration must be positive")
|
||||
}
|
||||
if e.ID == "" {
|
||||
e.ID = uuid.NewString()
|
||||
}
|
||||
if e.Source == "" {
|
||||
e.Source = ticket.SourceManual
|
||||
}
|
||||
if e.Start.IsZero() {
|
||||
e.Start = time.Now().UTC()
|
||||
}
|
||||
if e.End.IsZero() {
|
||||
e.End = e.Start.Add(e.Duration)
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`INSERT INTO time_entries(`+entryCols+`) VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
e.ID, e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End),
|
||||
int64(e.Duration), e.Source, e.Note, e.ExternalID)
|
||||
if err != nil {
|
||||
return ticket.TimeEntry{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Update replaces a time entry.
|
||||
func (s *TimeStore) Update(e ticket.TimeEntry) error {
|
||||
if e.ID == "" {
|
||||
return fmt.Errorf("time entry id is required")
|
||||
}
|
||||
if e.Duration <= 0 {
|
||||
return fmt.Errorf("duration must be positive")
|
||||
}
|
||||
if e.Start.IsZero() {
|
||||
e.Start = time.Now().UTC()
|
||||
}
|
||||
e.End = e.Start.Add(e.Duration)
|
||||
res, err := s.db.SQL.Exec(`
|
||||
UPDATE time_entries SET ticket_id=?, project_id=?, start=?, end=?, duration_ns=?, source=?, note=?, external_id=?
|
||||
WHERE id=?`,
|
||||
e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End), int64(e.Duration), e.Source, e.Note, e.ExternalID, e.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("time entry %s: not found", e.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a time entry.
|
||||
func (s *TimeStore) Delete(id string) error {
|
||||
res, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("time entry %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetargetTicket updates project_id for all entries of a ticket.
|
||||
func (s *TimeStore) RetargetTicket(ticketID, projectID string) error {
|
||||
if ticketID == "" || projectID == "" {
|
||||
return fmt.Errorf("ticketId and projectId are required")
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`UPDATE time_entries SET project_id = ? WHERE ticket_id = ?`, projectID, ticketID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByProject removes all entries for a project.
|
||||
func (s *TimeStore) DeleteByProject(projectID string) error {
|
||||
if projectID == "" {
|
||||
return fmt.Errorf("projectId is required")
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE project_id = ?`, projectID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SumByTicket returns total duration.
|
||||
func (s *TimeStore) SumByTicket(ticketID string) (time.Duration, error) {
|
||||
var sum sql.NullInt64
|
||||
err := s.db.SQL.QueryRow(`SELECT SUM(duration_ns) FROM time_entries WHERE ticket_id = ?`, ticketID).Scan(&sum)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !sum.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return time.Duration(sum.Int64), nil
|
||||
}
|
||||
|
||||
// LoadActiveTimer returns the running timer or nil.
|
||||
func (s *TimeStore) LoadActiveTimer() (*ticket.ActiveTimer, error) {
|
||||
var t ticket.ActiveTimer
|
||||
var started string
|
||||
err := s.db.SQL.QueryRow(`SELECT ticket_id, project_id, started_at FROM active_timer WHERE id = 1`).
|
||||
Scan(&t.TicketID, &t.ProjectID, &started)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.StartedAt = parseTime(started)
|
||||
if t.TicketID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// SaveActiveTimer writes or clears the active timer.
|
||||
func (s *TimeStore) SaveActiveTimer(t *ticket.ActiveTimer) error {
|
||||
if t == nil {
|
||||
_, err := s.db.SQL.Exec(`DELETE FROM active_timer WHERE id = 1`)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO active_timer(id, ticket_id, project_id, started_at) VALUES(1, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET ticket_id=excluded.ticket_id, project_id=excluded.project_id, started_at=excluded.started_at`,
|
||||
t.TicketID, t.ProjectID, formatTime(t.StartedAt))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
TokenScopeRead = "read"
|
||||
TokenScopeWrite = "write" // includes read
|
||||
)
|
||||
|
||||
// ProjectToken is a project-scoped API credential (secret only on create).
|
||||
type ProjectToken struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||||
Token string `json:"token,omitempty"` // raw secret, only set on create
|
||||
}
|
||||
|
||||
// CreateProjectToken issues a new token; raw value is returned once in Token.
|
||||
func (db *DB) CreateProjectToken(projectID, name, scope, createdBy string) (ProjectToken, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
scope = strings.TrimSpace(scope)
|
||||
if projectID == "" || name == "" {
|
||||
return ProjectToken{}, fmt.Errorf("projectId and name required")
|
||||
}
|
||||
if scope != TokenScopeRead && scope != TokenScopeWrite {
|
||||
return ProjectToken{}, fmt.Errorf("scope must be read or write")
|
||||
}
|
||||
raw, err := randomToken()
|
||||
if err != nil {
|
||||
return ProjectToken{}, err
|
||||
}
|
||||
hash := hashToken(raw)
|
||||
t := ProjectToken{
|
||||
ID: uuid.NewString(),
|
||||
ProjectID: projectID,
|
||||
Name: name,
|
||||
Scope: scope,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Token: raw,
|
||||
}
|
||||
_, err = db.SQL.Exec(`
|
||||
INSERT INTO project_tokens(id, project_id, name, token_hash, scope, created_by, created_at)
|
||||
VALUES(?,?,?,?,?,?,?)`,
|
||||
t.ID, t.ProjectID, t.Name, hash, t.Scope, nullStr(createdBy), formatTime(t.CreatedAt))
|
||||
if err != nil {
|
||||
return ProjectToken{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "tm_" + base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func hashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return base64.RawStdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ListProjectTokens returns non-secret token metadata for a project.
|
||||
func (db *DB) ListProjectTokens(projectID string) ([]ProjectToken, error) {
|
||||
rows, err := db.SQL.Query(`
|
||||
SELECT id, project_id, name, scope, created_by, created_at, revoked_at
|
||||
FROM project_tokens WHERE project_id = ?
|
||||
ORDER BY created_at DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ProjectToken
|
||||
for rows.Next() {
|
||||
t, err := scanProjectToken(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ProjectToken{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeProjectToken soft-revokes a token.
|
||||
func (db *DB) RevokeProjectToken(projectID, tokenID string) error {
|
||||
res, err := db.SQL.Exec(`
|
||||
UPDATE project_tokens SET revoked_at = ? WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,
|
||||
formatTime(time.Now().UTC()), tokenID, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("token not found or already revoked")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TokenAuth is the resolved bearer identity.
|
||||
type TokenAuth struct {
|
||||
User User
|
||||
ProjectID string
|
||||
Scope string
|
||||
TokenID string
|
||||
}
|
||||
|
||||
// ResolveBearerToken validates a raw API token.
|
||||
func (db *DB) ResolveBearerToken(raw string) (TokenAuth, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return TokenAuth{}, fmt.Errorf("empty token")
|
||||
}
|
||||
hash := hashToken(raw)
|
||||
var (
|
||||
tokenID, projectID, scope string
|
||||
createdBy sql.NullString
|
||||
revoked sql.NullString
|
||||
)
|
||||
err := db.SQL.QueryRow(`
|
||||
SELECT id, project_id, scope, created_by, revoked_at
|
||||
FROM project_tokens WHERE token_hash = ?`, hash).
|
||||
Scan(&tokenID, &projectID, &scope, &createdBy, &revoked)
|
||||
if err == sql.ErrNoRows {
|
||||
return TokenAuth{}, fmt.Errorf("invalid token")
|
||||
}
|
||||
if err != nil {
|
||||
return TokenAuth{}, err
|
||||
}
|
||||
if revoked.Valid && revoked.String != "" {
|
||||
return TokenAuth{}, fmt.Errorf("token revoked")
|
||||
}
|
||||
var u User
|
||||
if createdBy.Valid && createdBy.String != "" {
|
||||
u, err = db.GetUserByID(createdBy.String)
|
||||
if err != nil {
|
||||
// creator deleted — synthetic limited principal
|
||||
u = User{ID: createdBy.String, Username: "token", IsAdmin: false}
|
||||
}
|
||||
} else {
|
||||
u = User{ID: "token:" + tokenID, Username: "token", IsAdmin: false}
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
return TokenAuth{User: u, ProjectID: projectID, Scope: scope, TokenID: tokenID}, nil
|
||||
}
|
||||
|
||||
// TokenAllows reports whether scope permits the operation (write implies read).
|
||||
func TokenAllows(scope, need string) bool {
|
||||
if need == TokenScopeRead {
|
||||
return scope == TokenScopeRead || scope == TokenScopeWrite
|
||||
}
|
||||
return scope == TokenScopeWrite
|
||||
}
|
||||
|
||||
func scanProjectToken(scanner interface{ Scan(dest ...any) error }) (ProjectToken, error) {
|
||||
var t ProjectToken
|
||||
var createdBy sql.NullString
|
||||
var created, revoked sql.NullString
|
||||
err := scanner.Scan(&t.ID, &t.ProjectID, &t.Name, &t.Scope, &createdBy, &created, &revoked)
|
||||
if err != nil {
|
||||
return ProjectToken{}, err
|
||||
}
|
||||
if createdBy.Valid {
|
||||
t.CreatedBy = createdBy.String
|
||||
}
|
||||
t.CreatedAt = parseTime(created.String)
|
||||
if revoked.Valid && revoked.String != "" {
|
||||
tm := parseTime(revoked.String)
|
||||
t.RevokedAt = &tm
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user and their sessions.
|
||||
func (db *DB) DeleteUser(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("user id required")
|
||||
}
|
||||
res, err := db.SQL.Exec(`DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserAdmin updates the is_admin flag.
|
||||
func (db *DB) SetUserAdmin(id string, isAdmin bool) error {
|
||||
res, err := db.SQL.Exec(`UPDATE users SET is_admin = ? WHERE id = ?`, boolInt(isAdmin), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user