Skip to content
quarry Artifact [0682216455]
Login

Artifact [0682216455]

Artifact 0682216455ed93851b944d9e9afa0a3d773bb8700eda4090b1e97203f5343640:


// avatar.go is an uploaded mark, for the named things a page draws one
// beside: a repository, or a team.
//
// One table for both, because a mark is a mark — the same upload, the
// same validation, the same serving endpoint, the same fallback. Two
// tables would be two of each, and the second would drift.
//
// The BYTES are not here. They live in the forge's own bucket
// (oltp.ForgeBucket), because that is the storage the operator already
// pays for and configures, and because a database is a poor place for
// binary that is served on every listing. What is here is the little a
// page needs to serve it: the key, the media type, and a strong
// validator so a reader fetches the image once.
//
// Absence is the ordinary case and is not an error: something with no
// row keeps the identicon derived from its name, which is what
// everything had before this existed (RFC 0018 §"An uploaded avatar",
// amended).
package oltp

import (
	"database/sql"
	"errors"
	"fmt"
	"time"
)

// AvatarKind is what a name belongs to. A team and a repository can
// hold the same name — teams share the ACCOUNT namespace and
// repositories have their own — so the kind is half the key and not a
// label.
type AvatarKind string

const (
	AvatarRepo AvatarKind = "repo"
	AvatarTeam AvatarKind = "team"
	// AvatarUser is a person's own picture. It shares this table for
	// the reason the other two do — a mark is a mark, and the upload,
	// the validation, the serving and the fallback are one mechanism —
	// and it shares the (kind, name) key because an account and a
	// repository may hold the same name.
	AvatarUser AvatarKind = "user"
)

const avatarSchema = `
CREATE TABLE IF NOT EXISTS avatar (
	kind   TEXT NOT NULL,          -- 'repo' | 'team'
	name   TEXT NOT NULL,
	key    TEXT NOT NULL,          -- the object in the forge's bucket
	mime   TEXT NOT NULL,          -- what to serve it as; validated before it was stored
	etag   TEXT NOT NULL,          -- sha256 of the bytes, for If-None-Match
	bytes  INTEGER NOT NULL,
	set_by TEXT NOT NULL DEFAULT '',
	at     TEXT NOT NULL,
	PRIMARY KEY (kind, name)
);`

// avatarMigrations drops the first shape of this table.
//
// repo_avatar was keyed on a repository alone and landed one change
// before teams were asked for. Dropping rather than migrating is safe
// here for a reason that will not hold again: no writer for it ever
// existed, so the table is empty in every database that has it — it
// was created on open and never written to. A rename would carry rows
// nobody can have.
var avatarMigrations = []string{
	`DROP TABLE IF EXISTS repo_avatar`,
}

// Avatar is one uploaded mark.
type Avatar struct {
	Kind  AvatarKind `json:"kind"`
	Name  string     `json:"name"`
	Key   string     `json:"key"`
	Mime  string     `json:"mime"`
	ETag  string     `json:"etag"`
	Bytes int64      `json:"bytes"`
	SetBy string     `json:"setBy,omitempty"`
	At    string     `json:"at"`
}

func validKind(k AvatarKind) bool {
	return k == AvatarRepo || k == AvatarTeam || k == AvatarUser
}

// SetAvatar records a mark, replacing any the same thing had. The
// caller has already written the object; this is the row that makes it
// findable.
func (d *DB) SetAvatar(a Avatar) error {
	if !validKind(a.Kind) {
		return fmt.Errorf("oltp: avatar: unknown kind %q", a.Kind)
	}
	if a.Name == "" || a.Key == "" || a.Mime == "" {
		return errors.New("oltp: avatar needs a name, a key and a type")
	}
	if a.At == "" {
		a.At = time.Now().UTC().Format(time.RFC3339)
	}
	_, err := d.db.Exec(`INSERT INTO avatar (kind, name, key, mime, etag, bytes, set_by, at)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?)
		ON CONFLICT(kind, name) DO UPDATE SET key = excluded.key, mime = excluded.mime,
			etag = excluded.etag, bytes = excluded.bytes, set_by = excluded.set_by, at = excluded.at`,
		string(a.Kind), a.Name, a.Key, a.Mime, a.ETag, a.Bytes, a.SetBy, a.At)
	return err
}

