Skip to content
quarry Artifact [3b15b3cc4b]
Login

Artifact [3b15b3cc4b]

Artifact 3b15b3cc4bbcbd2ded7b3575c18ffb017be3e9c5cb037fd2ade4d07b5a89211e:


// attachment.go is RFC 0016 Phase 3's forge-local state: the ticket an
// upload holds between begin and commit, the holds on uploads nobody
// trusted has vouched for, and the two policy switches on a binding.
//
// None of it is in the DAG. A ticket that never commits expires here
// and its object is the steward's lifecycle rule's to delete; a hold is
// display state on an A card that fossil clients see as any other; the
// switches decide what the forge does around a removal, not what the
// repository records.
package oltp

import (
	"crypto/rand"
	"database/sql"
	"errors"
	"time"
)

const attachmentSchema = `
CREATE TABLE IF NOT EXISTS upload_ticket (
	id      TEXT PRIMARY KEY,   -- opaque, random
	repo    TEXT NOT NULL,
	target  TEXT NOT NULL,      -- what the A card will name
	name    TEXT NOT NULL,
	size    INTEGER NOT NULL,
	type    TEXT NOT NULL,
	sha256  TEXT NOT NULL,
	who     TEXT NOT NULL,
	created TEXT NOT NULL,
	family  TEXT NOT NULL DEFAULT '' -- the kind a deferred ticket waits for
);
CREATE TABLE IF NOT EXISTS attachment_hold (
	repo     TEXT NOT NULL,
	artifact TEXT NOT NULL,     -- the A card
	target   TEXT NOT NULL,
	name     TEXT NOT NULL,
	who      TEXT NOT NULL,     -- the uploader
	created  TEXT NOT NULL,
	PRIMARY KEY (repo, artifact)
);`

// bucketMigrations widens the binding with Phase 3's two switches.
var bucketMigrations = []string{
	// May an upload declare an archive type. Off: the forge never sees
	// the bytes, so what it holds an upload to is the declared type.
	`ALTER TABLE bucket ADD COLUMN archives INTEGER NOT NULL DEFAULT 0`,
	// Does a removal also DELETE the object. Off: a removal is a
	// decision about the page and deletion one about the bytes, and
	// the steward owns the second (RFC 0016 §Writing).
	`ALTER TABLE bucket ADD COLUMN delete_objects INTEGER NOT NULL DEFAULT 0`,
	// Did the bucket answer a browser's preflight for this forge's
	// origin the last time it was probed. Off by default and never a
	// reason to refuse a binding: the multipart form still uploads
	// through the forge, only the scripted PUT from the page needs the
	// steward's CORS rule (docs/running.md).
	`ALTER TABLE bucket ADD COLUMN cors_ok INTEGER NOT NULL DEFAULT 0`,
	// A ticket begun from a composer at submit, before its object
	// exists (a new thread, a reply, a new ticket), names no target;
	// it remembers the family so commit can check the target it is
	// finally given against the letters begin was gated on.
	`ALTER TABLE upload_ticket ADD COLUMN family TEXT NOT NULL DEFAULT ''`,
}

// UploadTicket is what begin was told, kept so commit can check the
// object against it rather than against the caller's second claim.
type UploadTicket struct {
	ID   string
	Repo string
	// Target is what the A card will name. Empty for a DEFERRED ticket,
	// begun as a composer is submitted, before its object exists; the
	// op that writes the object then commits it and Family is checked
	// against the target's.
	Target  string
	Family  string
	Name    string
	Size    int64
	Type    string
	SHA256  string
	Who     string
	Created string
}

// UploadTicketTTL is how long a ticket answers, deferred or not. The
// presigned PUT it pairs with expires in fifteen minutes; a commit an
// hour later is a client that lost its way, and its object is an
// orphan either way. A deferred ticket is begun when the composer is
// submitted, not when the file was pasted, so it needs no longer.
const UploadTicketTTL = time.Hour

