Skip to content
quarry Artifact [2eaf3594af]
Login

Artifact [2eaf3594af]

Artifact 2eaf3594af46322ab5ef018358bf21a183f5d66973df0c5d25cc0f05a0cece67:


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.db.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: clip(was), Now: clip(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.
func clipValue(s string) string {
	const max = 600
	if len(s) > max {
		return s[:max] + "…"
	}
	return s
}