// bucket.go is RFC 0016's binding: which bucket holds the files
// uploaded through the forge for one repository, and the key that
// writes there. Forge-local, like a mirror — the DAG carries stubs
// that name bytes by hash, and this row is where those bytes are
// today. Unbinding leaves every stub as it was; binding another
// bucket with the same objects makes them all live again.
//
// The secret is sealed before it reaches the table (secret.go) and
// unsealed only by Bucket, whose one caller is the ops layer signing
// a request. Nothing else reads it and no op returns it.
package oltp
import (
"database/sql"
"errors"
"time"
)
const bucketSchema = `
CREATE TABLE IF NOT EXISTS bucket (
repo TEXT PRIMARY KEY,
endpoint TEXT NOT NULL, -- https://<account>.r2.cloudflarestorage.com and the like
bucket TEXT NOT NULL,
region TEXT NOT NULL, -- 'auto' for R2
access_key TEXT NOT NULL,
secret_enc TEXT NOT NULL, -- sealed, never plaintext
public_base TEXT NOT NULL DEFAULT '', -- a public domain the bucket serves from; empty presigns every download
limit_bytes INTEGER NOT NULL DEFAULT 104857600,
probed_at TEXT NOT NULL DEFAULT '', -- last probe, success or not
probe_err TEXT NOT NULL DEFAULT '' -- empty when the last probe passed
);`
// DefaultBucketLimit is the per-file cap when a binding names none:
// a screen recording fits, a disk image does not (RFC 0016).
const DefaultBucketLimit int64 = 100 << 20
// BucketBinding is one repository's bucket, secret included. It is
// handed to the ops layer and nowhere else.
type BucketBinding struct {
Endpoint string
Bucket string
Region string
AccessKey string
Secret string
PublicBase string
LimitBytes int64
// ProbedAt is the last probe's stamp, ProbeErr what it said when
// it failed; empty ProbeErr with a stamp is a binding that works.
ProbedAt string
ProbeErr string
// CorsOK says the bucket answered a preflight for the forge's
// origin at the last probe, which is what the page's scripted
// upload needs and the form does not.
CorsOK bool
// Archives opens the type policy to zip, gzip and tar;
// DeleteObjects makes a removal also delete the object. Both off
// by default (attachment.go); a re-bind keeps them.
Archives bool
DeleteObjects bool
}
// BindBucket stores (replacing) a repository's binding. The secret is
// required here: an op that lets a steward edit the public base
// without retyping the key reads the stored one first and passes it
// back, so this layer never has to guess whether an empty secret
// means "keep" or "clear". The probe columns are the caller's to set
// afterwards, since a binding is written only once a probe passed.
func (d *DB) BindBucket(repo string, b BucketBinding) error {
if repo == "" || b.Endpoint == "" || b.Bucket == "" || b.AccessKey == "" || b.Secret == "" {
return errors.New("oltp: a bucket binding needs a repository, an endpoint, a bucket, an access key and a secret")
}
if b.LimitBytes <= 0 {
b.LimitBytes = DefaultBucketLimit
}
sealed, err := d.seal.seal(b.Secret)
if err != nil {
return err
}
_, err = d.db.Exec(`
INSERT INTO bucket (repo, endpoint, bucket, region, access_key, secret_enc, public_base, limit_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo) DO UPDATE SET
endpoint = excluded.endpoint, bucket = excluded.bucket, region = excluded.region,
access_key = excluded.access_key, secret_enc = excluded.secret_enc,
public_base = excluded.public_base, limit_bytes = excluded.limit_bytes`,
repo, b.Endpoint, b.Bucket, b.Region, b.AccessKey, sealed, b.PublicBase, b.LimitBytes)
return err
}
// ForgeBucket is the name the FORGE's own binding is kept under: the
// empty one, the same convention the fleet default for capability
// categories uses. A repository with no binding of its own inherits it,
// so a forge can supply storage for everybody on it and bringing your
// own stays a choice rather than a requirement.
const ForgeBucket = ""
// Bucket answers a repository's binding with the secret unsealed.
func (d *DB) Bucket(repo string) (BucketBinding, bool, error) {
var b BucketBinding
var sealed string
var archives, deleteObjects, corsOK int
err := d.db.QueryRow(`
SELECT endpoint, bucket, region, access_key, secret_enc, public_base, limit_bytes, probed_at, probe_err, archives, delete_objects, cors_ok
FROM bucket WHERE repo = ?`, repo).
Scan(&b.Endpoint, &b.Bucket, &b.Region, &b.AccessKey, &sealed, &b.PublicBase, &b.LimitBytes, &b.ProbedAt, &b.ProbeErr, &archives, &deleteObjects, &corsOK)
b.Archives, b.DeleteObjects, b.CorsOK = archives != 0, deleteObjects != 0, corsOK != 0
if errors.Is(err, sql.ErrNoRows) {
return BucketBinding{}, false, nil
}
if err != nil {
return BucketBinding{}, false, err
}
if b.Secret, err = d.seal.open(sealed); err != nil {
return BucketBinding{}, false, err
}
return b, true, nil
}
// UnbindBucket forgets the binding. The objects stay in the steward's
// bucket and the stubs stay in the DAG.
func (d *DB) UnbindBucket(repo string) error {
_, err := d.db.Exec(`DELETE FROM bucket WHERE repo = ?`, repo)
return err
}
// SetBucketProbe stamps a probe. An empty probeErr is a pass; corsOK
// is the preflight's separate verdict, recorded beside it because a
// bucket that takes the forge's PUT and refuses the browser's is a
// working binding with one thing left for the steward to do.
func (d *DB) SetBucketProbe(repo string, at time.Time, probeErr string, corsOK bool) error {
_, err := d.db.Exec(`UPDATE bucket SET probed_at = ?, probe_err = ?, cors_ok = ? WHERE repo = ?`,
at.UTC().Format(time.RFC3339), probeErr, boolInt(corsOK), repo)
return err
}