Compare commits

..

5 Commits

Author SHA1 Message Date
hyung-hwan 6c4572d27f some db/transaction code refactored 2026-06-19 23:06:44 +09:00
hyung-hwan 858afe5371 refactored withStoreTransaction() by removing duplicate code 2026-06-19 19:00:03 +09:00
hyung-hwan 11d2d80d77 updated codit-data-browser to use the common routine for db access 2026-06-19 15:45:17 +09:00
hyung-hwan e1c056ebc4 checklist input enhancement 2026-06-19 08:00:16 +09:00
hyung-hwan 3a2d962472 enhanced CreateSession failure.
enhanced to handle unknown status or sprint in the boardchartview
2026-06-18 21:54:50 +09:00
24 changed files with 577 additions and 470 deletions
+5 -1
View File
@@ -9,6 +9,7 @@ import "sort"
import "strconv" import "strconv"
import "strings" import "strings"
import storedb "codit/internal/db"
import _ "modernc.org/sqlite" import _ "modernc.org/sqlite"
import "github.com/gdamore/tcell/v2" import "github.com/gdamore/tcell/v2"
import "github.com/rivo/tview" import "github.com/rivo/tview"
@@ -72,12 +73,15 @@ func main() {
func newBrowser(dbPath string, dataDir string) (*browser, error) { func newBrowser(dbPath string, dataDir string) (*browser, error) {
var err error var err error
var store *storedb.Store
var db *sql.DB var db *sql.DB
var br *browser var br *browser
db, err = sql.Open("sqlite", "file:"+dbPath+"?_pragma=foreign_keys(1)")
store, err = storedb.Open("sqlite", dbPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
db = store.DB
br = &browser{ br = &browser{
DB: db, DB: db,
DataDir: filepath.Clean(dataDir), DataDir: filepath.Clean(dataDir),
+6 -14
View File
@@ -271,7 +271,7 @@ func (s *Store) CountAuthProviderUsers(id string) (int, error) {
} }
func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) { func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
var tx *sql.Tx var tx txExecutor
var err error var err error
var owned bool var owned bool
var now int64 var now int64
@@ -282,9 +282,7 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
if strings.TrimSpace(item.ID) == "" { if strings.TrimSpace(item.ID) == "" {
item.ID, err = util.NewID() item.ID, err = util.NewID()
if err != nil { if err != nil { return item, err }
return item, err
}
} }
if item.Type == AuthProviderTypeLDAP && strings.TrimSpace(item.LDAPUserFilter) == "" { if item.Type == AuthProviderTypeLDAP && strings.TrimSpace(item.LDAPUserFilter) == "" {
item.LDAPUserFilter = "(uid={username})" item.LDAPUserFilter = "(uid={username})"
@@ -293,14 +291,10 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
item.OIDCScopes = "openid profile email" item.OIDCScopes = "openid profile email"
} }
item.GroupSyncMode, err = normalizeAuthProviderGroupSyncMode(item.GroupSyncMode) item.GroupSyncMode, err = normalizeAuthProviderGroupSyncMode(item.GroupSyncMode)
if err != nil { if err != nil { return item, err }
return item, err
}
tx, owned, err = s.begin() tx, owned, err = s.begin()
if err != nil { if err != nil { return item, err }
return item, err
}
_, err = tx.Exec(`INSERT INTO auth_providers ( _, err = tx.Exec(`INSERT INTO auth_providers (
public_id, name, type, enabled, public_id, name, type, enabled,
@@ -338,7 +332,7 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
} }
func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) { func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
var tx *sql.Tx var tx txExecutor
var err error var err error
var owned bool var owned bool
var now int64 var now int64
@@ -358,9 +352,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
} }
tx, owned, err = s.begin() tx, owned, err = s.begin()
if err != nil { if err != nil { return item, err }
return item, err
}
_, err = tx.Exec(`UPDATE auth_providers SET _, err = tx.Exec(`UPDATE auth_providers SET
name = ?, enabled = ?, name = ?, enabled = ?,
+2 -2
View File
@@ -307,7 +307,7 @@ func (s *Store) ReorderBlockChecklistItems(boardID, blockID string, ids []string
var internalBlockID int64 var internalBlockID int64
var err error var err error
var now int64 var now int64
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var i int var i int
var id string var id string
@@ -704,7 +704,7 @@ func (s *Store) listBlockAssigneesForBoard(boardID string) (map[string][]models.
func (s *Store) UpsertBlockProperties(boardID, blockID string, props models.BlockProperties) (models.BlockProperties, error) { func (s *Store) UpsertBlockProperties(boardID, blockID string, props models.BlockProperties) (models.BlockProperties, error) {
var internalBlockID int64 var internalBlockID int64
var err error var err error
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var assigneeIDs []string var assigneeIDs []string
var i int var i int
+8 -8
View File
@@ -43,7 +43,7 @@ func (s *Store) CreateBoard(board models.Board) (models.Board, error) {
var id string var id string
var err error var err error
var nowUnix int64 var nowUnix int64
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
if board.ID == "" { if board.ID == "" {
@@ -322,7 +322,7 @@ func scanBlock(row interface{ Scan(...any) error }, bl *models.Block) error {
// resolveParentBlockID returns the internal row ID for a parent block constrained // resolveParentBlockID returns the internal row ID for a parent block constrained
// to the same board. Returns 0 when parentPublicID is "". Returns sql.ErrNoRows // to the same board. Returns 0 when parentPublicID is "". Returns sql.ErrNoRows
// when the block doesn't exist, is deleted, or belongs to a different board. // when the block doesn't exist, is deleted, or belongs to a different board.
func resolveParentBlockID(tx *sql.Tx, boardPublicID, parentPublicID string) (int64, error) { func resolveParentBlockID(tx txExecutor, boardPublicID, parentPublicID string) (int64, error) {
var parentID int64 var parentID int64
var row *sql.Row var row *sql.Row
if parentPublicID == "" { if parentPublicID == "" {
@@ -338,7 +338,7 @@ func resolveParentBlockID(tx *sql.Tx, boardPublicID, parentPublicID string) (int
// hasCycleAfterReparent returns true when assigning newParentID as the parent // hasCycleAfterReparent returns true when assigning newParentID as the parent
// of blockID would create a cycle (including the self-parent case). // of blockID would create a cycle (including the self-parent case).
func hasCycleAfterReparent(tx *sql.Tx, blockID, newParentID int64) (bool, error) { func hasCycleAfterReparent(tx txExecutor, blockID, newParentID int64) (bool, error) {
var count int var count int
var row *sql.Row var row *sql.Row
if newParentID == 0 { if newParentID == 0 {
@@ -364,7 +364,7 @@ func hasCycleAfterReparent(tx *sql.Tx, blockID, newParentID int64) (bool, error)
return count > 0, nil return count > 0, nil
} }
func (s *Store) insertBlockHistory(tx *sql.Tx, blockID int64, insertAt int64) error { func (s *Store) insertBlockHistory(tx txExecutor, blockID int64, insertAt int64) error {
var err error var err error
_, err = tx.Exec(` _, err = tx.Exec(`
INSERT INTO blocks_history INSERT INTO blocks_history
@@ -381,7 +381,7 @@ func (s *Store) CreateBlock(block models.Block) (models.Block, error) {
var id string var id string
var err error var err error
var nowUnix int64 var nowUnix int64
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var result sql.Result var result sql.Result
var blockID int64 var blockID int64
@@ -515,7 +515,7 @@ func (s *Store) ListBlocksByType(boardID string, blockType string) ([]models.Blo
func (s *Store) UpdateBlock(block models.Block) (models.Block, error) { func (s *Store) UpdateBlock(block models.Block) (models.Block, error) {
var err error var err error
var nowUnix int64 var nowUnix int64
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var row *sql.Row var row *sql.Row
var blockID int64 var blockID int64
@@ -594,7 +594,7 @@ func (s *Store) UpdateBlock(block models.Block) (models.Block, error) {
func (s *Store) PatchBlocks(boardID string, patches []models.BlockPatch, updatedBy string) error { func (s *Store) PatchBlocks(boardID string, patches []models.BlockPatch, updatedBy string) error {
var err error var err error
var nowUnix int64 var nowUnix int64
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var i int var i int
var p models.BlockPatch var p models.BlockPatch
@@ -704,7 +704,7 @@ func (s *Store) PatchBlocks(boardID string, patches []models.BlockPatch, updated
func (s *Store) DeleteBlock(boardID string, id string, updatedBy string) error { func (s *Store) DeleteBlock(boardID string, id string, updatedBy string) error {
var err error var err error
var nowUnix int64 var nowUnix int64
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var blockID int64 var blockID int64
var row *sql.Row var row *sql.Row
+115 -49
View File
@@ -9,51 +9,102 @@ import "path/filepath"
import "sort" import "sort"
import "strings" import "strings"
type Store struct {
DB *sql.DB
tx *sql.Tx
}
type sqlExecutor interface { type sqlExecutor interface {
Exec(query string, args ...any) (sql.Result, error) Exec(query string, args ...any) (sql.Result, error)
Query(query string, args ...any) (*sql.Rows, error) Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row QueryRow(query string, args ...any) *sql.Row
} }
func Open(driver, dsn string) (*Store, error) { type txExecutor interface {
sqlExecutor
Commit() error
Rollback() error
}
type Store struct {
DB *sql.DB
tx txExecutor
imm bool // whether BEGIN IMMEDIATE is supported. For sqlite.
}
type manualTx struct {
conn *sql.Conn
ctx context.Context
}
// ------------------------------------------------------
func (tx *manualTx) Exec(query string, args ...any) (sql.Result, error) {
return tx.conn.ExecContext(tx.ctx, query, args...)
}
func (tx *manualTx) Query(query string, args ...any) (*sql.Rows, error) {
return tx.conn.QueryContext(tx.ctx, query, args...)
}
func (tx *manualTx) QueryRow(query string, args ...any) *sql.Row {
return tx.conn.QueryRowContext(tx.ctx, query, args...)
}
func (tx *manualTx) Commit() error {
var err error
if tx.conn != nil {
// pass context.Background() to make COMMIT resist connection teardown/cancellation
//_, err = tx.conn.ExecContext(tx.ctx, "COMMIT")
_, err = tx.conn.ExecContext(context.Background(), "COMMIT")
_ = tx.conn.Close()
tx.conn = nil
}
return err
}
func (tx *manualTx) Rollback() error {
var err error
if tx.conn != nil {
// pass context.Background() to make ROLLBACK resist connection teardown/cancellation
//_, err = tx.conn.ExecContext(tx.ctx, "ROLLBACK")
_, err = tx.conn.ExecContext(context.Background(), "ROLLBACK")
_ = tx.conn.Close()
tx.conn = nil
}
return err
}
// ------------------------------------------------------
func Open(driver string, dsn string) (*Store, error) {
var db *sql.DB var db *sql.DB
var drv string var drv string
var imm bool
var err error var err error
drv = driverName(driver) drv = driverName(driver)
if drv == "sqlite" { if drv == "sqlite" {
dsn = appendSQLitePragmas(dsn, map[string]string{ dsn = appendSQLitePragmas(dsn, map[string]string{
"busy_timeout": "10000", "busy_timeout": "10000", // make this configurable too
"journal_mode": "WAL", "journal_mode": "WAL",
"foreign_keys": "1", "foreign_keys": "1",
}) })
imm = true
} }
db, err = sql.Open(drv, dsn) db, err = sql.Open(drv, dsn)
if err != nil { if err != nil { goto oops }
goto oops
}
//if drv == "sqlite" { if drv == "sqlite" {
// db.SetMaxOpenConns(10) db.SetMaxOpenConns(64) // TODO: make this configurable
// db.SetMaxIdleConns(1) // db.SetMaxIdleConns(8)
//} // db.SetConnMaxIdleTime(1 * time.Minute)
}
err = db.Ping() err = db.Ping()
if err != nil { if err != nil { goto oops }
goto oops
}
return &Store{DB: db}, nil // this is usually a base store without a transaction started.
// usually to be used as a base connection
return &Store{DB: db, tx: nil, imm: imm}, nil
oops: oops:
if db != nil { if db != nil { db.Close() }
db.Close()
}
return nil, err return nil, err
} }
@@ -149,57 +200,72 @@ func (s *Store) BeginStore(ctx context.Context) (*Store, error) {
var tx *sql.Tx var tx *sql.Tx
var err error var err error
if s == nil { if s == nil { return nil, fmt.Errorf("nil store") }
return nil, fmt.Errorf("nil store") if s.tx != nil { return s, nil } // more than one BeginStore calls, return the existing tx
}
if s.tx != nil {
return s, nil
}
tx, err = s.DB.BeginTx(ctx, nil) tx, err = s.DB.BeginTx(ctx, nil)
if err != nil { return nil, err }
// return a new store off the current base db store
return &Store{DB: s.DB, tx: tx, imm: s.imm}, nil
}
func (s *Store) BeginImmediateStore(ctx context.Context) (*Store, error) {
var conn *sql.Conn
var tx *manualTx
var err error
if s.tx != nil { return s, nil }
if !s.imm { return s.BeginStore(ctx) }
conn, err = s.DB.Conn(ctx)
if err != nil { return nil, err }
_, err = conn.ExecContext(ctx, "BEGIN IMMEDIATE")
if err != nil { if err != nil {
_ = conn.Close()
return nil, err return nil, err
} }
return &Store{DB: s.DB, tx: tx}, nil
tx = &manualTx{
conn: conn,
ctx: ctx,
}
return &Store{DB: s.DB, tx: tx, imm: s.imm}, nil
} }
func (s *Store) Commit() error { func (s *Store) Commit() error {
if s == nil || s.tx == nil { if s == nil || s.tx == nil { return nil }
return nil
}
return s.tx.Commit() return s.tx.Commit()
} }
func (s *Store) Rollback() error { func (s *Store) Rollback() error {
if s == nil || s.tx == nil { if s == nil || s.tx == nil { return nil }
return nil
}
return s.tx.Rollback() return s.tx.Rollback()
} }
func (s *Store) begin() (*sql.Tx, bool, error) { func (s *Store) begin() (txExecutor, bool, error) {
var tx *sql.Tx var tx *sql.Tx
var err error var err error
if s != nil && s.tx != nil { // return an existing transaction object that's already started by
return s.tx, false, nil // BeginStore() and BeginImmediateStore().
} // the second return value is false because the transaction is not started here.
if s != nil && s.tx != nil { return s.tx, false, nil }
tx, err = s.DB.Begin() tx, err = s.DB.Begin()
if err != nil { if err != nil { return nil, false, err }
return nil, false, err
} // the second return value is true to indicate that the transaction has been started here
return tx, true, nil return tx, true, nil
} }
func rollbackIfOwned(tx *sql.Tx, owned bool) { func rollbackIfOwned(tx txExecutor, owned bool) {
if tx != nil && owned { if tx != nil && owned { _ = tx.Rollback() }
_ = tx.Rollback()
}
} }
func commitIfOwned(tx *sql.Tx, owned bool) error { func commitIfOwned(tx txExecutor, owned bool) error {
if !owned { if !owned { return nil }
return nil
}
return tx.Commit() return tx.Commit()
} }
+29 -30
View File
@@ -128,21 +128,21 @@ func (s *Store) UpdateUserGroup(item models.UserGroup) (models.UserGroup, error)
} }
func (s *Store) DeleteUserGroup(groupID string) error { func (s *Store) DeleteUserGroup(groupID string) error {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var group models.UserGroup var group models.UserGroup
group, err = s.GetUserGroup(groupID) group, err = s.GetUserGroup(groupID)
if err != nil { if err != nil { return err }
return err
}
if NormalizeUserGroupScope(group.Scope) == UserGroupScopeAllUsers { if NormalizeUserGroupScope(group.Scope) == UserGroupScopeAllUsers {
return errors.New("all users group cannot be deleted") return errors.New("all users group cannot be deleted")
} }
tx, owned, err = s.begin() tx, owned, err = s.begin()
if err != nil { if err != nil { return err }
return err
}
_, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'group' AND subject_id = (SELECT id FROM user_groups WHERE public_id = ?)`, strings.TrimSpace(groupID)) _, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'group' AND subject_id = (SELECT id FROM user_groups WHERE public_id = ?)`, strings.TrimSpace(groupID))
if err != nil { if err != nil {
rollbackIfOwned(tx, owned) rollbackIfOwned(tx, owned)
@@ -163,11 +163,8 @@ func (s *Store) DeleteUserGroup(groupID string) error {
rollbackIfOwned(tx, owned) rollbackIfOwned(tx, owned)
return err return err
} }
err = commitIfOwned(tx, owned)
if err != nil { return commitIfOwned(tx, owned)
return err
}
return err
} }
func (s *Store) ListUserGroupMembers(groupID string) ([]models.UserGroupMember, error) { func (s *Store) ListUserGroupMembers(groupID string) ([]models.UserGroupMember, error) {
@@ -215,30 +212,33 @@ func (s *Store) AddUserGroupMember(groupID string, userID string) error {
var now int64 var now int64
var err error var err error
var group models.UserGroup var group models.UserGroup
group, err = s.GetUserGroup(groupID) group, err = s.GetUserGroup(groupID)
if err != nil { if err != nil { return err }
return err
}
if NormalizeUserGroupScope(group.Scope) != UserGroupScopeExplicit { if NormalizeUserGroupScope(group.Scope) != UserGroupScopeExplicit {
return errors.New("virtual groups do not accept explicit members") return errors.New("virtual groups do not accept explicit members")
} }
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
_, err = s.Exec(`INSERT OR IGNORE INTO user_group_members (group_id, user_id, created_at) _, err = s.Exec(`INSERT OR IGNORE INTO user_group_members (group_id, user_id, created_at)
VALUES ((SELECT id FROM user_groups WHERE public_id = ?), (SELECT id FROM users WHERE public_id = ?), ?)`, VALUES ((SELECT id FROM user_groups WHERE public_id = ?), (SELECT id FROM users WHERE public_id = ?), ?)`,
strings.TrimSpace(groupID), strings.TrimSpace(userID), now) strings.TrimSpace(groupID), strings.TrimSpace(userID), now)
return err return err
} }
func (s *Store) RemoveUserGroupMember(groupID string, userID string) error { func (s *Store) RemoveUserGroupMember(groupID string, userID string) error {
var err error var err error
var group models.UserGroup var group models.UserGroup
group, err = s.GetUserGroup(groupID) group, err = s.GetUserGroup(groupID)
if err != nil { if err != nil { return err }
return err
}
if NormalizeUserGroupScope(group.Scope) != UserGroupScopeExplicit { if NormalizeUserGroupScope(group.Scope) != UserGroupScopeExplicit {
return errors.New("virtual groups do not have explicit members") return errors.New("virtual groups do not have explicit members")
} }
_, err = s.Exec(`DELETE FROM user_group_members _, err = s.Exec(`DELETE FROM user_group_members
WHERE group_id = (SELECT id FROM user_groups WHERE public_id = ?) WHERE group_id = (SELECT id FROM user_groups WHERE public_id = ?)
AND user_id = (SELECT id FROM users WHERE public_id = ?)`, AND user_id = (SELECT id FROM users WHERE public_id = ?)`,
@@ -253,15 +253,15 @@ func (s *Store) ListProjectGroupRoles(projectID string) ([]models.ProjectGroupRo
var err error var err error
var items []models.ProjectGroupRole var items []models.ProjectGroupRole
var item models.ProjectGroupRole var item models.ProjectGroupRole
rows, err = s.Query(`SELECT p.public_id, g.public_id, b.role, b.created_at rows, err = s.Query(`SELECT p.public_id, g.public_id, b.role, b.created_at
FROM project_role_bindings b FROM project_role_bindings b
JOIN projects p ON p.id = b.project_id JOIN projects p ON p.id = b.project_id
JOIN user_groups g ON g.id = b.subject_id JOIN user_groups g ON g.id = b.subject_id
WHERE p.public_id = ? AND b.subject_type = 'group' WHERE p.public_id = ? AND b.subject_type = 'group'
ORDER BY g.name`, strings.TrimSpace(projectID)) ORDER BY g.name`, strings.TrimSpace(projectID))
if err != nil { if err != nil { return nil, err }
return nil, err
}
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
err = rows.Scan(&item.ProjectID, &item.GroupID, &item.Role, &item.CreatedAt) err = rows.Scan(&item.ProjectID, &item.GroupID, &item.Role, &item.CreatedAt)
@@ -270,10 +270,10 @@ func (s *Store) ListProjectGroupRoles(projectID string) ([]models.ProjectGroupRo
} }
items = append(items, item) items = append(items, item)
} }
err = rows.Err() err = rows.Err()
if err != nil { if err != nil { return nil, err }
return nil, err
}
return items, nil return items, nil
} }
@@ -283,6 +283,7 @@ func (s *Store) UpsertProjectGroupRole(projectID string, groupID string, role st
var result sql.Result var result sql.Result
var rowsAffected int64 var rowsAffected int64
var err error var err error
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
result, err = s.Exec(`INSERT INTO project_role_bindings (project_id, subject_type, subject_id, role, created_at, updated_at) result, err = s.Exec(`INSERT INTO project_role_bindings (project_id, subject_type, subject_id, role, created_at, updated_at)
SELECT (SELECT id FROM projects WHERE public_id = ?), 'group', g.id, ?, ?, ? SELECT (SELECT id FROM projects WHERE public_id = ?), 'group', g.id, ?, ?, ?
@@ -294,12 +295,10 @@ func (s *Store) UpsertProjectGroupRole(projectID string, groupID string, role st
return item, err return item, err
} }
rowsAffected, err = result.RowsAffected() rowsAffected, err = result.RowsAffected()
if err != nil { if err != nil { return item, err }
return item, err
} if rowsAffected <= 0 { return item, errors.New("group not found") }
if rowsAffected <= 0 {
return item, errors.New("group not found")
}
item.ProjectID = strings.TrimSpace(projectID) item.ProjectID = strings.TrimSpace(projectID)
item.GroupID = strings.TrimSpace(groupID) item.GroupID = strings.TrimSpace(groupID)
item.Role = strings.TrimSpace(role) item.Role = strings.TrimSpace(role)
+3 -3
View File
@@ -313,7 +313,7 @@ func (s *Store) GetActivePKIClientProfileForUser(profileID string, userID string
} }
func (s *Store) CreatePKIClientProfile(item models.PKIClientProfile) (models.PKIClientProfile, error) { func (s *Store) CreatePKIClientProfile(item models.PKIClientProfile) (models.PKIClientProfile, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var now int64 var now int64
@@ -413,7 +413,7 @@ func (s *Store) CreatePKIClientProfile(item models.PKIClientProfile) (models.PKI
} }
func (s *Store) UpdatePKIClientProfile(item models.PKIClientProfile) (models.PKIClientProfile, error) { func (s *Store) UpdatePKIClientProfile(item models.PKIClientProfile) (models.PKIClientProfile, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var now int64 var now int64
@@ -684,7 +684,7 @@ func (s *Store) listPKIClientProfileTargets(profileID string) ([]models.PKIClien
return items, nil return items, nil
} }
func insertPKIClientProfileTargetTx(tx *sql.Tx, profileID string, targetType string, targetID string, createdAt int64) error { func insertPKIClientProfileTargetTx(tx txExecutor, profileID string, targetType string, targetID string, createdAt int64) error {
var err error var err error
targetType = strings.ToLower(strings.TrimSpace(targetType)) targetType = strings.ToLower(strings.TrimSpace(targetType))
targetID = strings.TrimSpace(targetID) targetID = strings.TrimSpace(targetID)
+2 -2
View File
@@ -128,7 +128,7 @@ func (s *Store) DeletePKICA(id string) error {
} }
func (s *Store) DeletePKICASubtree(id string) error { func (s *Store) DeletePKICASubtree(id string) error {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var rows *sql.Rows var rows *sql.Rows
var err error var err error
@@ -205,7 +205,7 @@ func (s *Store) DeletePKICASubtree(id string) error {
} }
func (s *Store) NextPKICASerial(caID string) (int64, error) { func (s *Store) NextPKICASerial(caID string) (int64, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var row *sql.Row var row *sql.Row
+3 -4
View File
@@ -76,7 +76,7 @@ func (s *Store) SetPrincipalAPIKeyDisabled(principalID string, id string, disabl
func (s *Store) GetPrincipalByAPIKeyHash(tokenHash string) (models.ServicePrincipal, error) { func (s *Store) GetPrincipalByAPIKeyHash(tokenHash string) (models.ServicePrincipal, error) {
var principal models.ServicePrincipal var principal models.ServicePrincipal
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var row *sql.Row var row *sql.Row
var keyID int64 var keyID int64
@@ -86,9 +86,8 @@ func (s *Store) GetPrincipalByAPIKeyHash(tokenHash string) (models.ServicePrinci
var err error var err error
tx, owned, err = s.begin() tx, owned, err = s.begin()
if err != nil { if err != nil { return principal, err }
return principal, err
}
now = time.Now().UTC() now = time.Now().UTC()
currentUnix = now.Unix() currentUnix = now.Unix()
row = tx.QueryRow(` row = tx.QueryRow(`
+8 -9
View File
@@ -73,13 +73,13 @@ func (s *Store) UpdateServicePrincipal(item models.ServicePrincipal) error {
} }
func (s *Store) DeleteServicePrincipal(id string) error { func (s *Store) DeleteServicePrincipal(id string) error {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
tx, owned, err = s.begin() tx, owned, err = s.begin()
if err != nil { if err != nil { return err }
return err
}
_, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'principal' AND subject_id = (SELECT id FROM service_principals WHERE public_id = ?)`, id) _, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'principal' AND subject_id = (SELECT id FROM service_principals WHERE public_id = ?)`, id)
if err != nil { if err != nil {
rollbackIfOwned(tx, owned) rollbackIfOwned(tx, owned)
@@ -95,8 +95,8 @@ func (s *Store) DeleteServicePrincipal(id string) error {
rollbackIfOwned(tx, owned) rollbackIfOwned(tx, owned)
return err return err
} }
err = commitIfOwned(tx, owned)
return err return commitIfOwned(tx, owned)
} }
func (s *Store) ListCertPrincipalBindings() ([]models.CertPrincipalBinding, error) { func (s *Store) ListCertPrincipalBindings() ([]models.CertPrincipalBinding, error) {
@@ -119,10 +119,9 @@ func (s *Store) ListCertPrincipalBindings() ([]models.CertPrincipalBinding, erro
} }
items = append(items, item) items = append(items, item)
} }
err = rows.Err() err = rows.Err()
if err != nil { if err != nil { return nil, err }
return nil, err
}
return items, nil return items, nil
} }
+9 -13
View File
@@ -141,7 +141,7 @@ func (s *Store) TryStartRPMMirrorTask(repoID string, path string, now int64) (bo
} }
func (s *Store) TryStartRPMMirrorRun(repoID string, path string, startedAt int64) (string, bool, error) { func (s *Store) TryStartRPMMirrorRun(repoID string, path string, startedAt int64) (string, bool, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var res sql.Result var res sql.Result
var rows int64 var rows int64
@@ -192,7 +192,7 @@ func (s *Store) UpdateRPMMirrorTaskProgress(repoID string, path string, step str
} }
func (s *Store) FinishRPMMirrorTask(repoID string, path string, success bool, revision string, errMsg string) error { func (s *Store) FinishRPMMirrorTask(repoID string, path string, success bool, revision string, errMsg string) error {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var now int64 var now int64
var status string var status string
@@ -237,7 +237,7 @@ func (s *Store) FinishRPMMirrorTask(repoID string, path string, success bool, re
} }
func (s *Store) FinishRPMMirrorTaskRun(repoID string, path string, runID string, success bool, finishedAt int64, step string, total int64, done int64, failed int64, deleted int64, revision string, errMsg string) error { func (s *Store) FinishRPMMirrorTaskRun(repoID string, path string, runID string, success bool, finishedAt int64, step string, total int64, done int64, failed int64, deleted int64, revision string, errMsg string) error {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var interval int64 var interval int64
var nextSync int64 var nextSync int64
@@ -310,7 +310,7 @@ func (s *Store) SetRPMMirrorSyncEnabled(repoID string, path string, enabled bool
} }
func (s *Store) ResetRunningRPMMirrorTasks() error { func (s *Store) ResetRunningRPMMirrorTasks() error {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var now int64 var now int64
var err error var err error
@@ -541,16 +541,16 @@ func (s *Store) DeleteRPMRepoDirSubtree(repoID string, path string) error {
} }
func (s *Store) MoveRPMRepoDir(repoID string, oldPath string, newPath string) error { func (s *Store) MoveRPMRepoDir(repoID string, oldPath string, newPath string) error {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var now int64 var now int64
var oldPrefix string var oldPrefix string
var newPrefix string var newPrefix string
var err error var err error
tx, owned, err = s.begin() tx, owned, err = s.begin()
if err != nil { if err != nil { return err }
return err
}
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
oldPrefix = oldPath + "/" oldPrefix = oldPath + "/"
newPrefix = newPath + "/" newPrefix = newPath + "/"
@@ -569,9 +569,5 @@ func (s *Store) MoveRPMRepoDir(repoID string, oldPath string, newPath string) er
rollbackIfOwned(tx, owned) rollbackIfOwned(tx, owned)
return err return err
} }
err = commitIfOwned(tx, owned) return commitIfOwned(tx, owned)
if err != nil {
return err
}
return nil
} }
+6 -6
View File
@@ -615,7 +615,7 @@ func (s *Store) GetSSHAccessProfile(id string) (models.SSHAccessProfile, error)
} }
func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSHAccessProfile, error) { func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSHAccessProfile, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var now int64 var now int64
@@ -777,7 +777,7 @@ func (s *Store) CreateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
} }
func (s *Store) UpdateSSHAccessProfile(item models.SSHAccessProfile) (models.SSHAccessProfile, error) { func (s *Store) UpdateSSHAccessProfile(item models.SSHAccessProfile) (models.SSHAccessProfile, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var grantIDsJSON string var grantIDsJSON string
@@ -911,7 +911,7 @@ func (s *Store) UpdateSSHAccessProfile(item models.SSHAccessProfile) (models.SSH
} }
func (s *Store) DeleteSSHAccessProfile(id string) error { func (s *Store) DeleteSSHAccessProfile(id string) error {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var row *sql.Row var row *sql.Row
var secretID sql.NullString var secretID sql.NullString
@@ -2169,7 +2169,7 @@ func (s *Store) CountSSHFileTransfersFiltered(query string, status string, sessi
return total, nil return total, nil
} }
func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, error) { func createSSHSecretTx(tx txExecutor, item models.SSHSecret) (models.SSHSecret, error) {
var err error var err error
var now int64 var now int64
@@ -2212,7 +2212,7 @@ func createSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, err
return item, err return item, err
} }
func updateSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, error) { func updateSSHSecretTx(tx txExecutor, item models.SSHSecret) (models.SSHSecret, error) {
var err error var err error
item.UpdatedAt = time.Now().UTC().Unix() item.UpdatedAt = time.Now().UTC().Unix()
@@ -2235,7 +2235,7 @@ func updateSSHSecretTx(tx *sql.Tx, item models.SSHSecret) (models.SSHSecret, err
return item, err return item, err
} }
func insertSSHAccessProfileTargetTx(tx *sql.Tx, profileID string, targetType string, targetID string, createdAt int64) error { func insertSSHAccessProfileTargetTx(tx txExecutor, profileID string, targetType string, targetID string, createdAt int64) error {
var err error var err error
var id string var id string
+1 -1
View File
@@ -110,7 +110,7 @@ func (s *Store) GetSSHCredentialForUser(userID string, id string) (models.SSHCre
} }
func (s *Store) CreateSSHCredential(item models.SSHCredential, secret models.SSHSecret) (models.SSHCredential, error) { func (s *Store) CreateSSHCredential(item models.SSHCredential, secret models.SSHSecret) (models.SSHCredential, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var now int64 var now int64
+3 -3
View File
@@ -240,7 +240,7 @@ func (s *Store) GetSSHPrincipalGrant(id string) (models.SSHPrincipalGrant, error
} }
func (s *Store) CreateSSHPrincipalGrant(item models.SSHPrincipalGrant) (models.SSHPrincipalGrant, error) { func (s *Store) CreateSSHPrincipalGrant(item models.SSHPrincipalGrant) (models.SSHPrincipalGrant, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var now int64 var now int64
@@ -336,7 +336,7 @@ func (s *Store) CreateSSHPrincipalGrant(item models.SSHPrincipalGrant) (models.S
} }
func (s *Store) UpdateSSHPrincipalGrant(item models.SSHPrincipalGrant) (models.SSHPrincipalGrant, error) { func (s *Store) UpdateSSHPrincipalGrant(item models.SSHPrincipalGrant) (models.SSHPrincipalGrant, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var now int64 var now int64
@@ -538,7 +538,7 @@ func (s *Store) listSSHPrincipalGrantTargets(grantID string) ([]models.SSHPrinci
return items, nil return items, nil
} }
func insertSSHPrincipalGrantTargetTx(tx *sql.Tx, grantID string, targetType string, targetID string, createdAt int64) error { func insertSSHPrincipalGrantTargetTx(tx txExecutor, grantID string, targetType string, targetID string, createdAt int64) error {
var err error var err error
var result sql.Result var result sql.Result
var rowsAffected int64 var rowsAffected int64
+2 -2
View File
@@ -114,7 +114,7 @@ func (s *Store) ListSSHServersForGroup(groupID string) ([]models.SSHServer, erro
} }
func (s *Store) CreateSSHServerGroup(item models.SSHServerGroup) (models.SSHServerGroup, error) { func (s *Store) CreateSSHServerGroup(item models.SSHServerGroup) (models.SSHServerGroup, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var now int64 var now int64
@@ -141,7 +141,7 @@ func (s *Store) CreateSSHServerGroup(item models.SSHServerGroup) (models.SSHServ
} }
func (s *Store) UpdateSSHServerGroup(item models.SSHServerGroup) (models.SSHServerGroup, error) { func (s *Store) UpdateSSHServerGroup(item models.SSHServerGroup) (models.SSHServerGroup, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var now int64 var now int64
+1 -1
View File
@@ -146,7 +146,7 @@ func (s *Store) GetSSHUserCAForUser(id string) (models.SSHUserCA, error) {
} }
func (s *Store) NextSSHUserCASerial(id string) (uint64, error) { func (s *Store) NextSSHUserCASerial(id string) (uint64, error) {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var row *sql.Row var row *sql.Row
var serial int64 var serial int64
+16 -258
View File
@@ -8,196 +8,6 @@ import "time"
import "codit/internal/models" import "codit/internal/models"
import "codit/internal/util" import "codit/internal/util"
func (s *Store) CreateUser(user models.User, passwordHash string) (models.User, error) {
var id string
var err error
var nowUnix int64
if user.ID == "" {
id, err = util.NewID()
if err != nil {
return user, err
}
user.ID = id
}
nowUnix = time.Now().UTC().Unix()
user.CreatedAt = nowUnix
user.UpdatedAt = nowUnix
_, err = s.Exec(`
INSERT INTO users (public_id, username, display_name, email, password_hash, is_admin, disabled, auth_provider_id, totp_required, external_subject, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM auth_providers WHERE public_id = ?) END, ?, ?, ?, ?)
`, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.Disabled, user.AuthProviderID, user.AuthProviderID, user.TOTPRequired, user.ExternalSubject, nowUnix, nowUnix)
return user, err
}
func (s *Store) UpdateUser(user models.User) error {
var err error
var nowUnix int64
nowUnix = time.Now().UTC().Unix()
user.UpdatedAt = nowUnix
_, err = s.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, totp_required = ?, updated_at = ? WHERE public_id = ?`,
user.DisplayName, user.Email, user.IsAdmin, user.Disabled, user.TOTPRequired, nowUnix, user.ID)
return err
}
func (s *Store) UpdateUserWithPassword(user models.User, passwordHash string) error {
var err error
var nowUnix int64
var tx *sql.Tx
var owned bool
nowUnix = time.Now().UTC().Unix()
user.UpdatedAt = nowUnix
tx, owned, err = s.begin()
if err != nil {
return err
}
_, err = tx.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, totp_required = ?, password_hash = ?, updated_at = ? WHERE public_id = ?`,
user.DisplayName, user.Email, user.IsAdmin, user.Disabled, user.TOTPRequired, passwordHash, nowUnix, user.ID)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
err = commitIfOwned(tx, owned)
if err != nil {
return err
}
return nil
}
func (s *Store) SetUserPassword(userID, passwordHash string) error {
var err error
_, err = s.Exec(`UPDATE users SET password_hash = ?, updated_at = ? WHERE public_id = ?`, passwordHash, time.Now().UTC().Unix(), userID)
return err
}
func (s *Store) GetUserByID(id string) (models.User, error) {
var user models.User
var row *sql.Row
var err error
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required, CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END, u.avatar_updated_at, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.public_id = ?`, id)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &user.TOTPEnabled, &user.TOTPRequired, &user.AvatarURL, &user.AvatarUpdatedAt, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return user, err
}
return user, nil
}
func (s *Store) GetUserByUsername(username string) (models.User, string, error) {
var user models.User
var passwordHash sql.NullString
var row *sql.Row
var err error
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), u.password_hash, COALESCE(t.enabled, 0), u.totp_required, CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END, u.avatar_updated_at, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.username = ?`, username)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &passwordHash, &user.TOTPEnabled, &user.TOTPRequired, &user.AvatarURL, &user.AvatarUpdatedAt, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return user, passwordHash.String, err
}
return user, passwordHash.String, nil
}
func (s *Store) ListUsers() ([]models.User, error) {
var rows *sql.Rows
var err error
var users []models.User
var u models.User
rows, err = s.Query(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required, CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END, u.avatar_updated_at, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id ORDER BY u.username`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
u = models.User{}
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthProviderID, &u.TOTPEnabled, &u.TOTPRequired, &u.AvatarURL, &u.AvatarUpdatedAt, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
func (s *Store) GetUserAvatar(id string) (models.UserAvatar, error) {
var avatar models.UserAvatar
var row *sql.Row
var err error
row = s.QueryRow(`SELECT public_id, avatar_storage_path, avatar_content_type, avatar_updated_at FROM users WHERE public_id = ? AND avatar_storage_path != ''`, id)
err = row.Scan(&avatar.UserID, &avatar.StoragePath, &avatar.ContentType, &avatar.UpdatedAt)
if err != nil {
return avatar, err
}
return avatar, nil
}
func (s *Store) UpdateUserAvatar(id string, storagePath string, contentType string) error {
var err error
var nowUnix int64
nowUnix = time.Now().UTC().Unix()
_, err = s.Exec(`UPDATE users SET avatar_storage_path = ?, avatar_content_type = ?, avatar_updated_at = ?, updated_at = ? WHERE public_id = ?`,
storagePath, contentType, nowUnix, nowUnix, id)
return err
}
func (s *Store) ClearUserAvatar(id string) error {
var err error
_, err = s.Exec(`UPDATE users SET avatar_storage_path = '', avatar_content_type = '', avatar_updated_at = 0, updated_at = ? WHERE public_id = ?`,
time.Now().UTC().Unix(), id)
return err
}
func (s *Store) DeleteUser(id string) error {
var tx *sql.Tx
var owned bool
var err error
tx, owned, err = s.begin()
if err != nil {
return err
}
_, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'user' AND subject_id = (SELECT id FROM users WHERE public_id = ?)`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`DELETE FROM ssh_principal_grant_targets WHERE target_type = 'user' AND target_id = (SELECT id FROM users WHERE public_id = ?)`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`DELETE FROM subject_permissions WHERE subject_type = 'user' AND subject_id = (SELECT id FROM users WHERE public_id = ?)`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`DELETE FROM users WHERE public_id = ?`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
err = commitIfOwned(tx, owned)
return err
}
func (s *Store) GetUserByProviderAndSub(providerPublicID string, sub string) (models.User, error) {
var user models.User
var row *sql.Row
var err error
row = s.QueryRow(`
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled,
COALESCE(ap.public_id, ''), u.external_subject, COALESCE(t.enabled, 0), u.totp_required,
CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END,
u.avatar_updated_at, u.created_at, u.updated_at
FROM users u
LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id
LEFT JOIN user_totp t ON t.user_id = u.id
WHERE ap.public_id = ? AND u.external_subject = ?
`, strings.TrimSpace(providerPublicID), strings.TrimSpace(sub))
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled,
&user.AuthProviderID, &user.ExternalSubject, &user.TOTPEnabled, &user.TOTPRequired,
&user.AvatarURL, &user.AvatarUpdatedAt, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return user, err
}
return user, nil
}
func (s *Store) GetTLSSettings() (models.TLSSettings, error) { func (s *Store) GetTLSSettings() (models.TLSSettings, error) {
var settings models.TLSSettings var settings models.TLSSettings
var rows *sql.Rows var rows *sql.Rows
@@ -261,15 +71,15 @@ func (s *Store) GetTLSSettings() (models.TLSSettings, error) {
} }
func (s *Store) SetTLSSettings(settings models.TLSSettings) error { func (s *Store) SetTLSSettings(settings models.TLSSettings) error {
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
var err error var err error
var now int64 var now int64
var endpointPoliciesJSON string var endpointPoliciesJSON string
tx, owned, err = s.begin() tx, owned, err = s.begin()
if err != nil { if err != nil { return err }
return err
}
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) _, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
@@ -376,12 +186,6 @@ func splitCSVValue(value string) []string {
return out return out
} }
func (s *Store) SetUserDisabled(id string, disabled bool) error {
var err error
_, err = s.Exec(`UPDATE users SET disabled = ?, updated_at = ? WHERE public_id = ?`, disabled, time.Now().UTC().Unix(), id)
return err
}
func (s *Store) CreateAPIKey(userID string, name string, tokenHash string, prefix string, expiresAt int64) (models.APIKey, error) { func (s *Store) CreateAPIKey(userID string, name string, tokenHash string, prefix string, expiresAt int64) (models.APIKey, error) {
var key models.APIKey var key models.APIKey
var err error var err error
@@ -605,48 +409,6 @@ func (s *Store) ListAPIKeysAdmin(subjectType string, query string) ([]models.Adm
return keys, rows.Err() return keys, rows.Err()
} }
func (s *Store) GetUserByAPIKeyHash(tokenHash string) (models.User, error) {
var user models.User
var tx *sql.Tx
var owned bool
var row *sql.Row
var keyID int64
var nowUnix int64
var err error
tx, owned, err = s.begin()
if err != nil {
return user, err
}
nowUnix = time.Now().UTC().Unix()
row = tx.QueryRow(`
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), u.created_at, u.updated_at, k.id
FROM api_keys k
JOIN users u ON u.id = k.user_id
LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id
WHERE k.token_hash = ?
AND u.disabled = 0
AND k.disabled = 0
AND (k.expires_at = 0 OR k.expires_at > ?)
LIMIT 1
`, tokenHash, nowUnix)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &user.CreatedAt, &user.UpdatedAt, &keyID)
if err != nil {
rollbackIfOwned(tx, owned)
return user, err
}
nowUnix = time.Now().UTC().Unix()
_, err = tx.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, nowUnix, keyID)
if err != nil {
rollbackIfOwned(tx, owned)
return user, err
}
err = commitIfOwned(tx, owned)
if err != nil {
return user, err
}
return user, nil
}
func (s *Store) CreateSession(userID string, token string, expiresAt time.Time, totpVerified bool, oidcIDToken string) error { func (s *Store) CreateSession(userID string, token string, expiresAt time.Time, totpVerified bool, oidcIDToken string) error {
var err error var err error
_, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at, totp_verified, oidc_id_token) _, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at, totp_verified, oidc_id_token)
@@ -715,28 +477,23 @@ func (s *Store) CreateProject(project models.Project) (models.Project, error) {
var id string var id string
var err error var err error
var nowUnix int64 var nowUnix int64
var tx *sql.Tx var tx txExecutor
var owned bool var owned bool
if project.ID == "" { if project.ID == "" {
id, err = util.NewID() id, err = util.NewID()
if err != nil { if err != nil { return project, err }
return project, err
}
project.ID = id project.ID = id
} }
nowUnix = time.Now().UTC().Unix() nowUnix = time.Now().UTC().Unix()
project.CreatedAt = nowUnix project.CreatedAt = nowUnix
project.UpdatedAt = nowUnix project.UpdatedAt = nowUnix
if project.UpdatedBy == "" { if project.UpdatedBy == "" { project.UpdatedBy = project.CreatedBy }
project.UpdatedBy = project.CreatedBy
}
tx, owned, err = s.begin() tx, owned, err = s.begin()
if err != nil { if err != nil { return project, err }
return project, err if project.HomePage == "" { project.HomePage = "settings" }
}
if project.HomePage == "" {
project.HomePage = "settings"
}
_, err = tx.Exec(`INSERT INTO projects (public_id, slug, name, description, home_page, created_by, updated_by, created_at, updated_at) _, err = tx.Exec(`INSERT INTO projects (public_id, slug, name, description, home_page, created_by, updated_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, (SELECT id FROM users WHERE public_id = ?), (SELECT id FROM users WHERE public_id = ?), ?, ?)`, VALUES (?, ?, ?, ?, ?, (SELECT id FROM users WHERE public_id = ?), (SELECT id FROM users WHERE public_id = ?), ?, ?)`,
project.ID, project.ID,
@@ -760,15 +517,16 @@ func (s *Store) CreateProject(project models.Project) (models.Project, error) {
rollbackIfOwned(tx, owned) rollbackIfOwned(tx, owned)
return project, err return project, err
} }
err = commitIfOwned(tx, owned) err = commitIfOwned(tx, owned)
if err != nil { if err != nil { return project, err }
return project, err
}
return project, nil return project, nil
} }
func (s *Store) UpdateProject(project models.Project) error { func (s *Store) UpdateProject(project models.Project) error {
var err error var err error
if project.HomePage == "" { if project.HomePage == "" {
project.HomePage = "settings" project.HomePage = "settings"
} }
+245
View File
@@ -0,0 +1,245 @@
package db
import "database/sql"
import "strings"
import "time"
import "codit/internal/models"
import "codit/internal/util"
func (s *Store) CreateUser(user models.User, passwordHash string) (models.User, error) {
var id string
var err error
var nowUnix int64
if user.ID == "" {
id, err = util.NewID()
if err != nil {
return user, err
}
user.ID = id
}
nowUnix = time.Now().UTC().Unix()
user.CreatedAt = nowUnix
user.UpdatedAt = nowUnix
_, err = s.Exec(`
INSERT INTO users (public_id, username, display_name, email, password_hash, is_admin, disabled, auth_provider_id, totp_required, external_subject, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM auth_providers WHERE public_id = ?) END, ?, ?, ?, ?)
`, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.Disabled, user.AuthProviderID, user.AuthProviderID, user.TOTPRequired, user.ExternalSubject, nowUnix, nowUnix)
return user, err
}
func (s *Store) UpdateUser(user models.User) error {
var err error
var nowUnix int64
nowUnix = time.Now().UTC().Unix()
user.UpdatedAt = nowUnix
_, err = s.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, totp_required = ?, updated_at = ? WHERE public_id = ?`,
user.DisplayName, user.Email, user.IsAdmin, user.Disabled, user.TOTPRequired, nowUnix, user.ID)
return err
}
func (s *Store) UpdateUserWithPassword(user models.User, passwordHash string) error {
var err error
var nowUnix int64
var tx txExecutor
var owned bool
nowUnix = time.Now().UTC().Unix()
user.UpdatedAt = nowUnix
tx, owned, err = s.begin()
if err != nil {
return err
}
_, err = tx.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, totp_required = ?, password_hash = ?, updated_at = ? WHERE public_id = ?`,
user.DisplayName, user.Email, user.IsAdmin, user.Disabled, user.TOTPRequired, passwordHash, nowUnix, user.ID)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
err = commitIfOwned(tx, owned)
if err != nil {
return err
}
return nil
}
func (s *Store) SetUserPassword(userID, passwordHash string) error {
var err error
_, err = s.Exec(`UPDATE users SET password_hash = ?, updated_at = ? WHERE public_id = ?`, passwordHash, time.Now().UTC().Unix(), userID)
return err
}
func (s *Store) GetUserByID(id string) (models.User, error) {
var user models.User
var row *sql.Row
var err error
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required, CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END, u.avatar_updated_at, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.public_id = ?`, id)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &user.TOTPEnabled, &user.TOTPRequired, &user.AvatarURL, &user.AvatarUpdatedAt, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return user, err
}
return user, nil
}
func (s *Store) GetUserByUsername(username string) (models.User, string, error) {
var user models.User
var passwordHash sql.NullString
var row *sql.Row
var err error
row = s.QueryRow(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), u.password_hash, COALESCE(t.enabled, 0), u.totp_required, CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END, u.avatar_updated_at, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id WHERE u.username = ?`, username)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &passwordHash, &user.TOTPEnabled, &user.TOTPRequired, &user.AvatarURL, &user.AvatarUpdatedAt, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return user, passwordHash.String, err
}
return user, passwordHash.String, nil
}
func (s *Store) ListUsers() ([]models.User, error) {
var rows *sql.Rows
var err error
var users []models.User
var u models.User
rows, err = s.Query(`SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), COALESCE(t.enabled, 0), u.totp_required, CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END, u.avatar_updated_at, u.created_at, u.updated_at FROM users u LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id LEFT JOIN user_totp t ON t.user_id = u.id ORDER BY u.username`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
u = models.User{}
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthProviderID, &u.TOTPEnabled, &u.TOTPRequired, &u.AvatarURL, &u.AvatarUpdatedAt, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
func (s *Store) GetUserAvatar(id string) (models.UserAvatar, error) {
var avatar models.UserAvatar
var row *sql.Row
var err error
row = s.QueryRow(`SELECT public_id, avatar_storage_path, avatar_content_type, avatar_updated_at FROM users WHERE public_id = ? AND avatar_storage_path != ''`, id)
err = row.Scan(&avatar.UserID, &avatar.StoragePath, &avatar.ContentType, &avatar.UpdatedAt)
if err != nil {
return avatar, err
}
return avatar, nil
}
func (s *Store) UpdateUserAvatar(id string, storagePath string, contentType string) error {
var err error
var nowUnix int64
nowUnix = time.Now().UTC().Unix()
_, err = s.Exec(`UPDATE users SET avatar_storage_path = ?, avatar_content_type = ?, avatar_updated_at = ?, updated_at = ? WHERE public_id = ?`,
storagePath, contentType, nowUnix, nowUnix, id)
return err
}
func (s *Store) ClearUserAvatar(id string) error {
var err error
_, err = s.Exec(`UPDATE users SET avatar_storage_path = '', avatar_content_type = '', avatar_updated_at = 0, updated_at = ? WHERE public_id = ?`,
time.Now().UTC().Unix(), id)
return err
}
func (s *Store) DeleteUser(id string) error {
var tx txExecutor
var owned bool
var err error
tx, owned, err = s.begin()
if err != nil { return err }
_, err = tx.Exec(`DELETE FROM project_role_bindings WHERE subject_type = 'user' AND subject_id = (SELECT id FROM users WHERE public_id = ?)`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`DELETE FROM ssh_principal_grant_targets WHERE target_type = 'user' AND target_id = (SELECT id FROM users WHERE public_id = ?)`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`DELETE FROM subject_permissions WHERE subject_type = 'user' AND subject_id = (SELECT id FROM users WHERE public_id = ?)`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
_, err = tx.Exec(`DELETE FROM users WHERE public_id = ?`, id)
if err != nil {
rollbackIfOwned(tx, owned)
return err
}
err = commitIfOwned(tx, owned)
return err
}
func (s *Store) GetUserByProviderAndSub(providerPublicID string, sub string) (models.User, error) {
var user models.User
var row *sql.Row
var err error
row = s.QueryRow(`
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled,
COALESCE(ap.public_id, ''), u.external_subject, COALESCE(t.enabled, 0), u.totp_required,
CASE WHEN u.avatar_storage_path != '' THEN '/api/users/' || u.public_id || '/avatar?v=' || u.avatar_updated_at ELSE '' END,
u.avatar_updated_at, u.created_at, u.updated_at
FROM users u
LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id
LEFT JOIN user_totp t ON t.user_id = u.id
WHERE ap.public_id = ? AND u.external_subject = ?
`, strings.TrimSpace(providerPublicID), strings.TrimSpace(sub))
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled,
&user.AuthProviderID, &user.ExternalSubject, &user.TOTPEnabled, &user.TOTPRequired,
&user.AvatarURL, &user.AvatarUpdatedAt, &user.CreatedAt, &user.UpdatedAt)
if err != nil { return user, err }
return user, nil
}
func (s *Store) SetUserDisabled(id string, disabled bool) error {
var err error
_, err = s.Exec(`UPDATE users SET disabled = ?, updated_at = ? WHERE public_id = ?`, disabled, time.Now().UTC().Unix(), id)
return err
}
func (s *Store) GetUserByAPIKeyHash(tokenHash string) (models.User, error) {
var user models.User
var tx txExecutor
var owned bool
var row *sql.Row
var keyID int64
var nowUnix int64
var err error
tx, owned, err = s.begin()
if err != nil { return user, err }
nowUnix = time.Now().UTC().Unix()
row = tx.QueryRow(`
SELECT u.public_id, u.username, u.display_name, u.email, u.is_admin, u.disabled, COALESCE(ap.public_id, ''), u.created_at, u.updated_at, k.id
FROM api_keys k
JOIN users u ON u.id = k.user_id
LEFT JOIN auth_providers ap ON ap.id = u.auth_provider_id
WHERE k.token_hash = ?
AND u.disabled = 0
AND k.disabled = 0
AND (k.expires_at = 0 OR k.expires_at > ?)
LIMIT 1
`, tokenHash, nowUnix)
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthProviderID, &user.CreatedAt, &user.UpdatedAt, &keyID)
if err != nil {
rollbackIfOwned(tx, owned)
return user, err
}
nowUnix = time.Now().UTC().Unix()
_, err = tx.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, nowUnix, keyID)
if err != nil {
rollbackIfOwned(tx, owned)
return user, err
}
err = commitIfOwned(tx, owned)
if err != nil {
return user, err
}
return user, nil
}
+8 -3
View File
@@ -538,18 +538,22 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "invalid credentials") WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "invalid credentials")
} }
func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models.User, totpVerified bool, oidcIDToken string) { func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models.User, totpVerified bool, oidcIDToken string) error {
var token string var token string
var err error var err error
var expires time.Time var expires time.Time
var cookie *http.Cookie var cookie *http.Cookie
token, err = auth.NewSessionToken() token, err = auth.NewSessionToken()
if err != nil { return } if err != nil { return err }
expires = auth.SessionExpiry(api.Cfg) expires = auth.SessionExpiry(api.Cfg)
_ = api.store(r).DeleteExpiredSessions(time.Now().UTC()) _ = api.store(r).DeleteExpiredSessions(time.Now().UTC())
_ = api.store(r).CreateSession(user.ID, token, expires, totpVerified, oidcIDToken) err = api.store(r).CreateSession(user.ID, token, expires, totpVerified, oidcIDToken)
if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "session create failed user=%s err=%v", user.Username, err)
return err
}
cookie = &http.Cookie{ cookie = &http.Cookie{
Name: api.sessionCookieName(), Name: api.sessionCookieName(),
Value: token, Value: token,
@@ -558,6 +562,7 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
Expires: expires, Expires: expires,
} }
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
return nil
} }
func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) { func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) {
+5 -1
View File
@@ -173,7 +173,11 @@ func (api *API) OIDCCallbackWithProvider(w http.ResponseWriter, r *http.Request,
user.TOTPEffectiveRequired = required user.TOTPEffectiveRequired = required
user.TOTPSetupRequired = required && !user.TOTPEnabled user.TOTPSetupRequired = required && !user.TOTPEnabled
sessionVerified = !required sessionVerified = !required
api.issueSession(w, r, user, sessionVerified, token.IDToken) err = api.issueSession(w, r, user, sessionVerified, token.IDToken)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login success username=%s provider=%s", user.Username, provider.Name) api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login success username=%s provider=%s", user.Username, provider.Name)
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
} }
+5 -1
View File
@@ -61,7 +61,11 @@ func (api *API) finishLogin(w http.ResponseWriter, r *http.Request, user models.
if required && !user.TOTPEnabled { if required && !user.TOTPEnabled {
sessionVerified = false sessionVerified = false
} }
api.issueSession(w, r, user, sessionVerified, "") err = api.issueSession(w, r, user, sessionVerified, "")
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, user) WriteJSON(w, http.StatusOK, user)
} }
+34 -39
View File
@@ -110,32 +110,47 @@ func WithStoreTransactionUnbuffered(store *db.Store, next http.Handler) http.Han
return withStoreTransaction(store, next, false) return withStoreTransaction(store, next, false)
} }
func isWebSocketUpgrade(r *http.Request) bool {
if r == nil { return false }
return strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Connection"))), "upgrade") &&
strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket")
}
func rollbackIfPanic(txStore *db.Store) {
var rec interface{}
rec = recover()
if rec != nil { // if the goroutne panicked
_ = txStore.Rollback() // rollback the transaction
panic(rec) // resume panicking
}
}
func withStoreTransaction(store *db.Store, next http.Handler, bufferWrites bool) http.Handler { func withStoreTransaction(store *db.Store, next http.Handler, bufferWrites bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var txStore *db.Store var txStore *db.Store
var recorder *bufferedResponseWriter
var statusWriter *passThroughStatusWriter
var err error var err error
if isWebSocketUpgrade(r) { if isWebSocketUpgrade(r) {
// no transaction on websocket upgrade
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
if isReadOnlyMethod(r.Method) {
txStore, err = store.BeginStore(r.Context()) txStore, err = store.BeginStore(r.Context())
} else {
txStore, err = store.BeginImmediateStore(r.Context())
}
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
// arrange to rollback even if the handler panics
defer rollbackIfPanic(txStore)
r = WithStore(r, txStore) r = WithStore(r, txStore)
if isReadOnlyMethod(r.Method) { if isReadOnlyMethod(r.Method) {
defer func() {
var rec interface{}
rec = recover()
if rec != nil {
_ = txStore.Rollback()
panic(rec)
}
}()
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
err = txStore.Commit() err = txStore.Commit()
if err != nil { if err != nil {
@@ -143,18 +158,11 @@ func withStoreTransaction(store *db.Store, next http.Handler, bufferWrites bool)
return return
} }
runAfterCommit(r.Context()) runAfterCommit(r.Context())
return } else if !bufferWrites {
} // read-write or write-only. unbufferred.
if !bufferWrites { var statusWriter *passThroughStatusWriter
statusWriter = newPassThroughStatusWriter(w) statusWriter = newPassThroughStatusWriter(w)
defer func() {
var rec interface{}
rec = recover()
if rec != nil {
_ = txStore.Rollback()
panic(rec)
}
}()
next.ServeHTTP(statusWriter, r) next.ServeHTTP(statusWriter, r)
if statusWriter.status >= 400 { if statusWriter.status >= 400 {
_ = txStore.Rollback() _ = txStore.Rollback()
@@ -169,17 +177,11 @@ func withStoreTransaction(store *db.Store, next http.Handler, bufferWrites bool)
return return
} }
runAfterCommit(r.Context()) runAfterCommit(r.Context())
return } else {
} // read-write or write-only. bufferred.
var recorder *bufferedResponseWriter
recorder = newBufferedResponseWriter() recorder = newBufferedResponseWriter()
defer func() {
var rec interface{}
rec = recover()
if rec != nil {
_ = txStore.Rollback()
panic(rec)
}
}()
next.ServeHTTP(recorder, r) next.ServeHTTP(recorder, r)
if recorder.status >= 400 { if recorder.status >= 400 {
_ = txStore.Rollback() _ = txStore.Rollback()
@@ -194,17 +196,10 @@ func withStoreTransaction(store *db.Store, next http.Handler, bufferWrites bool)
} }
runAfterCommit(r.Context()) runAfterCommit(r.Context())
recorder.FlushTo(w) recorder.FlushTo(w)
}
}) })
} }
func isWebSocketUpgrade(r *http.Request) bool {
if r == nil {
return false
}
return strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Connection"))), "upgrade") &&
strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket")
}
func RegisterAfterCommit(ctx context.Context, fn func()) { func RegisterAfterCommit(ctx context.Context, fn func()) {
var holder *afterCommitHolder var holder *afterCommitHolder
var ok bool var ok bool
+23 -3
View File
@@ -12,6 +12,7 @@ import { Block, BlockProperties, BoardFieldValue } from '../api'
// --- Constants ---------------------------------------------------------------- // --- Constants ----------------------------------------------------------------
const FALLBACK_COLOR = '#9e9e9e' const FALLBACK_COLOR = '#9e9e9e'
const OTHER_STATUS_VALUE = '__other__'
const CHART_H = 280 const CHART_H = 280
const M = { top: 16, right: 16, bottom: 56, left: 48 } const M = { top: 16, right: 16, bottom: 56, left: 48 }
const M_H = { top: 10, right: 48, bottom: 28, left: 90 } const M_H = { top: 10, right: 48, bottom: 28, left: 90 }
@@ -516,7 +517,7 @@ function AssigneeWorkloadChart({ width, data }: { width: number; data: AssigneeD
<text x={avatarCx} y={cy + 4} textAnchor="middle" fontSize={avatarR > 10 ? 10 : 9} fill="#fff" fontWeight={700}> <text x={avatarCx} y={cy + 4} textAnchor="middle" fontSize={avatarR > 10 ? 10 : 9} fill="#fff" fontWeight={700}>
{getInitials(d.name)} {getInitials(d.name)}
</text> </text>
<text x={avatarCx + avatarR + 6} y={cy + 4} textAnchor="start" fontSize={11} fill="#444"> <text x={avatarCx + avatarR + 6} y={cy + 4} textAnchor="start" fontSize={11} fill={labelColor}>
{displayName} {displayName}
</text> </text>
<rect x={0} y={y} width={iw} height={bh} fill="#f0f0f0" rx={5} /> <rect x={0} y={y} width={iw} height={bh} fill="#f0f0f0" rx={5} />
@@ -525,7 +526,7 @@ function AssigneeWorkloadChart({ width, data }: { width: number; data: AssigneeD
const bw = xScale(seg.count) ?? 0 const bw = xScale(seg.count) ?? 0
const el = ( const el = (
<rect <rect
key={seg.statusValue || 'other'} key={seg.statusValue || OTHER_STATUS_VALUE}
x={xLeft} y={y} width={bw} height={bh} fill={seg.color} rx={5} x={xLeft} y={y} width={bw} height={bh} fill={seg.color} rx={5}
onMouseMove={(e) => { onMouseMove={(e) => {
if (!containerRef.current) return if (!containerRef.current) return
@@ -601,7 +602,7 @@ function SprintStackedBarChart({ width, data }: { width: number; data: SprintDat
<g key={d.sprintValue}> <g key={d.sprintValue}>
{stacked.map(({ seg, y, h }) => ( {stacked.map(({ seg, y, h }) => (
<rect <rect
key={seg.statusValue} key={seg.statusValue || OTHER_STATUS_VALUE}
x={x} y={y} width={bw} height={h} fill={seg.color} rx={2} x={x} y={y} width={bw} height={h} fill={seg.color} rx={2}
onMouseMove={(e) => { onMouseMove={(e) => {
if (!containerRef.current) return if (!containerRef.current) return
@@ -868,6 +869,7 @@ export default function BoardChartView({ blocks, properties, fieldValues }: Boar
const [throughputPeriod, setThroughputPeriod] = useState<ThroughputPeriod>('weekly') const [throughputPeriod, setThroughputPeriod] = useState<ThroughputPeriod>('weekly')
const statusValues = fieldValues['status'] ?? [] const statusValues = fieldValues['status'] ?? []
const sprintValues = fieldValues['sprint'] ?? [] const sprintValues = fieldValues['sprint'] ?? []
const knownStatusValues: Set<string> = new Set(statusValues.map((sv) => sv.value))
const statusCountMap = properties.reduce<Record<string, number>>((acc, p) => { const statusCountMap = properties.reduce<Record<string, number>>((acc, p) => {
const k = p.status || ''; acc[k] = (acc[k] ?? 0) + 1; return acc const k = p.status || ''; acc[k] = (acc[k] ?? 0) + 1; return acc
@@ -877,6 +879,15 @@ export default function BoardChartView({ blocks, properties, fieldValues }: Boar
count: statusCountMap[sv.value] ?? 0, count: statusCountMap[sv.value] ?? 0,
color: sv.color || FALLBACK_COLOR, color: sv.color || FALLBACK_COLOR,
})) }))
const statusOtherCount: number = properties.filter((p) => !knownStatusValues.has(p.status || '')).length
if (statusOtherCount > 0) {
statusData.push({
value: OTHER_STATUS_VALUE,
label: 'Other',
count: statusOtherCount,
color: FALLBACK_COLOR,
})
}
const totalCards = properties.length const totalCards = properties.length
const sprintData: SprintDatum[] = sprintValues const sprintData: SprintDatum[] = sprintValues
@@ -887,6 +898,15 @@ export default function BoardChartView({ blocks, properties, fieldValues }: Boar
count: inSprint.filter((p) => (p.status || '') === sv.value).length, count: inSprint.filter((p) => (p.status || '') === sv.value).length,
color: sv.color || FALLBACK_COLOR, color: sv.color || FALLBACK_COLOR,
})) }))
const sprintOtherCount: number = inSprint.filter((p) => !knownStatusValues.has(p.status || '')).length
if (sprintOtherCount > 0) {
segments.push({
statusValue: OTHER_STATUS_VALUE,
label: 'Other',
count: sprintOtherCount,
color: FALLBACK_COLOR,
})
}
return { sprintValue: sprint.value, label: sprint.label, total: inSprint.length, segments } return { sprintValue: sprint.value, label: sprint.label, total: inSprint.length, segments }
}) })
.filter((d) => d.total > 0) .filter((d) => d.total > 0)
+22 -1
View File
@@ -100,10 +100,22 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
// Track last-saved values for text fields so blur/cancel can revert correctly // Track last-saved values for text fields so blur/cancel can revert correctly
const savedTitleRef = useRef('') const savedTitleRef = useRef('')
const savedDescriptionRef = useRef('') const savedDescriptionRef = useRef('')
const checklistInputRef = useRef<HTMLInputElement>(null)
const focusChecklistInputAfterSaveRef = useRef(false)
const checklistSensors = useSensors( const checklistSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
) )
useEffect(() => {
if (savingChecklist) return
if (!focusChecklistInputAfterSaveRef.current) return
focusChecklistInputAfterSaveRef.current = false
requestAnimationFrame(() => { checklistInputRef.current?.focus() })
}, [savingChecklist])
useEffect(() => { useEffect(() => {
if (!open || !block) return if (!open || !block) return
setTitle(block.title) setTitle(block.title)
@@ -272,8 +284,11 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
function handleCreateChecklistItem() { function handleCreateChecklistItem() {
if (!block || !newChecklistTitle.trim()) return if (!block || !newChecklistTitle.trim()) return
focusChecklistInputAfterSaveRef.current = true
setSavingChecklist(true) setSavingChecklist(true)
setChecklistError(null) setChecklistError(null)
api.createBlockChecklistItem(boardId, block.id, newChecklistTitle.trim()) api.createBlockChecklistItem(boardId, block.id, newChecklistTitle.trim())
.then((item: BlockChecklistItem) => { .then((item: BlockChecklistItem) => {
setChecklistItems((prev: BlockChecklistItem[]) => [...prev, item]) setChecklistItems((prev: BlockChecklistItem[]) => [...prev, item])
@@ -647,10 +662,16 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
{checklistError ? <Alert severity="error" sx={{ mt: 1 }}>{checklistError}</Alert> : null} {checklistError ? <Alert severity="error" sx={{ mt: 1 }}>{checklistError}</Alert> : null}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 0.5 }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 0.5 }}>
<TextField <TextField
inputRef={checklistInputRef}
placeholder="Add checklist item..." placeholder="Add checklist item..."
value={newChecklistTitle} value={newChecklistTitle}
onChange={(e) => setNewChecklistTitle(e.target.value)} onChange={(e) => setNewChecklistTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateChecklistItem() }} onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleCreateChecklistItem()
}
}}
fullWidth fullWidth
size="small" size="small"
disabled={savingChecklist} disabled={savingChecklist}