// 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
);
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`,
}
// 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 string
Name string
Size int64
Type string
SHA256 string
Who string
Created string
}
// UploadTicketTTL is how long a ticket answers. 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.
const UploadTicketTTL = time.Hour
// PutUploadTicket mints a ticket and returns its id.
func (d *DB) PutUploadTicket(t UploadTicket) (string, error) {
if t.Repo == "" || t.Target == "" || t.Name == "" || t.SHA256 == "" {
return "", errors.New("oltp: an upload ticket needs a repository, a target, a name and a hash")
}
id := rand.Text()
_, err := d.db.Exec(`INSERT INTO upload_ticket (id, repo, target, name, size, type, sha256, who, created)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, t.Repo, t.Target, 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, name, size, type, sha256, who, created
FROM upload_ticket WHERE id = ?`, id).
Scan(&t.ID, &t.Repo, &t.Target, &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
}