package store import ( "context" "database/sql" "errors" "time" "github.com/barerepo/server/internal/token" ) // Token is one row of item 3 on the closed list. type Token struct { ID int64 Kind token.Kind Account string Scope string // owner/name for a runner token, empty for a git token Label string Created time.Time LastUsed time.Time } // CreateToken issues a token and returns it. The caller shows it once. func (db *DB) CreateToken(ctx context.Context, kind token.Kind, account, scope, label string) (string, *Token, error) { tok, hash, err := token.New(kind) if err != nil { return "", nil, err } now := now() var id int64 err = db.QueryRowContext(ctx, `INSERT INTO tokens (kind, hash, account, scope, label, created_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id`, string(kind), hash, account, scope, label, now.Unix()).Scan(&id) if err != nil { return "", nil, err } return tok, &Token{ID: id, Kind: kind, Account: account, Scope: scope, Label: label, Created: now}, nil } // AccountForToken names the owner and stamps last_used, which the keys page shows. func (db *DB) AccountForToken(ctx context.Context, kind token.Kind, tok string) (*Token, error) { if k, err := token.KindOf(tok); err != nil || k != kind { return nil, ErrNotFound } var t Token var created int64 var expires sql.NullInt64 err := db.QueryRowContext(ctx, `SELECT id, account, scope, label, created_at, expires_at FROM tokens WHERE kind = ? AND hash = ?`, string(kind), token.Hash(tok)).Scan(&t.ID, &t.Account, &t.Scope, &t.Label, &created, &expires) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } nowT := now() if expires.Valid && nowT.Unix() > expires.Int64 { return nil, ErrNotFound } t.Kind = kind t.Created = time.Unix(created, 0) if _, err := db.ExecContext(ctx, `UPDATE tokens SET last_used = ? WHERE id = ?`, nowT.Unix(), t.ID); err != nil { return nil, err } return &t, nil } // DeleteToken revokes one token belonging to account. func (db *DB) DeleteToken(ctx context.Context, account string, id int64) error { res, err := db.ExecContext(ctx, `DELETE FROM tokens WHERE account = ? AND id = ?`, account, id) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // TokensOf lists tokens newest first, without sessions, which nobody manages by hand. func (db *DB) TokensOf(ctx context.Context, account string) ([]Token, error) { rows, err := db.QueryContext(ctx, `SELECT id, kind, account, scope, label, created_at, last_used FROM tokens WHERE account = ? AND kind != ? AND kind != ? ORDER BY created_at DESC, id DESC`, account, string(token.Session), string(token.Claim)) if err != nil { return nil, err } defer rows.Close() var out []Token for rows.Next() { var t Token var kind string var created int64 var used sql.NullInt64 if err := rows.Scan(&t.ID, &kind, &t.Account, &t.Scope, &t.Label, &created, &used); err != nil { return nil, err } t.Kind = token.Kind(kind) t.Created = time.Unix(created, 0) if used.Valid { t.LastUsed = time.Unix(used.Int64, 0) } out = append(out, t) } return out, rows.Err() } // RunnerTokenGrace is how long an unused runner token survives a reload of the page that made it. const RunnerTokenGrace = time.Hour // DropStaleRunnerTokens revokes runner tokens nobody pasted, so reloading the page cannot pile them up. func (db *DB) DropStaleRunnerTokens(ctx context.Context, account, repo string) error { _, err := db.ExecContext(ctx, `DELETE FROM tokens WHERE kind = ? AND account = ? AND scope = ? AND last_used IS NULL AND created_at < ? AND id NOT IN (SELECT token_id FROM runners)`, string(token.Runner), account, repo, now().Add(-RunnerTokenGrace).Unix()) return err }