// avatar.go is a repository's own mark, when somebody has uploaded one.
//
// 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: a repository with
// no row keeps the identicon derived from its name, which is what
// every repository had before this existed (RFC 0018 ยง"An uploaded
// avatar", amended).
package oltp
import (
"database/sql"
"errors"
"time"
)
const avatarSchema = `
CREATE TABLE IF NOT EXISTS repo_avatar (
repo TEXT PRIMARY KEY,
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
);`
// RepoAvatar is one repository's uploaded mark.
type RepoAvatar struct {
Repo string `json:"repo"`
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"`
}
// SetRepoAvatar records a repository's uploaded mark, replacing any it
// had. The caller has already written the object; this is the row that
// makes it findable.
func (d *DB) SetRepoAvatar(a RepoAvatar) error {
if a.Repo == "" || a.Key == "" || a.Mime == "" {
return errors.New("oltp: avatar needs a repository, a key and a type")
}
if a.At == "" {
a.At = time.Now().UTC().Format(time.RFC3339)
}
_, err := d.db.Exec(`INSERT INTO repo_avatar (repo, key, mime, etag, bytes, set_by, at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo) DO UPDATE SET key = excluded.key, mime = excluded.mime,
etag = excluded.etag, bytes = excluded.bytes, set_by = excluded.set_by, at = excluded.at`,
a.Repo, a.Key, a.Mime, a.ETag, a.Bytes, a.SetBy, a.At)
return err
}
// RepoAvatarOf reads a repository's mark. Absent is not an error.
func (d *DB) RepoAvatarOf(repo string) (RepoAvatar, bool, error) {
var a RepoAvatar
err := d.db.QueryRow(`SELECT repo, key, mime, etag, bytes, set_by, at
FROM repo_avatar WHERE repo = ?`, repo).
Scan(&a.Repo, &a.Key, &a.Mime, &a.ETag, &a.Bytes, &a.SetBy, &a.At)
if errors.Is(err, sql.ErrNoRows) {
return RepoAvatar{}, false, nil
}
if err != nil {
return RepoAvatar{}, false, err
}
return a, true, nil
}
// RepoAvatarETags is every repository's validator in one 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) RepoAvatarETags() (map[string]string, error) {
rows, err := d.db.Query(`SELECT repo, etag FROM repo_avatar`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var repo, etag string
if err := rows.Scan(&repo, &etag); err != nil {
return nil, err
}
out[repo] = etag
}
return out, rows.Err()
}
// ClearRepoAvatar 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) ClearRepoAvatar(repo string) error {
_, err := d.db.Exec(`DELETE FROM repo_avatar WHERE repo = ?`, repo)
return err
}