// 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.w.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.w.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.w.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.w.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.w.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.w.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
}