package oltp
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// Refusal is a save the guard would not make, with the reason in the
// words the page shows. It is the error type callers match on.
type Refusal struct{ Reason string }
func (r Refusal) Error() string { return r.Reason }
// GuardCaps judges a change to one account's string on a repository
// (RFC 0008, bulletproofing): a save that would remove the actor's own
// admin here is refused; a save that would leave the repository with
// no admin is refused unless the actor is a site admin, who is told
// instead. Site admins hold s through the account flag, so nothing
// they do to a string locks them out.
//
// This is the judgement alone, for a page previewing a save. A save
// goes through SetCapsGuarded, which makes the same judgement under
// the lock it writes with; judging here and writing elsewhere is the
// race that left repositories with no admin.
func (d *DB) GuardCaps(actor, account, repo, caps string) (warning string, err error) {
return d.guardCaps(d.db, actor, account, repo, caps)
}
func (d *DB) guardCaps(q querier, actor, account, repo, caps string) (warning string, err error) {
me, _ := accountByName(q, actor)
if !me.Admin && actor == account {
own, _ := capsOf(q, actor, repo)
if d.effectiveIn(q, actor, repo, own).HasAny("a") && !d.effectiveIn(q, actor, repo, caps).HasAny("a") {
return "", Refusal{fmt.Sprintf("that would remove your own admin on %s; have another admin make the change", repo)}
}
}
before, after, err := d.adminCountIn(q, repo, account, caps)
if err != nil {
return "", err
}
if before > 0 && after == 0 {
if me.Admin {
return fmt.Sprintf("%s has no admin of its own now; only site admins can change its settings", repo), nil
}
return "", Refusal{fmt.Sprintf("that would leave %s with no admin; make someone else an Admin first", repo)}
}
return "", nil
}
// adminCount counts the repository's admins — accounts whose effective
// set holds a on their own account, site admins aside — before and
// after one account's string becomes caps.
func (d *DB) adminCount(repo, account, caps string) (before, after int, err error) {
return d.adminCountIn(d.db, repo, account, caps)
}
func (d *DB) adminCountIn(q querier, repo, account, caps string) (before, after int, err error) {
holders, err := capHolders(q, repo)
if err != nil {
return 0, 0, err
}
for _, h := range holders {
if acct, _ := accountByName(q, h.Account); acct.Admin {
continue
}
if d.effectiveIn(q, h.Account, repo, h.Caps).HasAny("a") {
before++
}
own := h.Caps
if h.Account == account {
own = caps
}
if d.effectiveIn(q, h.Account, repo, own).HasAny("a") {
after++
}
}
return before, after, nil
}
// IsRefusal reports whether err is the guard saying no, including
// when an op has wrapped it on the way out.
func IsRefusal(err error) bool {
var r Refusal
return errors.As(err, &r)
}
// RefusalReason is the guard's own words from err, without whatever an
// op wrapped around them; "" when err is not a refusal.
func RefusalReason(err error) string {
var r Refusal
if errors.As(err, &r) {
return r.Reason
}
return ""
}
// Minimal drops every letter the others already imply, so a string
// assembled from checkboxes is stored the way a person would type it.
func Minimal(caps string) string {
// In the order given: the presets are hand-ordered strings, and a
// string that round-trips through the editor should come back as
// it went in.
full := Closure(caps)
var out []rune
for _, r := range capUnion(caps) {
rest := strings.ReplaceAll(full, string(r), "")
if !strings.ContainsRune(Closure(rest), r) {
out = append(out, r)
}
}
return string(out)
}
// Change says what a save did, in words: the role it moved between and
// the abilities it gained and lost. "" when nothing changed.
func Change(before, after string) string {
if Closure(before) == Closure(after) {
return ""
}
var parts []string
b, a := Infer(before), Infer(after)
switch {
case before == "":
parts = append(parts, "added as "+a.Role)
case after == "":
parts = append(parts, "removed from the repository")
case b.Role != a.Role:
parts = append(parts, b.Role+" → "+a.Role)
}
was := map[string]bool{}
for _, s := range Decode(before) {
was[s] = true
}
now := map[string]bool{}
var gained, lost []string
for _, s := range Decode(after) {
now[s] = true
if !was[s] {
gained = append(gained, s)
}
}
for _, s := range Decode(before) {
if !now[s] {
lost = append(lost, s)
}
}
if len(gained) > 0 && after != "" {
parts = append(parts, "gains: "+strings.Join(gained, ", "))
}
if len(lost) > 0 && before != "" && after != "" {
parts = append(parts, "loses: "+strings.Join(lost, ", "))
}
return strings.Join(parts, "; ")
}
// SetCapsGuarded is the one way a person's string on a repository is
// changed: GuardCaps and the write as one decision. The guard counts
// admins and the write changes them; run apart, two admins demoting
// each other at once each count two, each pass, and the repository is
// left with none. Here the write lock is taken before the count, so
// the second save counts after the first has landed and is refused.
func (d *DB) SetCapsGuarded(actor, account, repo, caps string) (warning string, err error) {
err = d.guarded(func(tx *sql.Tx) error {
if warning, err = d.guardCaps(tx, actor, account, repo, caps); err != nil {
return err
}
return setCaps(tx, account, repo, caps)
})
return warning, err
}
// GrantGuarded is Grant under the same guard: a role's string, or no
// string for NoRole, written as SetCapsGuarded writes it.
func (d *DB) GrantGuarded(actor, account, repo string, role Role) (warning string, err error) {
if role == Custom {
return "", errors.New("oltp: custom is a reading, not a role to grant")
}
caps := ""
if role != NoRole {
caps = role.Caps()
}
err = d.guarded(func(tx *sql.Tx) error {
if role != NoRole {
if _, found := accountByName(tx, account); !found {
return fmt.Errorf("oltp: no such account %q", account)
}
}
if warning, err = d.guardCaps(tx, actor, account, repo, caps); err != nil {
return err
}
return setCaps(tx, account, repo, caps)
})
return warning, err
}
// guarded runs a guard and its write as one transaction on the writer,
// which takes SQLite's write lock at BEGIN (the DSN's _txlock), so no
// other writer can change what the guard counts before the write
// lands. Everything inside fn goes through tx: a read through d.db
// would miss the transaction's own rows, and a write through d.w
// would wait forever on the one connection this transaction holds.
func (d *DB) guarded(fn func(tx *sql.Tx) error) error {
tx, err := d.w.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := fn(tx); err != nil {
return err
}
return tx.Commit()
}
// querier is the read half of a *sql.DB or a *sql.Tx, so a helper can
// read through a guard's transaction as well as the pool.
type querier interface {
QueryRow(query string, args ...any) *sql.Row
Query(query string, args ...any) (*sql.Rows, error)
}