package oltp
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// The durable record of who changed a repository's settings.
//
// One row per FIELD that actually changed, not per request. The
// question an operator asks is "when did mirrorable last change and who
// did it"; a row per request, carrying a blob, cannot answer that
// without parsing, and the 24-hour event trail's "settings changed"
// with no field name is exactly the useless version of it.
//
// Append-only. There is no method here that edits or removes an entry,
// for the same reason AppendModLog has none.
const settingsLogSchema = `
CREATE TABLE IF NOT EXISTS repo_settings_log (
repo TEXT NOT NULL,
at TEXT NOT NULL, -- RFC3339 UTC. The row carries its own
-- stamp because it outlives the event
-- that reported the same change: that
-- stream keeps 24 hours, and after
-- that only this row can date it.
actor TEXT NOT NULL, -- an account, or '' when nothing named one
field TEXT NOT NULL, -- description | visibility | homepage |
-- topics | chat | mirrorable
was TEXT NOT NULL,
"now" TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS repo_settings_log_by_repo ON repo_settings_log (repo, at);`
// RepoPatch changes only the settings it names. A nil field is not
// being changed.
//
// The whole point of the shape: a patch has no before-image, so there
// is nothing to go stale between reading and writing. The write path
// this replaced read every setting, overlaid the named ones and wrote
// the row back, which lost a concurrent edit to any field the second
// writer had not touched.
type RepoPatch struct {
Description *string
// Visibility is normalised to Public unless it is exactly Private,
// the same rule SetRepoVisibility applies.
Visibility *string
Homepage *string
// Topics is the space-separated list, already normalised.
Topics *string
Chat *bool
Mirrorable *bool
}
// Any reports whether this patch changes anything.
func (p RepoPatch) Any() bool {
return p.Description != nil || p.Visibility != nil || p.Homepage != nil ||
p.Topics != nil || p.Chat != nil || p.Mirrorable != nil
}
// SettingsChange is one field that changed, as the log records it.
type SettingsChange struct {
At string
Actor string
Field string
Was string
Now string
}
// PatchRepo writes the settings this patch names and records what
// changed, in ONE transaction.
//
// The before-value is read inside that transaction, which is the only
// place it is trustworthy: read it outside and another writer can move
// it before the update lands, so the log would assert a `was` that was
// never true. An audit trail that is wrong about the previous value is
// worse than one that omits it, because it is what somebody reaches for
// precisely when they have stopped trusting their own reconstruction.
//
// Three properties fall out of doing it here rather than at a caller:
// the write and its record cannot diverge, because no path writes the
// column and skips the log; a field set to the value it already holds
// writes nothing and records nothing, which only the transaction can
// know; and the returned slice tells the caller what actually changed.
//
// An EMPTY result with a nil error means the write arrived and changed
// nothing. That is deliberately distinguishable from an error, because
// "nothing happened" and "the write never landed" looked identical from
// outside during the incident this log exists to make legible.
func (d *DB) PatchRepo(name string, p RepoPatch, by string) ([]SettingsChange, error) {
if !p.Any() {
return nil, nil
}
tx, err := d.w.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var description, visibility, homepage, topics string
var chat, mirrorable int
err = tx.QueryRow(`SELECT description, visibility, homepage, topics, chat, mirrorable
FROM repo WHERE name = ?`, name).
Scan(&description, &visibility, &homepage, &topics, &chat, &mirrorable)
if errors.Is(err, sql.ErrNoRows) {
// Refused rather than reported as a successful no-op, the same
// way SetRepoVisibility refuses: a settings write that says it
// worked while touching nothing is worse than one that is
// absent.
return nil, fmt.Errorf("no repository named %q", name)
}
if err != nil {
return nil, err
}
stamp := now()
var sets []string
var args []any
var changes []SettingsChange
put := func(column, was, to string) {
if was == to {
return
}
sets = append(sets, column+" = ?")
switch column {
case "chat", "mirrorable":
args = append(args, to == "true")
default:
args = append(args, to)
}
changes = append(changes, SettingsChange{At: stamp, Actor: by,
Field: column, Was: clipValue(was), Now: clipValue(to)})
}
// Visibility first, because whether mirrorable may be written at
// all depends on where this patch leaves it.
nowPrivate := visibility == Private
if p.Visibility != nil {
want := Public
if *p.Visibility == Private {
want = Private
}
put("visibility", visibility, want)
nowPrivate = want == Private
}
if p.Description != nil {
put("description", description, *p.Description)
}
if p.Homepage != nil {
put("homepage", homepage, *p.Homepage)
}
if p.Topics != nil {
put("topics", topics, *p.Topics)
}
if p.Chat != nil {
put("chat", boolText(chat != 0), boolText(*p.Chat))
}
if p.Mirrorable != nil && !nowPrivate {
// Skipped only for the WRITE while private, never for the read:
// the setting has no effect there (a private repository is
// never mirrorable regardless of the flag — see MayMirror), and
// the settings form's checkbox is disabled for exactly this
// state, so whatever it submitted is not the admin's intent.
// A choice made while public therefore survives a private
// detour instead of being zeroed by a disabled control.
//
// The comparison is against the STORED flag held right here.
// Comparing against the EFFECTIVE answer is what laundered
// "false because private" into "false because asked" and left
// a public repository unclonable.
put("mirrorable", boolText(mirrorable != 0), boolText(*p.Mirrorable))
}
if len(sets) == 0 {
return nil, tx.Commit()
}
args = append(args, name)
if _, err := tx.Exec(`UPDATE repo SET `+strings.Join(sets, ", ")+` WHERE name = ?`,
args...); err != nil {
return nil, err
}
for _, c := range changes {
if _, err := tx.Exec(`INSERT INTO repo_settings_log (repo, at, actor, field, was, "now")
VALUES (?, ?, ?, ?, ?, ?)`, name, c.At, c.Actor, c.Field, c.Was, c.Now); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
// The other half of a visibility change, which writes a different
// table and so runs after the commit — the order SetRepoVisibility
// has always used.
for _, c := range changes {
if c.Field == "visibility" {
if err := d.applyVisibilityCategories(name, c.Now); err != nil {
return changes, err
}
}
}
return changes, nil
}
// SettingsLog lists a repository's settings changes, newest first.
//
// Absence of rows means no field changed, NOT that nobody saved: a save
// that changed nothing records nothing. PatchRepo's return value is
// what distinguishes those two for the caller making the write.
func (d *DB) SettingsLog(repo string, limit int) ([]SettingsChange, error) {
rows, err := d.db.Query(`SELECT at, actor, field, was, "now" FROM repo_settings_log
WHERE repo = ? ORDER BY at DESC, rowid DESC LIMIT ?`, repo, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SettingsChange
for rows.Next() {
var c SettingsChange
if err := rows.Scan(&c.At, &c.Actor, &c.Field, &c.Was, &c.Now); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func boolText(on bool) string {
if on {
return "true"
}
return "false"
}
// clipValue bounds a stored value. The handler already enforces each
// field's limit, so this only catches a value that reached the database
// by some other path; an audit row is not worth an unbounded write.
//
// NOT oltp.clip, which cuts to 19 characters because it is shortening a
// timestamp to seconds. Calling that one here stored every audit value
// chopped at 19, and since both sides were chopped the same way a real
// change came back reading was == now -- the log denying the change it
// existed to record, while reporting success. It compiled because the
// two have the same signature, and it survived the tests because every
// fixture value was shorter than 19 characters.
// TestPatchRepoKeepsLongValuesWhole is sized to catch it.
func clipValue(s string) string {
const max = 600
if len(s) > max {
return s[:max] + "…"
}
return s
}