This commit is contained in:
+180
-19
@@ -27,6 +27,7 @@ type User struct {
|
||||
Email string
|
||||
Role string
|
||||
AvatarURL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type Attendance struct {
|
||||
@@ -103,6 +104,7 @@ type BoardTask struct {
|
||||
CreatorName string
|
||||
DueDate string
|
||||
CreatedAt string
|
||||
ArchivedAt string
|
||||
Tags []BoardTag
|
||||
}
|
||||
|
||||
@@ -132,6 +134,38 @@ func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||
|
||||
func (s *Store) ensureColumn(table, column, definition string) error {
|
||||
rows, err := s.db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid, notNull, primaryKey int
|
||||
var name, columnType string
|
||||
var defaultValue any
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if name == column {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
return nil
|
||||
}
|
||||
_, err = s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + definition)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -142,6 +176,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
email TEXT UNIQUE COLLATE NOCASE,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('member','admin')),
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||
@@ -200,6 +235,7 @@ CREATE TABLE IF NOT EXISTS board_tasks (
|
||||
assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
due_date TEXT,
|
||||
archived_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at);
|
||||
@@ -219,6 +255,12 @@ CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,tas
|
||||
if _, err := s.db.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureColumn("users", "active", "active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1))"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureColumn("board_tasks", "archived_at", "archived_at DATETIME"); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||
return err
|
||||
@@ -297,12 +339,15 @@ func tokenHash(token string) string {
|
||||
func (s *Store) Authenticate(identifier, password string) (*User, error) {
|
||||
var u User
|
||||
var hash string
|
||||
err := s.db.QueryRow(`SELECT id,username,password_hash,display_name,COALESCE(email,''),role,avatar_url
|
||||
err := s.db.QueryRow(`SELECT id,username,password_hash,display_name,COALESCE(email,''),role,avatar_url,active
|
||||
FROM users WHERE username=? OR email=?`, strings.TrimSpace(identifier), strings.TrimSpace(identifier)).
|
||||
Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL)
|
||||
Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active)
|
||||
if err != nil || !verifyPassword(hash, password) {
|
||||
return nil, errors.New("invalid email, username, or password")
|
||||
}
|
||||
if !u.Active {
|
||||
return nil, errors.New("this account is locked; contact an administrator")
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
@@ -394,7 +439,20 @@ func validUsername(username string) bool {
|
||||
}
|
||||
|
||||
func (s *Store) Users() ([]User, error) {
|
||||
rows, err := s.db.Query(`SELECT id,username,display_name,COALESCE(email,''),role,avatar_url FROM users ORDER BY display_name`)
|
||||
return s.users(false)
|
||||
}
|
||||
|
||||
func (s *Store) ActiveUsers() ([]User, error) {
|
||||
return s.users(true)
|
||||
}
|
||||
|
||||
func (s *Store) users(activeOnly bool) ([]User, error) {
|
||||
query := `SELECT id,username,display_name,COALESCE(email,''),role,avatar_url,active FROM users`
|
||||
if activeOnly {
|
||||
query += ` WHERE active=1`
|
||||
}
|
||||
query += ` ORDER BY display_name`
|
||||
rows, err := s.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -402,7 +460,7 @@ func (s *Store) Users() ([]User, error) {
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL); err != nil {
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
@@ -419,8 +477,16 @@ func (s *Store) CreateSession(userID int64) (token, csrf string, err error) {
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at) VALUES(?,?,?,?)`,
|
||||
tokenHash(token), userID, csrf, time.Now().Add(30*24*time.Hour))
|
||||
result, err := s.db.Exec(`INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at)
|
||||
SELECT ?,id,?,? FROM users WHERE id=? AND active=1`,
|
||||
tokenHash(token), csrf, time.Now().Add(30*24*time.Hour), userID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
count, _ := result.RowsAffected()
|
||||
if count == 0 {
|
||||
return "", "", errors.New("account is locked or was not found")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -434,15 +500,24 @@ func (s *Store) UpsertOAuthUser(provider, providerID, username, email, displayNa
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userID int64
|
||||
err = tx.QueryRow(`SELECT user_id FROM oauth_accounts WHERE provider=? AND provider_user_id=?`, provider, providerID).Scan(&userID)
|
||||
var active bool
|
||||
err = tx.QueryRow(`SELECT o.user_id,u.active FROM oauth_accounts o
|
||||
JOIN users u ON u.id=o.user_id
|
||||
WHERE o.provider=? AND o.provider_user_id=?`, provider, providerID).Scan(&userID, &active)
|
||||
if err == nil {
|
||||
if !active {
|
||||
return 0, errors.New("account is locked")
|
||||
}
|
||||
return userID, tx.Commit()
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, err
|
||||
}
|
||||
if email != "" {
|
||||
err = tx.QueryRow(`SELECT id FROM users WHERE email=?`, email).Scan(&userID)
|
||||
err = tx.QueryRow(`SELECT id,active FROM users WHERE email=?`, email).Scan(&userID, &active)
|
||||
if err == nil && !active {
|
||||
return 0, errors.New("account is locked")
|
||||
}
|
||||
}
|
||||
if email == "" || errors.Is(err, sql.ErrNoRows) {
|
||||
username = strings.TrimSpace(username)
|
||||
@@ -487,10 +562,10 @@ func (s *Store) UpsertOAuthUser(provider, providerID, username, email, displayNa
|
||||
func (s *Store) Session(token string) (*User, string, error) {
|
||||
var u User
|
||||
var csrf string
|
||||
err := s.db.QueryRow(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,s.csrf_token
|
||||
err := s.db.QueryRow(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,u.active,s.csrf_token
|
||||
FROM sessions s JOIN users u ON u.id=s.user_id
|
||||
WHERE s.token_hash=? AND s.expires_at>?`, tokenHash(token), time.Now()).
|
||||
Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &csrf)
|
||||
WHERE s.token_hash=? AND s.expires_at>? AND u.active=1`, tokenHash(token), time.Now()).
|
||||
Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active, &csrf)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -501,6 +576,53 @@ func (s *Store) DeleteSession(token string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, tokenHash(token))
|
||||
}
|
||||
|
||||
func (s *Store) SetUserActive(userID, actorID int64, active bool) error {
|
||||
if userID < 1 {
|
||||
return errors.New("account was not found")
|
||||
}
|
||||
if !active && userID == actorID {
|
||||
return errors.New("you cannot lock your own account")
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var role string
|
||||
var currentlyActive bool
|
||||
if err := tx.QueryRow(`SELECT role,active FROM users WHERE id=?`, userID).Scan(&role, ¤tlyActive); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errors.New("account was not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if currentlyActive == active {
|
||||
return tx.Commit()
|
||||
}
|
||||
if !active && role == "admin" {
|
||||
var activeAdmins int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE role='admin' AND active=1`).Scan(&activeAdmins); err != nil {
|
||||
return err
|
||||
}
|
||||
if activeAdmins <= 1 {
|
||||
return errors.New("the last active administrator cannot be locked")
|
||||
}
|
||||
}
|
||||
activeValue := 0
|
||||
if active {
|
||||
activeValue = 1
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE users SET active=? WHERE id=?`, activeValue, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !active {
|
||||
if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) TodayAttendance(userID int64, day string) (*Attendance, error) {
|
||||
var a Attendance
|
||||
err := s.db.QueryRow(`SELECT id,user_id,day,
|
||||
@@ -661,6 +783,7 @@ func (s *Store) DayRoster(day string) ([]DayRosterRow, error) {
|
||||
),'')
|
||||
FROM users u
|
||||
LEFT JOIN attendance a ON a.user_id=u.id AND a.day=?
|
||||
WHERE u.active=1
|
||||
ORDER BY u.display_name`, day, day)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -820,16 +943,32 @@ func (s *Store) CreateBoardTask(creatorID int64, title, description, status stri
|
||||
}
|
||||
|
||||
func (s *Store) BoardTasks() ([]BoardTask, error) {
|
||||
rows, err := s.db.Query(`SELECT
|
||||
return s.boardTasks(false)
|
||||
}
|
||||
|
||||
func (s *Store) ArchivedBoardTasks() ([]BoardTask, error) {
|
||||
return s.boardTasks(true)
|
||||
}
|
||||
|
||||
func (s *Store) boardTasks(archived bool) ([]BoardTask, error) {
|
||||
query := `SELECT
|
||||
t.id,t.title,t.description,t.status,t.assignee_id,
|
||||
COALESCE(a.display_name,''),COALESCE(a.username,''),
|
||||
t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT)
|
||||
t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT),
|
||||
COALESCE(CAST(t.archived_at AS TEXT),'')
|
||||
FROM board_tasks t
|
||||
JOIN users c ON c.id=t.creator_id
|
||||
LEFT JOIN users a ON a.id=t.assignee_id
|
||||
ORDER BY CASE t.status
|
||||
LEFT JOIN users a ON a.id=t.assignee_id`
|
||||
if archived {
|
||||
query += ` WHERE t.archived_at IS NOT NULL
|
||||
ORDER BY t.archived_at DESC,t.id DESC`
|
||||
} else {
|
||||
query += ` WHERE t.archived_at IS NULL
|
||||
ORDER BY CASE t.status
|
||||
WHEN 'backlog' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END,
|
||||
t.created_at,t.id`)
|
||||
t.created_at,t.id`
|
||||
}
|
||||
rows, err := s.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -839,7 +978,7 @@ func (s *Store) BoardTasks() ([]BoardTask, error) {
|
||||
if err := rows.Scan(
|
||||
&task.ID, &task.Title, &task.Description, &task.Status, &task.AssigneeID,
|
||||
&task.AssigneeName, &task.AssigneeUser, &task.CreatorID, &task.CreatorName,
|
||||
&task.DueDate, &task.CreatedAt,
|
||||
&task.DueDate, &task.CreatedAt, &task.ArchivedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -880,7 +1019,7 @@ func (s *Store) MoveBoardTask(id int64, status string) error {
|
||||
if !validBoardStatus(status) {
|
||||
return errors.New("invalid board column")
|
||||
}
|
||||
result, err := s.db.Exec(`UPDATE board_tasks SET status=? WHERE id=?`, status, id)
|
||||
result, err := s.db.Exec(`UPDATE board_tasks SET status=? WHERE id=? AND archived_at IS NULL`, status, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -891,6 +1030,28 @@ func (s *Store) MoveBoardTask(id int64, status string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetBoardTaskArchived(id, userID int64, admin, archived bool) error {
|
||||
archiveValue := any(nil)
|
||||
if archived {
|
||||
archiveValue = time.Now().UTC()
|
||||
}
|
||||
query := `UPDATE board_tasks SET archived_at=? WHERE id=?`
|
||||
args := []any{archiveValue, id}
|
||||
if !admin {
|
||||
query += ` AND creator_id=?`
|
||||
args = append(args, userID)
|
||||
}
|
||||
result, err := s.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, _ := result.RowsAffected()
|
||||
if count == 0 {
|
||||
return errors.New("only the creator or an admin can archive or restore this task")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBoardTask(id, userID int64, admin bool) error {
|
||||
query := `DELETE FROM board_tasks WHERE id=?`
|
||||
args := []any{id}
|
||||
@@ -904,7 +1065,7 @@ func (s *Store) DeleteBoardTask(id, userID int64, admin bool) error {
|
||||
}
|
||||
count, _ := result.RowsAffected()
|
||||
if count == 0 {
|
||||
return errors.New("only the creator or an admin can remove this task")
|
||||
return errors.New("only the creator or an admin can delete this task")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user