// PutUploadTicket mints a ticket and returns its id. A ticket needs a
// target or, when deferred, the family the target will belong to.
func (d *DB) PutUploadTicket(t UploadTicket) (string, error) {
	if t.Repo == "" || (t.Target == "" && t.Family == "") || t.Name == "" || t.SHA256 == "" {
		return "", errors.New("oltp: an upload ticket needs a repository, a target or a family, a name and a hash")
	}
	id := rand.Text()
	_, err := d.db.Exec(`INSERT INTO upload_ticket (id, repo, target, family, name, size, type, sha256, who, created)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, t.Repo, t.Target, t.Family, t.Name, t.Size, t.Type, t.SHA256, t.Who, now())
	return id, err
}

// UploadTicket reads one ticket by id, false when it is unknown or
// older than UploadTicketTTL. Expired rows are swept on the way.
func (d *DB) UploadTicket(id string) (UploadTicket, bool, error) {
	cutoff := time.Now().UTC().Add(-UploadTicketTTL).Format(time.RFC3339)
	if _, err := d.db.Exec(`DELETE FROM upload_ticket WHERE created < ?`, cutoff); err != nil {
		return UploadTicket{}, false, err
	}
	var t UploadTicket
	err := d.db.QueryRow(`SELECT id, repo, target, family, name, size, type, sha256, who, created
		FROM upload_ticket WHERE id = ?`, id).
		Scan(&t.ID, &t.Repo, &t.Target, &t.Family, &t.Name, &t.Size, &t.Type, &t.SHA256, &t.Who, &t.Created)
	if errors.Is(err, sql.ErrNoRows) {
		return UploadTicket{}, false, nil
	}
	return t, err == nil, err
}

// DeleteUploadTicket spends a ticket. A commit is once.
func (d *DB) DeleteUploadTicket(id string) error {
	_, err := d.db.Exec(`DELETE FROM upload_ticket WHERE id = ?`, id)
	return err
}

// Hold is one upload waiting for a person to vouch for it.
type Hold struct {
	Repo     string `json:"repo"`
	Artifact string `json:"artifact"`
	Target   string `json:"target"`
	Name     string `json:"name"`
	Who      string `json:"who"`
	Created  string `json:"created"`
}

// HoldAttachment records that artifact's upload is held.
func (d *DB) HoldAttachment(h Hold) error {
	_, err := d.db.Exec(`INSERT OR REPLACE INTO attachment_hold (repo, artifact, target, name, who, created)
		VALUES (?, ?, ?, ?, ?, ?)`, h.Repo, h.Artifact, h.Target, h.Name, h.Who, now())
	return err
}

// ReleaseHold clears the hold on one artifact; a vouch and a reject
// both end here. Releasing an artifact that was never held is fine.
func (d *DB) ReleaseHold(repo, artifact string) error {
	_, err := d.db.Exec(`DELETE FROM attachment_hold WHERE repo = ? AND artifact = ?`, repo, artifact)
	return err
}

// Holds lists a repository's held uploads, oldest first: the review
// queue's order.
func (d *DB) Holds(repo string) ([]Hold, error) {
	rows, err := d.db.Query(`SELECT repo, artifact, target, name, who, created
		FROM attachment_hold WHERE repo = ? ORDER BY created, artifact`, repo)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Hold
	for rows.Next() {
		var h Hold
		if err := rows.Scan(&h.Repo, &h.Artifact, &h.Target, &h.Name, &h.Who, &h.Created); err != nil {
			return nil, err
		}
		out = append(out, h)
	}
	return out, rows.Err()
}

// HeldArtifacts is the set of held A cards on one repository, for
// decorating a list without a query per row.
func (d *DB) HeldArtifacts(repo string) (map[string]bool, error) {
	holds, err := d.Holds(repo)
	if err != nil {
		return nil, err
	}
	out := make(map[string]bool, len(holds))
	for _, h := range holds {
		out[h.Artifact] = true
	}
	return out, nil
}

// SetBucketPolicy flips the binding's two switches. The row must
// exist: policy on a repository with no bucket is policy on nothing.
func (d *DB) SetBucketPolicy(repo string, archives, deleteObjects bool) error {
	res, err := d.db.Exec(`UPDATE bucket SET archives = ?, delete_objects = ? WHERE repo = ?`,
		boolInt(archives), boolInt(deleteObjects), repo)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return errors.New("oltp: no bucket is bound to " + repo)
	}
	return nil
}