// uvsync.go is what RFC 0020's sync front needs from the catalog and
// the bucket beyond rung 2's ops: whether a write can land at all, a
// file's bytes, and fossil's mtime-only update. The front itself is
// internal/xfer/unversioned.go; SyncUV is the seam it is handed.
package api
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/uv"
"craftdesign.group/quarry/internal/xfer"
)
// SyncUV answers the sync front's unversioned questions
// (xfer.Unversioned) from these Deps. Its refusals are sentences for a
// fossil error card, without the ops layer's sentinel on the end.
type SyncUV struct{ Deps }
var _ xfer.Unversioned = SyncUV{}
// UVWrite is Deps.UVWrite with its refusal in plain words.
func (s SyncUV) UVWrite(ctx context.Context, repo string, f uv.File, content []byte) (uv.File, bool, error) {
out, applied, err := s.Deps.UVWrite(ctx, repo, f, content)
return out, applied, plainErr(err)
}
// UVWritable is the binding's per-file limit, or why repo can take no
// unversioned write at all.
func (s SyncUV) UVWritable(ctx context.Context, repo string) (int64, error) {
_, b, _, ok, err := s.uvBinding(repo)
if err != nil {
return 0, err
}
if !ok {
return 0, fmt.Errorf("%s has no bucket of its own: %s", repo, NoUVBucket)
}
if err := s.uvPublicRefusal(repo, b); err != nil {
return 0, plainErr(err)
}
return b.LimitBytes, nil
}
// UVContent is Deps.UVContent.
func (s SyncUV) UVContent(ctx context.Context, repo string, f uv.File) ([]byte, error) {
return s.Deps.UVContent(ctx, repo, f)
}
// UVTouch is Deps.UVTouch with its refusal in plain words.
func (s SyncUV) UVTouch(ctx context.Context, repo string, f uv.File) (uv.File, bool, error) {
out, applied, err := s.Deps.UVTouch(ctx, repo, f)
return out, applied, plainErr(err)
}
// MayPublish reports whether user holds y on repo. A forge with no
// accounts plane has nobody to hold it, so the answer is no: unlike an
// op's own gate, the sync front has no other floor to fall back on.
func (s SyncUV) MayPublish(repo, user string) bool {
return s.CapsFor != nil && user != "" && s.CapsFor(user, repo).HasAny("y")
}
// plainErr drops the ops sentinel an error was wrapped with for HTTP,
// which a fossil client would print as part of the sentence.
func plainErr(err error) error {
for _, sentinel := range []error{ops.ErrInvalid, ops.ErrForbidden, ops.ErrNotFound} {
if err != nil && errors.Is(err, sentinel) {
return errors.New(strings.TrimSuffix(err.Error(), ": "+sentinel.Error()))
}
}
return err
}
// uvTransferTimeout bounds one bucket transfer of size bytes: 30
// seconds, plus 2 seconds for every MiB begun, which is half a MiB a
// second at worst. A file runs up to the binding's limit (100 MiB by
// default, more if its admin says so), and attachments' fixed 20
// seconds would refuse a large release on an ordinary link while a
// fixed generous one would leave a stalled small write hanging.
func uvTransferTimeout(size int64) time.Duration {
const mib = 1 << 20
return 30*time.Second + time.Duration((size+mib-1)/mib)*2*time.Second
}
// uvClient is the client for a transfer of size bytes.
func uvClient(size int64) *http.Client {
return &http.Client{Timeout: uvTransferTimeout(size)}
}
// UVContent is one file's bytes from the bucket, read whole, bounded
// by the binding's per-file limit: the sync front puts them in a
// uvfile card, and a reply is one buffer.
func (d Deps) UVContent(ctx context.Context, repo string, f uv.File) ([]byte, error) {
bucket, b, _, ok, err := d.uvBinding(repo)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("%s has no bucket of its own", repo)
}
if f.Size > b.LimitBytes {
return nil, fmt.Errorf("%s is %d bytes, over the bucket's limit of %d, lowered since it was published: %w", f.Name, f.Size, b.LimitBytes, xfer.ErrUVTooLarge)
}
return bucket.GetMax(ctx, uvClient(min(f.Size, b.LimitBytes)), uv.Key(repo, f.Name), b.LimitBytes, time.Now())
}
// UVTouch is fossil's mtime-only update: a uvfile card with flag 0x0004
// names a hash the catalog already holds with an older mtime, and
// fossil's receiver then changes the mtime alone (unversioned_status
// 2 in xfer_accept_unversioned_file). The object's metadata is
// rewritten in place by a copy onto itself, so the bytes do not move.
// Anything else is not applied: without content there is nothing to
// put in the bucket, and fossil's other cases would leave a row with no
// bytes.
func (d Deps) UVTouch(ctx context.Context, repo string, f uv.File) (uv.File, bool, error) {
if err := uv.CheckName(f.Name); err != nil {
return uv.File{}, false, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
}
bucket, b, _, ok, err := d.uvBinding(repo)
if err != nil {
return uv.File{}, false, err
}
if !ok {
return uv.File{}, false, fmt.Errorf("%s has no bucket of its own: %s: %w", repo, NoUVBucket, ops.ErrInvalid)
}
if err := d.uvPublicRefusal(repo, b); err != nil {
return uv.File{}, false, err
}
unlock := uvLock(repo)
defer unlock()
if err := d.uvEnsure(ctx, repo, bucket, b); err != nil {
return uv.File{}, false, err
}
have, exists, err := d.UV.UVFile(repo, f.Name)
if err != nil {
return uv.File{}, false, err
}
if !exists || have.Deleted || have.Hash != f.Hash || have.Mtime >= f.Mtime {
return have, false, nil
}
f.Size, f.Deleted = have.Size, false
now := time.Now()
key := uv.Key(repo, f.Name)
if err := bucket.Copy(ctx, uvClient(f.Size), key, key, uvPutOptions(f), now); err != nil {
return uv.File{}, false, fmt.Errorf("updating %s in the bucket: %w", key, err)
}
if err := d.uvRecord(repo, &f, now); err != nil {
return uv.File{}, false, err
}
d.raiseUVChanged(ctx, repo, f)
return f, true, nil
}