// AvatarOf reads one mark. Absent is not an error. A person with no
// picture of their own under a name that is a fossil login LINKED to a
// forge account wears that account's: the login is the same person,
// matched by whoever synced the repository's users.
func (d *DB) AvatarOf(kind AvatarKind, name string) (Avatar, bool, error) {
	a, ok, err := d.avatarRow(kind, name)
	if err != nil || ok || kind != AvatarUser {
		return a, ok, err
	}
	links, err := d.linkedLogins()
	if err != nil {
		return Avatar{}, false, err
	}
	if acct, linked := links[name]; linked {
		return d.avatarRow(kind, acct)
	}
	return Avatar{}, false, nil
}

// linkedLogins is every fossil login linked to a forge account, login →
// account. A login linked to DIFFERENT accounts in different
// repositories is two people who share a name, and is left out: a
// picture must never make one of them look like the other.
func (d *DB) linkedLogins() (map[string]string, error) {
	rows, err := d.db.Query(`SELECT DISTINCT login, linked FROM fossil_user WHERE linked != ''`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]string{}
	torn := map[string]bool{}
	for rows.Next() {
		var login, acct string
		if err := rows.Scan(&login, &acct); err != nil {
			return nil, err
		}
		if have, ok := out[login]; ok && have != acct {
			torn[login] = true
		}
		out[login] = acct
	}
	for login := range torn {
		delete(out, login)
	}
	return out, rows.Err()
}

func (d *DB) avatarRow(kind AvatarKind, name string) (Avatar, bool, error) {
	var a Avatar
	var k string
	err := d.db.QueryRow(`SELECT kind, name, key, mime, etag, bytes, set_by, at
		FROM avatar WHERE kind = ? AND name = ?`, string(kind), name).
		Scan(&k, &a.Name, &a.Key, &a.Mime, &a.ETag, &a.Bytes, &a.SetBy, &a.At)
	if errors.Is(err, sql.ErrNoRows) {
		return Avatar{}, false, nil
	}
	if err != nil {
		return Avatar{}, false, err
	}
	a.Kind = AvatarKind(k)
	return a, true, nil
}

// AvatarETags is every validator of one kind in a single read, for the
// listings that draw many marks at once. A page that asked per row
// would issue one query per repository on the forge's front page.
func (d *DB) AvatarETags(kind AvatarKind) (map[string]string, error) {
	rows, err := d.db.Query(`SELECT name, etag FROM avatar WHERE kind = ?`, string(kind))
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]string{}
	for rows.Next() {
		var name, etag string
		if err := rows.Scan(&name, &etag); err != nil {
			return nil, err
		}
		out[name] = etag
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	// A linked login wears its account's picture unless it has its own
	// (AvatarOf, which serves it, answers the same way).
	if kind == AvatarUser {
		links, err := d.linkedLogins()
		if err != nil {
			return nil, err
		}
		for login, acct := range links {
			if _, own := out[login]; own {
				continue
			}
			if etag, ok := out[acct]; ok {
				out[login] = etag
			}
		}
	}
	return out, nil
}

// ClearAvatar drops the row. The object is left in the bucket: a
// removal is a decision about the page and a deletion one about the
// bytes, which is the split RFC 0016 already draws for attachments,
// and the operator owns the second.
func (d *DB) ClearAvatar(kind AvatarKind, name string) error {
	_, err := d.db.Exec(`DELETE FROM avatar WHERE kind = ? AND name = ?`, string(kind), name)
	return err
}