Skip to content
quarry Artifact [09d87abec3]
Login

Artifact [09d87abec3]

Artifact 09d87abec35c52fa897510d4beeb3374a68c2b00166d7c41ba40d40660b400f4:


// attachwrite.go is RFC 0016 Phase 3: writing an attachment through the
// forge. The browser never sends the file to quarry — begin mints a
// presigned PUT whose signature fixes the key, the length and the type;
// the browser PUTs; commit checks what landed and writes the stub
// through zit. What the forge holds between the two is a ticket, so
// commit is checked against what begin was told and not against the
// caller's second claim.
//
// Every write here is gated three times: the family's upload letter (b
// for tickets, wiki pages and technotes; B for forum posts), the bound
// bucket, and the policy — the limit and the type allowlist. What it
// cannot gate is the bytes, which it never sees; that is the held rule
// below, and the steward's scanner on their own bucket.
package api

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"log"
	"mime"
	"net/http"
	"path"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/attach"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/s3"
	"craftdesign.group/quarry/internal/zitcli"
)

// AttachmentStore is Phase 3's forge-local state: upload tickets, holds
// and the binding's two policy switches. nil is a forge without an
// OLTP, where the write ops answer unavailable and every list is
// unheld.
type AttachmentStore interface {
	PutUploadTicket(t oltp.UploadTicket) (string, error)
	UploadTicket(id string) (oltp.UploadTicket, bool, error)
	DeleteUploadTicket(id string) error
	HoldAttachment(h oltp.Hold) error
	ReleaseHold(repo, artifact string) error
	Holds(repo string) ([]oltp.Hold, error)
	HeldArtifacts(repo string) (map[string]bool, error)
	SetBucketPolicy(repo string, archives, deleteObjects bool) error
}

const (
	// uploadExpiry is how long a presigned PUT is good for. Fifteen
	// minutes: long enough for a slow link to move a hundred megabytes,
	// short enough that a leaked URL is not a standing door.
	uploadExpiry = 15 * time.Minute
	// linkExpiry is a presigned GET's life: the hour the RFC names, so a
	// private repository's file cannot be shared onward for long.
	linkExpiry = time.Hour
	// maxFormUploadBytes bounds what the no-script form path holds in
	// memory. A presigned PUT signs the content length, so the bytes
	// must be counted before they are sent, and a multipart part's
	// length is unknown until it has been read; the scripted path knows
	// it from the File object and has no such bound.
	maxFormUploadBytes = 32 << 20
)

// attachFamily is which kind of object an A card's target names, and
// with it which letters apply (RFC 0008: b for tickets, wiki pages and
// technotes; B for forum posts; q, l and 5 to vouch).
type attachFamily struct {
	Family string // ticket | wiki | technote | forum
	// Target is the A card's target as the index knows it — the FULL
	// ticket id where the caller may have passed a prefix.
	Target string
	// Local says the target is a ticket on the forge tier: it has no A
	// card to hang a file on yet, so an upload against it is HELD in
	// the forge database and written to the log when the ticket is
	// promoted (9e628be2). The letters are the ticket family's; only
	// where the stub goes differs.
	Local bool
}

func (f attachFamily) uploadLetter() string {
	if f.Family == "forum" {
		return "B"
	}
	return "b"
}

func (f attachFamily) vouchLetter() string {
	switch f.Family {
	case "ticket":
		return "q"
	case "forum":
		return "5"
	}
	return "l"
}

// attachFamilyOf resolves a target against the index. A wiki page is
// found by its exact name first, since a page may be called anything;
// the three hash-addressed kinds follow. false means no object of any
// kind answers, which is the one refusal that must never be guessed
// around: zit writes an A card for any target string.
func attachFamilyOf(ix *index.Index, target string) (attachFamily, bool, error) {
	if target == "" {
		return attachFamily{}, false, nil
	}
	if _, ok, err := ix.WikiPage(target); err != nil || ok {
		return attachFamily{Family: "wiki", Target: target}, ok, err
	}
	if t, ok, err := ix.TicketByPrefixAll(target); err != nil || ok {
		return attachFamily{Family: "ticket", Target: t.ID}, ok, err
	}
	if n, ok, err := ix.TechnoteByEvent(target); err != nil || ok {
		return attachFamily{Family: "technote", Target: n.Event}, ok, err
	}
	if v, ok, err := ix.ForumPostVersions(target); err != nil || ok {
		f := attachFamily{Family: "forum", Target: target}
		if ok && len(v) > 0 && strings.HasPrefix(v[len(v)-1].Hash, target) {
			f.Target = v[len(v)-1].Hash
		}
		return f, ok, err
	}
	return attachFamily{}, false, nil
}

// attachFamilyFor is attachFamilyOf plus the forge tier: a target the
// index does not hold may still be a local ticket, which takes
// attachments like any other ticket and holds them until promotion.
// The index is asked first, so a promoted ticket resolves to its
// artifact and never to the tombstone it left behind.
func (d Deps) attachFamilyFor(repo *Repo, name, target string) (attachFamily, bool, error) {
	f, ok, err := attachFamilyOf(repo.Index, target)
	if err != nil || ok || d.Local == nil {
		return f, ok, err
	}
	lt, ok, err := d.Local.LocalTicketByPrefix(name, strings.TrimPrefix(target, "tkt:"))
	if err != nil || !ok {
		return attachFamily{}, false, err
	}
	return attachFamily{Family: "ticket", Target: lt.ID, Local: true}, true, nil
}

// localAttachmentRows presents what a local ticket holds in the shape
// every attachment reader already takes. A held file has no artifact,
// so Hash is empty and Src is the object's key: the address
// attachment.head and attachment.link answer for it, and the one the
// pages link through.
func (d Deps) localAttachmentRows(name, ticket string) ([]index.Attachment, error) {
	if d.Local == nil {
		return nil, nil
	}
	held, err := d.Local.LocalAttachments(name, ticket)
	if err != nil {
		return nil, err
	}
	out := make([]index.Attachment, 0, len(held))
	for _, a := range held {
		out = append(out, index.Attachment{
			Name: a.Name, Target: a.Ticket, Src: a.SHA256, Date: a.Created, User: a.Who,
			Mimetype: a.Type, External: true, SHA256: a.SHA256, Size: a.Size,
		})
	}
	return out, nil
}

// localAttachmentBySrc finds a held file by its object key, for the
// read ops that take an attachment's address: they look in the index
// first and here second, so a held file reads like any upload.
func (d Deps) localAttachmentBySrc(name, src string) (index.Attachment, bool) {
	if d.Local == nil {
		return index.Attachment{}, false
	}
	a, ok, err := d.Local.LocalAttachmentBySHA(name, src)
	if err != nil || !ok {
		return index.Attachment{}, false
	}
	return index.Attachment{
		Name: a.Name, Target: a.Ticket, Src: a.SHA256, Date: a.Created, User: a.Who,
		Mimetype: a.Type, External: true, SHA256: a.SHA256, Size: a.Size,
	}, true
}

// holds answers whether the caller holds any of these letters on repo.
// The edge resolved them into the context when a capability plane
// exists; without one every caller is the operator, which is the rule
// TrustedAuthor and the router already apply.
func (d Deps) holds(ctx context.Context, repo, letters string) bool {
	if ops.HasCaps(ctx) {
		return ops.CapsOf(ctx).HasAny(letters)
	}
	if d.CapsFor != nil {
		return d.CapsFor(author(ctx), repo).HasAny(letters)
	}
	return true
}

// attachWriteRepo is repoFor for a write: the repository must exist,
// accept writes, and have a store to write to.
func (d Deps) attachWriteRepo(ctx context.Context, name string) (*Repo, error) {
	repo, err := d.repoFor(ctx, name)
	if err != nil {
		return nil, err
	}
	if m, _ := d.posture(name); m.Tracking {
		return nil, fmt.Errorf("%w: %s mirrors %s and accepts no writes, uploads included", ops.ErrForbidden, name, m.URL)
	}
	if repo.Zit == nil {
		return nil, fmt.Errorf("this repository has no store to write to: %w", ops.ErrUnavailable)
	}
	return repo, nil
}

// noSuchTarget explains a target neither the store nor the forge tier
// holds.
//
// A forge-tier ticket now takes attachments (attachFamilyFor), so the
// one local case left is a ticket that has BEEN promoted: its local id
// is a redirect, and the files belong on the artifact it became. The
// bare not-found read like the ticket did not exist, which is the
// refusal that must not lie.
func (d Deps) noSuchTarget(repo, target string) error {
	if d.Local != nil {
		if to, ok := d.Local.LocalTicketRedirect(repo, strings.TrimPrefix(target, "tkt:")); ok {
			return fmt.Errorf(
				"%w: ticket %.12s was promoted to %.12s; attach to that", ops.ErrNotFound, target, to)
		}
	}
	return fmt.Errorf("no ticket, wiki page, technote or forum post %q: %w", target, ops.ErrNotFound)
}

// attachTarget resolves the target and checks the family's upload
// letter, the two gates every write here shares.
func (d Deps) attachTarget(ctx context.Context, repo *Repo, name, target string) (attachFamily, error) {
	f, ok, err := d.attachFamilyFor(repo, name, target)
	if err != nil {
		return f, err
	}
	if !ok {
		return f, d.noSuchTarget(name, target)
	}
	if !d.holds(ctx, name, f.uploadLetter()) {
		return f, fmt.Errorf("%w: attaching to a %s needs the %s capability", ops.ErrForbidden, f.Family, f.uploadLetter())
	}
	return f, nil
}

// boundBucket is the binding uploads to name use, refusing when there
// is none. The s3 handle carries the secret and never leaves this
// file's functions.
func (d Deps) boundBucket(name string) (BucketInfo, oltp.BucketBinding, s3.Bucket, error) {
	if d.Buckets == nil {
		return BucketInfo{}, oltp.BucketBinding{}, s3.Bucket{}, fmt.Errorf("this forge binds no buckets: %w", ops.ErrUnavailable)
	}
	info, b, err := d.bucketOf(name)
	if err != nil {
		return info, b, s3.Bucket{}, err
	}
	if !info.Bound {
		// The forge fallback arrives here already unbound, with its
		// reason in words; anything else is plainly unbound.
		if info.Note != "" {
			return info, b, s3.Bucket{}, fmt.Errorf("%s: %w", info.Note, ops.ErrInvalid)
		}
		return info, b, s3.Bucket{}, fmt.Errorf("%s has no bucket bound for uploads: %w", name, ops.ErrInvalid)
	}
	// A repository's files do not go to the FORGE's bucket.
	//
	// forgeAnswer now reports the fallback as unbound, so the refusal
	// above is the one a caller meets; this check stays as the
	// guarantee that the bytes never go there even if that changes.
	//
	// bucketOf falls back to the forge's binding when a repository has
	// none of its own, so uploads silently landed in the operator's
	// storage: dmestas attached files to an announcement on quarry and
	// found them in his personal R2 (9b8b8fb6). The disclosure half of
	// that — a repo admin being handed the endpoint, bucket name and
	// key id — is already fixed by forgeAnswer. This is the other
	// half, which is where the BYTES went.
	//
	// dmestas's call was to refuse and say what to do. It is worth
	// recording what that costs, because the fallback was not
	// thoughtless: a forge with no per-repository storage now cannot
	// attach anything until somebody binds a bucket, and the comment
	// in bucketOf argues that makes a self-hosted forge unusable by
	// anybody but its operator. The answer is that silently filing a
	// stranger's uploads into the operator's account is worse than a
	// refusal that names the remedy — an unusable feature is visible,
	// and a misrouted one is not.
	//
	// Marks are untouched. An avatar is forge-level furniture rather
	// than a repository's content, and forgeBucketForAvatar resolves
	// the forge binding on purpose, so a fresh forge still draws
	// pictures.
	if info.Forge {
		return info, b, s3.Bucket{}, fmt.Errorf("%s: %w", NoBucketOfItsOwn(name), ops.ErrInvalid)
	}
	return info, b, s3.Bucket{Endpoint: b.Endpoint, Name: b.Bucket, Region: b.Region, AccessKey: b.AccessKey, Secret: b.Secret}, nil
}

// NoBucketOfItsOwn is why an upload cannot be accepted at a repository
// whose binding resolved to the FORGE's.
//
// One sentence, named once, because two surfaces answer this question and
// they disagreed: bucket.limit reported the resolved binding as `bound`
// and handed out a size limit, while begin refused through boundBucket —
// so a reader was told they were ready and then failed (G-130). The read
// side now reports this same sentence instead of promising an upload that
// will not happen.
//
// bucketOf falls back to the forge's binding so a repository can inherit
// storage; the refusal is deliberate and its reasoning is in boundBucket,
// where the bytes-to-the-operator's-account incident is recorded.
func NoBucketOfItsOwn(name string) string {
	return name + " has no bucket of its own, and a repository's uploads do not go to the " +
		"forge's storage: bind a bucket under Settings > Attachments"
}

// attachClient is what the forge speaks to a bucket with: bounded, so
// a stalled provider is an error and not a held request.
func attachClient() *http.Client { return &http.Client{Timeout: probeTimeout} }

// cleanAttachName is the filename as the A card will carry it: one
// path element, so ../../etc/passwd attaches as passwd.
func cleanAttachName(name string) string {
	name = path.Base(strings.ReplaceAll(strings.TrimSpace(name), `\`, "/"))
	if name == "." || name == "/" {
		return ""
	}
	return name
}

// logModeration records an attachment decision in the same public log
// the forum's decisions go to.
//
// Until now nothing recorded them. attachment.reject clears the hold
// and removes the file, and the removal does write a sourceless A card
// carrying the actor — so the DAG knew WHO and WHEN — but there was no
// reason anywhere, and no surface: a person would have had to go
// looking in the DAG for a control artifact. Meanwhile the forum, for
// the same decision, appends a line with actor, action, target and
// reason that reads anonymously. Same product, same word, one record
// between the two (01a3b35f4055).
//
// Best effort. A decision that happened must not be undone because its
// log line could not be written, and the forum's own call sites take
// the same view — the alternative is refusing to moderate because the
// audit trail is unavailable.
func (d Deps) logModeration(repo, actor, action, name, artifact, reason, about string) {
	if d.Forum == nil {
		return
	}
	target := "attachment:" + name
	if len(artifact) >= 12 {
		target += "@" + artifact[:12]
	}
	if err := d.Forum.AppendModLog(repo, actor, action, target, reason, about); err != nil {
		log.Printf("attachments: %s: moderation log: %v", repo, err)
	}
}

// withHolds marks which of a target's attachments are held. The index
// knows nothing of holds — they are forge-local — so every list that
// leaves the ops layer passes through here.
func (d Deps) withHolds(repo string, att []index.Attachment) []index.Attachment {
	if d.Attachments == nil || len(att) == 0 {
		return att
	}
	held, err := d.Attachments.HeldArtifacts(repo)
	if err != nil {
		log.Printf("attachments: %s: reading holds: %v", repo, err)
		return att
	}
	for i := range att {
		att[i].Held = held[att[i].Hash]
	}
	return att
}

// attachmentsOn is AttachmentsFor plus the holds: the one call every
// object's op makes for its files.
func (d Deps) attachmentsOn(repo *Repo, name, target string) ([]index.Attachment, error) {
	att, err := repo.Index.AttachmentsFor(target)
	if err != nil {
		return nil, err
	}
	return d.withHolds(name, att), nil
}

type attachmentBeginIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Target names the object the file attaches to. Empty DEFERS the
	// ticket: the object does not exist yet (a thread, a reply or a
	// ticket being written), Family says which kind it will be, and
	// the op that writes the object — or `attachment.commit` — names
	// the target. Same fifteen-minute PUT, same hour on the ticket.
	Target string `json:"target" validate:"max=200"`
	Family string `json:"family" validate:"max=16"`
	Name   string `json:"name" validate:"required,max=255"`
	Size   int64  `json:"size" validate:"min=1"`
	Type   string `json:"type" validate:"required,max=128"`
	SHA256 string `json:"sha256" validate:"required,min=64,max=64"`
}

type attachmentBeginOut struct {
	Ticket string `json:"ticket"`
	// URL is the presigned PUT; Method and Headers are what the request
	// must carry exactly, since both headers are in the signature.
	URL     string            `json:"url"`
	Method  string            `json:"method"`
	Headers map[string]string `json:"headers"`
	Expires string            `json:"expires"`
}

type attachmentCommitIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Ticket string `json:"ticket" validate:"required,max=64"`
	// Target is required for a deferred ticket and ignored otherwise:
	// a ticket begun against an object commits to that object.
	Target string `json:"target" validate:"max=200"`
}

type attachmentCommitOut struct {
	Artifact string `json:"artifact"`
	Blob     string `json:"blob"`
	Name     string `json:"name"`
	Size     int64  `json:"size"`
	Type     string `json:"type"`
	SHA256   string `json:"sha256"`
	// Held says the upload waits for a person with the family's
	// moderation letter: it is in the DAG, and on quarry's pages it is
	// a link with a name until then.
	Held bool `json:"held"`
	// Local says the target is a forge-tier ticket: the file is in the
	// bucket and held against the ticket, with no artifact or blob yet.
	// Both are written when the ticket is promoted.
	Local bool `json:"local,omitempty"`
}

type attachmentRemoveIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Target string `json:"target" validate:"required,max=200"`
	Name   string `json:"name" validate:"required,max=255"`
}

type attachmentRemoveOut struct {
	Artifact string `json:"artifact"`
	Target   string `json:"target"`
	Name     string `json:"name"`
	Removed  bool   `json:"removed"`
	// Deleted says the object left the bucket too, which happens only
	// for a stub and only when the repository's setting says so.
	Deleted bool `json:"deleted"`
}

type attachmentVouchIn struct {
	Repo     string `json:"repo" validate:"required,max=64"`
	Artifact string `json:"artifact" validate:"required,min=4,max=64"`
	// Reason is the moderator's, recorded in the same public log the
	// forum's decisions go to. Optional, and the same bound the forum
	// uses, so the two surfaces cannot drift apart on what a reason is.
	Reason string `json:"reason" validate:"max=500"`
}

type attachmentVouchOut struct {
	Artifact string `json:"artifact"`
	Held     bool   `json:"held"`
}

type attachmentHeldIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

type attachmentHeldOut struct {
	Holds []oltp.Hold `json:"holds"`
}

type attachmentLinkIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Hash is the CONTENT artifact, the address every attachment route
	// takes.
	Hash string `json:"hash" validate:"required,min=4,max=64"`
}

// attachmentLinkOut is where a stub's bytes are reachable. Download is
// always answered; Inline only when a person vouched for the file and
// its type is on the inline allowlist, because that URL is the one a
// page puts in an <img> or <video>.
type attachmentLinkOut struct {
	External bool   `json:"external"`
	Name     string `json:"name"`
	Type     string `json:"type,omitempty"`
	Size     int64  `json:"size,omitempty"`
	Held     bool   `json:"held,omitempty"`
	Download string `json:"download,omitempty"`
	Inline   string `json:"inline,omitempty"`
	Expires  string `json:"expires,omitempty"`
}

type bucketLimitIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

// bucketLimitOut is the uploader's view of the binding: whether there
// is one and how big a file may be. Nothing else about the bucket
// reaches anyone but an administrator.
type bucketLimitOut struct {
	Repo  string `json:"repo"`
	Bound bool   `json:"bound"`
	Limit int64  `json:"limit,omitempty"`
	// Note says why an upload will NOT work when Bound is false and the
	// reason is knowable — the same sentence `begin` refuses with, so a
	// reader learns the remedy from the read instead of from a failure.
	Note string `json:"note,omitempty"`
}

type bucketPolicyIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// POINTERS, so absent is not the same as false.
	//
	// These were plain bools, and SetBucketPolicy was handed them
	// directly: a caller who sent {"archives": true} and omitted the
	// other switch turned delete-on-removal OFF without asking, and an
	// empty body turned both off and answered 200. That is dc748de1's
	// shape — a partial write clearing what it was not told about —
	// which repo.update already fixed this way and this op did not.
	//
	// Found by probing whether a bucket can be bound through the API
	// at all: the probe was an empty POST here, and it succeeded.
	Archives      *bool `json:"archives"`
	DeleteObjects *bool `json:"deleteObjects"`
}

// mayRemove refuses a removal that is neither the uploader's own nor a
// moderator's.
//
// THE HOLE IT CLOSES (G-147, found by jva's falsifier the hour the
// attachment surface became testable from a non-admin vantage): the
// attach letters b and B are in the READER preset, because attaching
// is something a reader does — fossil's model, deliberately. This op
// took those same letters and nothing else, so every reader on the
// forge could permanently remove ANY attachment, including an
// administrator's vouched one. Its sibling `reject` was gated on the
// moderation letters all along, so a reader was refused the moderation
// op and handed its destructive half.
//
// The letters alone cannot decide this, which is why the check is here
// rather than in Caps: removing your OWN upload is part of attaching,
// and removing SOMEBODY ELSE'S is moderation. The distinction is the
// A card's author, so the row has to be read before the answer exists.
//
// An anonymous caller matches nobody: an empty author beside an empty
// row user is two absences, not one person.
func (d Deps) mayRemove(ctx context.Context, repo *Repo, name string, f attachFamily, filename string) error {
	if d.holds(ctx, name, f.vouchLetter()+"a") {
		return nil
	}
	if f.Local {
		// The forge tier answers who uploaded it; the ownership rule is
		// the same one.
		held, err := d.localAttachmentRows(name, f.Target)
		if err != nil {
			return err
		}
		for _, row := range held {
			if row.Name == filename {
				return d.judgeRemoval(f, filename, row.User, author(ctx))
			}
		}
		return nil
	}
	live, err := repo.Index.AttachmentsFor(f.Target)
	if err != nil {
		return err
	}
	who := author(ctx)
	for _, row := range live {
		if row.Name != filename {
			continue
		}
		return d.judgeRemoval(f, filename, row.User, who)
	}
	// Not in the index. That is usually "no such attachment", and
	// remove() says so. But it is ALSO what a file looks like between
	// reaching the store and being folded, and in that window this
	// gate used to fall through deciding nothing at all — the caller
	// was neither allowed nor refused, they were simply unexamined.
	// Ask the store before concluding anything.
	if uploader, ok := d.storeUploader(ctx, repo, f.Target, filename); ok {
		return d.judgeRemoval(f, filename, uploader, who)
	}
	return nil
}

// judgeRemoval is the ownership question itself, asked the same way
// whichever source answered who uploaded the file.
func (d Deps) judgeRemoval(f attachFamily, filename, uploader, who string) error {
	if who != "" && uploader == who {
		return nil
	}
	return fmt.Errorf("%w: %s was attached by %s; removing an attachment that is not yours needs the %s capability",
		ops.ErrForbidden, filename, rowUser(uploader), f.vouchLetter())
}

// storeUploader asks the store who attached filename to target, for
// the window where the store is ahead of the index.
//
// A store that cannot answer leaves the caller where they were: this
// only ever ADDS a refusal the index could not reach, and never turns
// one into a pass.
func (d Deps) storeUploader(ctx context.Context, repo *Repo, target, filename string) (string, bool) {
	if repo.Zit == nil {
		return "", false
	}
	rows, err := repo.Zit.AttachList(ctx, target)
	if err != nil {
		return "", false
	}
	for _, row := range rows {
		if row.Name == filename {
			return row.User, true
		}
	}
	return "", false
}

// rowUser names an attachment's author for a refusal, without turning
// an anonymous upload into a person called "".
func rowUser(user string) string {
	if user == "" {
		return "an anonymous visitor"
	}
	return user
}

// remove is the removal itself, shared by attachment.remove and
// attachment.reject: the sourceless A card, the hold released, and the
// object deleted when the repository's switch says so.
func (d Deps) remove(ctx context.Context, repo *Repo, name string, f attachFamily, filename string) (attachmentRemoveOut, error) {
	if f.Local {
		return d.removeLocal(ctx, repo, name, f, filename)
	}
	live, err := repo.Index.AttachmentsFor(f.Target)
	if err != nil {
		return attachmentRemoveOut{}, err
	}
	var row *index.Attachment
	for i := range live {
		if live[i].Name == filename {
			row = &live[i]
			break
		}
	}
	if row == nil {
		// The store may be holding it while the index catches up, and
		// "nothing named X is attached" is a lie in that window: the
		// file is there, it is a second away, and a retry works. Say
		// which of the two it is (jva, measured on a fresh upload).
		if _, ok := d.storeUploader(ctx, repo, f.Target, filename); ok {
			return attachmentRemoveOut{}, fmt.Errorf(
				"%s is attached to %s but not indexed yet; try again in a moment: %w",
				filename, f.Target, ops.ErrUnavailable)
		}
		return attachmentRemoveOut{}, fmt.Errorf("nothing named %q is attached to %s: %w", filename, f.Target, ops.ErrNotFound)
	}
	artifact, err := repo.Zit.AttachRm(ctx, f.Target, filename, author(ctx))
	if err != nil {
		if errors.Is(err, zitcli.ErrNoEntry) {
			return attachmentRemoveOut{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
		}
		return attachmentRemoveOut{}, err
	}
	out := attachmentRemoveOut{Artifact: artifact, Target: f.Target, Name: filename, Removed: true}
	if d.Attachments != nil {
		if err := d.Attachments.ReleaseHold(name, row.Hash); err != nil {
			log.Printf("attachments: %s: releasing hold on %s: %v", name, row.Hash, err)
		}
	}
	if row.External && row.SHA256 != "" {
		// Deletion is the steward's decision (RFC 0016 §Writing); the
		// switch on the binding is where they made it. Another live
		// stub naming the same bytes keeps the object: the key is the
		// content, not the attachment.
		if _, b, bucket, err := d.boundBucket(name); err == nil && b.DeleteObjects && !d.otherStubNames(repo, row.SHA256) {
			if err := bucket.Delete(ctx, attachClient(), attach.Key(row.SHA256), time.Now()); err != nil {
				log.Printf("attachments: %s: deleting %s: %v", name, attach.Key(row.SHA256), err)
			} else {
				out.Deleted = true
			}
		}
	}
	return out, nil
}

// removeLocal is remove for a forge-tier ticket: the held row goes, and
// nothing in the log moves because nothing in the log was written. The
// object follows the same rule as for a stub — the repository's
// deleteObjects switch, and only when no other attachment, held or
// live, names the same bytes.
func (d Deps) removeLocal(ctx context.Context, repo *Repo, name string, f attachFamily, filename string) (attachmentRemoveOut, error) {
	held, err := d.localAttachmentRows(name, f.Target)
	if err != nil {
		return attachmentRemoveOut{}, err
	}
	var row *index.Attachment
	for i := range held {
		if held[i].Name == filename {
			row = &held[i]
			break
		}
	}
	if row == nil {
		return attachmentRemoveOut{}, fmt.Errorf("nothing named %q is attached to %s: %w", filename, f.Target, ops.ErrNotFound)
	}
	if err := d.Local.LocalAttachmentRemove(name, f.Target, filename); err != nil {
		return attachmentRemoveOut{}, err
	}
	out := attachmentRemoveOut{Target: f.Target, Name: filename, Removed: true}
	if _, b, bucket, err := d.boundBucket(name); err == nil && b.DeleteObjects && row.SHA256 != "" {
		n, err := d.Local.LocalSHACount(name, row.SHA256)
		stubs, serr := repo.Index.LiveStubCount(row.SHA256)
		if err == nil && serr == nil && n == 0 && stubs == 0 {
			if err := bucket.Delete(ctx, attachClient(), attach.Key(row.SHA256), time.Now()); err != nil {
				log.Printf("attachments: %s: deleting %s: %v", name, attach.Key(row.SHA256), err)
			} else {
				out.Deleted = true
			}
		}
	}
	return out, nil
}

// otherStubNames reports whether a live stub other than the one being
// removed still names sha. Searched through the live rows of every
// target is not possible cheaply, so this asks the row store the one
// question it can answer: is any live attachment's src a stub with
// this hash. The removal just written is not yet folded, so it still
// counts as one; two or more means another does.
func (d Deps) otherStubNames(repo *Repo, sha string) bool {
	n, err := repo.Index.LiveStubCount(sha)
	return err == nil && n > 1
}

// commitUpload is attachment.commit's body, shared with the ops that
// write a new object and then attach what was pasted while it was
// being written: the ticket is checked against its author and its
// repository, the target resolved (the ticket's own, or the one a
// deferred ticket is finally given), the object HEADed, the stub and
// the A card written, and the held rule applied.
func (d Deps) commitUpload(ctx context.Context, repo *Repo, name, ticket, target string) (attachmentCommitOut, error) {
	t, ok, err := d.Attachments.UploadTicket(ticket)
	if err != nil {
		return attachmentCommitOut{}, err
	}
	if !ok || t.Repo != name {
		return attachmentCommitOut{}, fmt.Errorf("no such upload ticket; begin again: %w", ops.ErrNotFound)
	}
	if t.Who != author(ctx) {
		return attachmentCommitOut{}, fmt.Errorf("%w: this ticket was begun by someone else", ops.ErrForbidden)
	}
	f, err := d.ticketTarget(ctx, repo, name, t, target)
	if err != nil {
		return attachmentCommitOut{}, err
	}
	_, _, bucket, err := d.boundBucket(name)
	if err != nil {
		return attachmentCommitOut{}, err
	}
	size, present, err := bucket.Head(ctx, attachClient(), attach.Key(t.SHA256), time.Now())
	if err != nil {
		return attachmentCommitOut{}, fmt.Errorf("the bucket could not be asked about the object (%v): %w", err, ops.ErrUnavailable)
	}
	if !present {
		return attachmentCommitOut{}, fmt.Errorf("the object is not in the bucket; the upload did not finish: %w", ops.ErrInvalid)
	}
	if size != t.Size {
		return attachmentCommitOut{}, fmt.Errorf("the object is %d bytes and the upload was declared as %d: %w", size, t.Size, ops.ErrInvalid)
	}
	if f.Local {
		// A forge-tier ticket: the object is checked exactly as above,
		// and the stub is held in the forge database instead of being
		// written to the log — there is no A card to write until the
		// ticket is promoted, and promotion writes them all. The held
		// rule is applied then too, against the uploader's letters,
		// because that is when the file first appears in the DAG.
		if err := d.Local.LocalAttachmentAdd(oltp.LocalAttachment{
			Repo: name, Ticket: f.Target, Name: t.Name, Size: t.Size, Type: t.Type, SHA256: t.SHA256, Who: author(ctx),
		}); err != nil {
			return attachmentCommitOut{}, err
		}
		if err := d.Attachments.DeleteUploadTicket(ticket); err != nil {
			log.Printf("attachments: %s: spending ticket %s: %v", name, ticket, err)
		}
		return attachmentCommitOut{Name: t.Name, Size: t.Size, Type: t.Type, SHA256: t.SHA256, Local: true}, nil
	}
	if repo.Zit == nil {
		return attachmentCommitOut{}, fmt.Errorf("this repository has no store to write to: %w", ops.ErrUnavailable)
	}
	stub := attach.Stub{SHA256: t.SHA256, Size: t.Size, Type: t.Type, Name: t.Name}
	artifact, blob, err := repo.Zit.AttachAdd(ctx, f.Target, t.Name, t.Type, author(ctx), "", stub.Format())
	if err != nil {
		if errors.Is(err, zitcli.ErrNoEntry) {
			return attachmentCommitOut{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
		}
		return attachmentCommitOut{}, err
	}
	out := attachmentCommitOut{Artifact: artifact, Blob: blob, Name: t.Name, Size: t.Size, Type: t.Type, SHA256: t.SHA256}
	// The held rule (RFC 0016 §Trust): trusted for the family
	// means the moderation letter or the steward's own. A hold
	// that cannot be recorded is logged and the file shows —
	// the A card is already written and cannot be unwritten,
	// and a hidden hold row would be a hold nobody can release.
	if !d.holds(ctx, name, f.vouchLetter()+"a") {
		if err := d.Attachments.HoldAttachment(oltp.Hold{Repo: name, Artifact: artifact, Target: f.Target, Name: t.Name, Who: author(ctx)}); err != nil {
			log.Printf("attachments: %s: recording hold on %s: %v", name, artifact, err)
		} else {
			out.Held = true
		}
	}
	if err := d.Attachments.DeleteUploadTicket(ticket); err != nil {
		log.Printf("attachments: %s: spending ticket %s: %v", name, ticket, err)
	}
	return out, nil
}

// ticketTarget resolves where a ticket's file attaches. A ticket begun
// against an object attaches there whatever the caller says now; a
// deferred one needs a target, and the target's family must be the
// one the ticket's letter was checked against — a ticket begun for a
// forum post with B must not land on a wiki page with no b.
func (d Deps) ticketTarget(ctx context.Context, repo *Repo, name string, t oltp.UploadTicket, target string) (attachFamily, error) {
	if t.Target != "" {
		return d.attachTarget(ctx, repo, name, t.Target)
	}
	if target == "" {
		return attachFamily{}, fmt.Errorf("this upload was begun without a target; name one to commit it: %w", ops.ErrInvalid)
	}
	f, err := d.attachTarget(ctx, repo, name, target)
	if err != nil {
		return f, err
	}
	if f.Family != t.Family {
		return f, fmt.Errorf("this upload was begun for a %s and %s is a %s: %w", t.Family, target, f.Family, ops.ErrInvalid)
	}
	return f, nil
}

// deferredFamily is the family a ticket without a target may name: the
// three kinds a composer writes. A technote is edited on its own page,
// where the section's form and paste apply.
func deferredFamily(family string) (attachFamily, error) {
	switch family {
	case "ticket", "forum", "wiki":
		return attachFamily{Family: family}, nil
	}
	return attachFamily{}, fmt.Errorf("an upload without a target needs a family of ticket, forum or wiki, not %q: %w", family, ops.ErrInvalid)
}

// maxComposerAttachments bounds the `attach` list a composer op takes:
// twenty files on one post is a gallery, and past it the list is a
// mistake. Checked in code as well as by the router's rules, since
// the web layer invokes the ops directly.
const maxComposerAttachments = 20

func checkAttachList(tickets []string) error {
	if len(tickets) > maxComposerAttachments {
		return fmt.Errorf("at most %d files can be attached while composing: %w", maxComposerAttachments, ops.ErrInvalid)
	}
	return nil
}

// stagedAttachNote is what a composer op answers for its tickets when
// the post itself was held for moderation: there is no object to attach
// to, and the tickets keep for their hour, so the files are uploaded
// again with the post once it is released.
func stagedAttachNote(tickets []string) []string {
	if len(tickets) == 0 {
		return nil
	}
	return []string{fmt.Sprintf("the post is held for moderation; %d upload(s) were not attached", len(tickets))}
}

// attachTickets commits the tickets a composer collected against the
// object it just wrote. The object is already in the DAG, so nothing
// here can roll it back: each refusal is reported in words and the
// rest go on. nil when every ticket landed.
func (d Deps) attachTickets(ctx context.Context, repo *Repo, name, target string, tickets []string) []string {
	if len(tickets) == 0 {
		return nil
	}
	if d.Attachments == nil {
		return []string{"this forge takes no uploads; nothing was attached"}
	}
	var errs []string
	for _, tk := range tickets {
		if _, err := d.commitUpload(ctx, repo, name, tk, target); err != nil {
			errs = append(errs, tk+": "+err.Error())
		}
	}
	return errs
}

// registerAttachWrite mounts the Phase 3 ops. Registered whether or not
// a store is wired, like the bucket ops, so the documented surface is
// the same on every forge and an unwired one answers unavailable.
func registerAttachWrite(r *ops.Registry, d Deps) error {
	unwired := func() error {
		if d.Attachments == nil {
			return fmt.Errorf("this forge takes no uploads: %w", ops.ErrUnavailable)
		}
		return nil
	}
	if err := ops.Register(r, ops.Op[attachmentBeginIn, attachmentBeginOut]{
		Name: "attachment.begin",
		// Either upload letter reaches the surface; which one the
		// target needs is decided inside, once the target's family is
		// known (b for tickets, wiki pages and technotes, B for posts).
		Caps: "bB",
		Doc:  "Begin an upload (RFC 0016 Phase 3): name the target, the file's name, size, type and SHA-256, and receive a presigned PUT — valid fifteen minutes, its content length and type signed — and a ticket for `attachment.commit`. Refused when the target is unknown or read-only, no bucket is bound, the size is over the binding's limit, or the type is not on the policy (text, the common images, PDF, JSON, the common audio and video; archives only when the repository allows them; executables never). An empty `target` with a `family` (ticket, forum or wiki) DEFERS the ticket for an object that does not exist yet or is about to change: the same gates apply against the family's letter, and the ticket is named its target by the `attach` list of `forum.post`, `forum.reply`, `ticket.new`, `ticket.set` and `wiki.save`, or by `attachment.commit`.",
		Kind: ops.Command,
		Auth: ops.Token,
		// Under attachment/, singular, like attachment.head: the plural
		// prefix is the multipart upload receiver's, mounted on the
		// outer mux with a trailing wildcard, and it swallowed these
		// two routes before the API router ever saw them.
		Route: "POST /api/v1/r/{repo}/attachment/begin",
		Run: func(ctx context.Context, in attachmentBeginIn) (attachmentBeginOut, error) {
			if err := unwired(); err != nil {
				return attachmentBeginOut{}, err
			}
			repo, err := d.attachWriteRepo(ctx, in.Repo)
			if err != nil {
				return attachmentBeginOut{}, err
			}
			var f attachFamily
			if in.Target == "" {
				// Deferred: gated on the family's letter now, on the
				// target's existence and family at commit.
				f, err = deferredFamily(in.Family)
				if err != nil {
					return attachmentBeginOut{}, err
				}
				if !d.holds(ctx, in.Repo, f.uploadLetter()) {
					return attachmentBeginOut{}, fmt.Errorf("%w: attaching to a %s needs the %s capability", ops.ErrForbidden, f.Family, f.uploadLetter())
				}
			} else if f, err = d.attachTarget(ctx, repo, in.Repo, in.Target); err != nil {
				return attachmentBeginOut{}, err
			}
			name := cleanAttachName(in.Name)
			if name == "" {
				return attachmentBeginOut{}, fmt.Errorf("the attachment needs a filename: %w", ops.ErrInvalid)
			}
			sha := strings.ToLower(in.SHA256)
			if strings.Trim(sha, "0123456789abcdef") != "" {
				return attachmentBeginOut{}, fmt.Errorf("sha256 must be 64 hex digits: %w", ops.ErrInvalid)
			}
			_, b, bucket, err := d.boundBucket(in.Repo)
			if err != nil {
				return attachmentBeginOut{}, err
			}
			if in.Size > b.LimitBytes {
				return attachmentBeginOut{}, fmt.Errorf("%d bytes is over this repository's limit of %d: %w", in.Size, b.LimitBytes, ops.ErrInvalid)
			}
			ctype, _, err := mime.ParseMediaType(in.Type)
			if err != nil || !attach.Allowed(ctype, b.Archives) {
				return attachmentBeginOut{}, fmt.Errorf("files of type %q cannot be attached here: %w", in.Type, ops.ErrInvalid)
			}
			now := time.Now()
			put, err := bucket.Presign(http.MethodPut, attach.Key(sha), uploadExpiry, now, map[string]string{
				"content-length": fmt.Sprint(in.Size),
				"content-type":   ctype,
			})
			if err != nil {
				return attachmentBeginOut{}, err
			}
			ticket, err := d.Attachments.PutUploadTicket(oltp.UploadTicket{
				Repo: in.Repo, Target: f.Target, Family: f.Family, Name: name, Size: in.Size, Type: ctype, SHA256: sha, Who: author(ctx)})
			if err != nil {
				return attachmentBeginOut{}, err
			}
			return attachmentBeginOut{
				Ticket: ticket, URL: put.String(), Method: http.MethodPut,
				Headers: map[string]string{"Content-Length": fmt.Sprint(in.Size), "Content-Type": ctype},
				Expires: now.Add(uploadExpiry).UTC().Format(time.RFC3339),
			}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[attachmentCommitIn, attachmentCommitOut]{
		Name:  "attachment.commit",
		Caps:  "bB",
		Doc:   "Finish an upload: quarry HEADs the object and refuses if it is absent or its length differs from what `attachment.begin` was told, then writes the stub and the A card through zit and answers both hashes. Against a ticket on the forge tier the file is HELD instead (`local` is true, no artifact or blob yet) and its A card is written when the ticket is promoted. A deferred ticket (begun with no target) needs `target` here, checked against the family it was begun for. `held` is true when the caller lacks the family's moderation letter (q on tickets, l on the wiki and notes, 5 on the forum) and is not an administrator: the file is in the DAG and shown as a link until a moderator vouches for it.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/attachment/commit",
		Run: func(ctx context.Context, in attachmentCommitIn) (attachmentCommitOut, error) {
			if err := unwired(); err != nil {
				return attachmentCommitOut{}, err
			}
			repo, err := d.attachWriteRepo(ctx, in.Repo)
			if err != nil {
				return attachmentCommitOut{}, err
			}
			return d.commitUpload(ctx, repo, in.Repo, in.Ticket, in.Target)
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[attachmentRemoveIn, attachmentRemoveOut]{
		Name:  "attachment.remove",
		Caps:  "bB",
		Doc:   "Remove an attachment: an A card with no source, fossil's own deletion form. The attach letters reach this op because removing your OWN upload is part of attaching; removing anybody else's needs the family's moderation letter (q on tickets, l on the wiki and notes, 5 on the forum) or the administrator's. Nothing leaves the DAG. When the repository's setting `deleteObjects` is on and the attachment was an upload, the object is deleted from the bucket too, unless another live attachment names the same bytes.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/attachments/remove",
		Run: func(ctx context.Context, in attachmentRemoveIn) (attachmentRemoveOut, error) {
			repo, err := d.attachWriteRepo(ctx, in.Repo)
			if err != nil {
				return attachmentRemoveOut{}, err
			}
			f, err := d.attachTarget(ctx, repo, in.Repo, in.Target)
			if err != nil {
				return attachmentRemoveOut{}, err
			}
			if err := d.mayRemove(ctx, repo, in.Repo, f, in.Name); err != nil {
				return attachmentRemoveOut{}, err
			}
			return d.remove(ctx, repo, in.Repo, f, in.Name)
		},
	}); err != nil {
		return err
	}
	resolveHeld := func(ctx context.Context, name, artifact string) (*Repo, index.Attachment, attachFamily, error) {
		if err := unwired(); err != nil {
			return nil, index.Attachment{}, attachFamily{}, err
		}
		repo, err := d.attachWriteRepo(ctx, name)
		if err != nil {
			return nil, index.Attachment{}, attachFamily{}, err
		}
		row, ok, err := repo.Index.AttachmentByHash(artifact)
		if err != nil {
			return nil, index.Attachment{}, attachFamily{}, err
		}
		if !ok {
			return nil, index.Attachment{}, attachFamily{}, fmt.Errorf("no attachment artifact %q: %w", artifact, ops.ErrNotFound)
		}
		f, ok, err := attachFamilyOf(repo.Index, row.Target)
		if err != nil {
			return nil, index.Attachment{}, attachFamily{}, err
		}
		if !ok {
			return nil, index.Attachment{}, attachFamily{}, fmt.Errorf("the attachment's target %q is unknown: %w", row.Target, ops.ErrNotFound)
		}
		if !d.holds(ctx, name, f.vouchLetter()+"a") {
			return nil, index.Attachment{}, attachFamily{}, fmt.Errorf("%w: reviewing a %s attachment needs the %s capability", ops.ErrForbidden, f.Family, f.vouchLetter())
		}
		return repo, row, f, nil
	}
	if err := ops.Register(r, ops.Op[attachmentVouchIn, attachmentVouchOut]{
		Name:  "attachment.vouch",
		Caps:  "ql5a",
		Doc:   "Vouch for a held upload: it renders from now on. Needs the family's moderation letter (q, l or 5) or the administrator's.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/attachments/vouch",
		Run: func(ctx context.Context, in attachmentVouchIn) (attachmentVouchOut, error) {
			_, row, _, err := resolveHeld(ctx, in.Repo, in.Artifact)
			if err != nil {
				return attachmentVouchOut{}, err
			}
			if err := d.Attachments.ReleaseHold(in.Repo, row.Hash); err != nil {
				return attachmentVouchOut{}, err
			}
			d.logModeration(in.Repo, author(ctx), "attach-vouch", row.Name, row.Hash, in.Reason, row.User)
			// And tell the uploader, for the same reason an approved
			// forum post tells its author: they should not have to go
			// back and look to find out.
			//
			// This was missed when rejection was wired, and found by
			// counting the write paths that resolve a held thing rather
			// than the ones I remembered — three of them, and only two
			// told anybody. A forum post being approved told its author
			// while an attachment being approved told nobody, which is
			// the same asymmetry jva caught pointing the other way.
			d.notifyModerated(in.Repo, row.User, author(ctx), "attachment", "approved",
				row.Name, in.Reason, d.link("/r/"+in.Repo+"/attachment/"+row.Src+"/get"),
				"attachment:"+row.Hash)
			return attachmentVouchOut{Artifact: row.Hash}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[attachmentVouchIn, attachmentRemoveOut]{
		Name:  "attachment.reject",
		Caps:  "ql5a",
		Doc:   "Reject a held upload: the hold is cleared and the attachment removed as `attachment.remove` would, object deletion included when the repository says so.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/attachments/reject",
		Run: func(ctx context.Context, in attachmentVouchIn) (attachmentRemoveOut, error) {
			repo, row, f, err := resolveHeld(ctx, in.Repo, in.Artifact)
			if err != nil {
				return attachmentRemoveOut{}, err
			}
			if err := d.Attachments.ReleaseHold(in.Repo, row.Hash); err != nil {
				return attachmentRemoveOut{}, err
			}
			// Recorded BEFORE the removal, because the removal is what
			// makes the file unfindable: a log line written afterwards
			// would be the only trace, and if the remove failed halfway
			// the log would claim something that did not happen. This
			// order can leave a line for a rejection that then errors,
			// which is the harmless direction — the hold is already
			// released either way, and a moderator can see what they
			// attempted.
			d.logModeration(in.Repo, author(ctx), "attach-reject", row.Name, row.Hash, in.Reason, row.User)
			// The uploader is the one person this concerns and, until
			// now, the only one never told: the file is about to be
			// removed, so there is nothing left for them to find and no
			// state change for them to notice. row.User is who attached
			// it, carried on the A card (b32aeba12bc6).
			d.notifyModerated(in.Repo, row.User, author(ctx), "attachment", "rejected",
				row.Name, in.Reason, "", "attachment:"+row.Hash)
			return d.remove(ctx, repo, in.Repo, f, row.Name)
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[attachmentHeldIn, attachmentHeldOut]{
		Name:  "attachment.held",
		Caps:  "ql5a",
		Doc:   "The review queue: uploads on this repository nobody trusted has vouched for, oldest first.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/attachments/held",
		Run: func(ctx context.Context, in attachmentHeldIn) (attachmentHeldOut, error) {
			if err := unwired(); err != nil {
				return attachmentHeldOut{}, err
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return attachmentHeldOut{}, err
			}
			holds, err := d.Attachments.Holds(in.Repo)
			if err != nil {
				return attachmentHeldOut{}, err
			}
			if holds == nil {
				holds = []oltp.Hold{}
			}
			return attachmentHeldOut{Holds: holds}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[attachmentLinkIn, attachmentLinkOut]{
		Name:  "attachment.link",
		Caps:  "jr",
		Doc:   "Where an uploaded attachment's bytes are: a link on the bucket's public base when the repository is public and one is set, otherwise a presigned GET good for an hour that names the file and its type. `inline` is answered only for a vouched file whose type may render in a page; SVG never. A real (non-upload) attachment answers `external: false` and is served by quarry's own routes.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/attachment/{hash}/link",
		Run: func(ctx context.Context, in attachmentLinkIn) (attachmentLinkOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return attachmentLinkOut{}, err
			}
			row, ok, err := repo.Index.AttachmentBySrc(in.Hash)
			if err != nil {
				return attachmentLinkOut{}, err
			}
			if !ok {
				// A file held against a forge-tier ticket: in the
				// bucket like any upload, addressed by its object key.
				row, ok = d.localAttachmentBySrc(in.Repo, in.Hash)
			}
			if !ok {
				return attachmentLinkOut{}, fmt.Errorf("no attachment %q: %w", in.Hash, ops.ErrNotFound)
			}
			out := attachmentLinkOut{External: row.External, Name: row.Name}
			if !row.External {
				return out, nil
			}
			out.Type, out.Size = row.Mimetype, row.Size
			out.Held = d.withHolds(in.Repo, []index.Attachment{row})[0].Held
			info, _, bucket, err := d.boundBucket(in.Repo)
			if err != nil {
				return attachmentLinkOut{}, fmt.Errorf("%s has no bucket bound, so this file cannot be reached: %w", in.Repo, ops.ErrNotFound)
			}
			inline := !out.Held && attach.Inline(row.Mimetype)
			private := d.Private != nil && d.Private()[in.Repo]
			if info.PublicBase != "" && !private {
				out.Download = info.PublicBase + "/" + attach.Key(row.SHA256)
				if inline {
					out.Inline = out.Download
				}
				return out, nil
			}
			now := time.Now()
			dl, err := bucket.PresignGet(attach.Key(row.SHA256), linkExpiry, now, row.Name, row.Mimetype, false)
			if err != nil {
				return attachmentLinkOut{}, err
			}
			out.Download = dl.String()
			out.Expires = now.Add(linkExpiry).UTC().Format(time.RFC3339)
			if inline {
				il, err := bucket.PresignGet(attach.Key(row.SHA256), linkExpiry, now, row.Name, row.Mimetype, true)
				if err != nil {
					return attachmentLinkOut{}, err
				}
				out.Inline = il.String()
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[bucketLimitIn, bucketLimitOut]{
		Name:  "bucket.limit",
		Caps:  "bB",
		Doc:   "Whether this repository takes uploads and how large one may be — the uploader's question, answered to holders of the upload letters without the binding's detail.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/bucket/limit",
		Run: func(ctx context.Context, in bucketLimitIn) (bucketLimitOut, error) {
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return bucketLimitOut{}, err
			}
			if d.Buckets == nil {
				return bucketLimitOut{Repo: in.Repo}, nil
			}
			info, b, err := d.bucketOf(in.Repo)
			if err != nil {
				return bucketLimitOut{}, err
			}
			// Bound is the uploader's answer straight from bucketOf: a
			// repository inheriting the forge's binding is reported
			// unbound there, with the reason, so this op no longer
			// re-derives "bound and not forge" — the reading bucket.get
			// used to leave to each caller (G-130).
			out := bucketLimitOut{Repo: in.Repo, Bound: info.Bound, Note: info.Note}
			if out.Bound {
				out.Limit = b.LimitBytes
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[bucketPolicyIn, BucketInfo]{
		Name:  "bucket.policy",
		Caps:  "a",
		Doc:   "The binding's two switches: `archives` opens the upload type policy to zip, gzip and tar; `deleteObjects` makes a removal delete the object from the bucket as well. Both are off by default.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/bucket/policy",
		Run: func(ctx context.Context, in bucketPolicyIn) (BucketInfo, error) {
			if err := unwired(); err != nil {
				return BucketInfo{}, err
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return BucketInfo{}, err
			}
			if parent := d.companionParent(in.Repo); parent != "" {
				return BucketInfo{}, fmt.Errorf("%s uses %s's bucket; set its policy there: %w", in.Repo, parent, ops.ErrInvalid)
			}
			// Read, then apply only what was named, the way
			// repo.update does. Omitted means unchanged.
			cur, _, cerr := d.bucketOf(in.Repo)
			if cerr != nil {
				return BucketInfo{}, cerr
			}
			archives, deleteObjects := cur.Archives, cur.DeleteObjects
			if in.Archives != nil {
				archives = *in.Archives
			}
			if in.DeleteObjects != nil {
				deleteObjects = *in.DeleteObjects
			}
			if err := d.Attachments.SetBucketPolicy(in.Repo, archives, deleteObjects); err != nil {
				return BucketInfo{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
			}
			what := "attachments policy: archives " + onOff(archives) + ", delete objects on removal " + onOff(deleteObjects)
			d.bucketSettingsEvent(ctx, in.Repo, what)
			info, _, err := d.bucketOf(in.Repo)
			return info, err
		},
	})
}

// uploadStatus is the router's own mapping of an op error to a status,
// for the one handler here that is not an op.
func uploadStatus(err error) int {
	switch {
	case errors.Is(err, ops.ErrNotFound):
		return http.StatusNotFound
	case errors.Is(err, ops.ErrInvalid):
		return http.StatusBadRequest
	case errors.Is(err, ops.ErrForbidden):
		return http.StatusForbidden
	case errors.Is(err, ops.ErrUnavailable):
		return http.StatusServiceUnavailable
	}
	return http.StatusInternalServerError
}

func onOff(b bool) string {
	if b {
		return "on"
	}
	return "off"
}

// UploadForm is the no-script path (RFC 0016 §Writing): a multipart
// form with `file` (and an optional `name`) that quarry carries to the
// bucket on the reader's behalf — begin, the PUT, commit — through the
// same ops the browser would call, so every gate is the same gate.
//
// The file is held in memory while it is counted and hashed, never on
// disk, and bounded twice: the binding's limit, refused as soon as one
// byte past it arrives, and maxFormUploadBytes, past which the scripted
// path is the way. ctx must carry the caller the way a gated request's
// does; the ops refuse on the letters as they would for any client.
func UploadForm(ctx context.Context, d Deps, r *ops.Registry, repo, target string, req *http.Request) (attachmentCommitOut, error) {
	info, b, err := d.bucketOf(repo)
	if d.Buckets == nil || err != nil || !info.Bound {
		if err == nil && info.Note != "" {
			return attachmentCommitOut{}, fmt.Errorf("%s: %w", info.Note, ops.ErrInvalid)
		}
		return attachmentCommitOut{}, fmt.Errorf("%s has no bucket bound for uploads: %w", repo, ops.ErrInvalid)
	}
	limit := min(b.LimitBytes, maxFormUploadBytes)
	mr, err := req.MultipartReader()
	if err != nil {
		return attachmentCommitOut{}, fmt.Errorf("expected a multipart form with a file: %w", ops.ErrInvalid)
	}
	var body bytes.Buffer
	var name, ctype string
	sum := sha256.New()
	got := false
	for {
		part, err := mr.NextPart()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return attachmentCommitOut{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
		}
		switch part.FormName() {
		case "name":
			text, _ := io.ReadAll(io.LimitReader(part, 512))
			if n := cleanAttachName(string(text)); n != "" {
				name = n
			}
		case "file":
			if name == "" {
				name = cleanAttachName(part.FileName())
			}
			ctype = part.Header.Get("Content-Type")
			n, err := io.Copy(io.MultiWriter(&body, sum), io.LimitReader(part, limit+1))
			if err != nil {
				return attachmentCommitOut{}, err
			}
			if n > limit {
				if limit < b.LimitBytes {
					return attachmentCommitOut{}, fmt.Errorf("files over %d bytes need a browser with script, which sends them to the bucket directly: %w", limit, ops.ErrInvalid)
				}
				return attachmentCommitOut{}, fmt.Errorf("the file is over this repository's limit of %d bytes: %w", b.LimitBytes, ops.ErrInvalid)
			}
			got = true
		}
		part.Close()
	}
	if !got || name == "" {
		return attachmentCommitOut{}, fmt.Errorf("choose a file: %w", ops.ErrInvalid)
	}
	if body.Len() == 0 {
		return attachmentCommitOut{}, fmt.Errorf("the file is empty: %w", ops.ErrInvalid)
	}
	if ctype == "" || ctype == "application/octet-stream" {
		// Browsers name a type from the extension and say octet-stream
		// when they have none; the policy then refuses it, which is the
		// right answer for a file nobody could name.
		if guess := mime.TypeByExtension(strings.ToLower(path.Ext(name))); guess != "" {
			ctype = guess
		}
	}
	begin := func() (attachmentBeginOut, error) {
		out, err := r.Invoke(ctx, "attachment.begin", attachmentBeginIn{
			Repo: repo, Target: target, Name: name, Size: int64(body.Len()), Type: ctype,
			SHA256: hex.EncodeToString(sum.Sum(nil))})
		if err != nil {
			return attachmentBeginOut{}, err
		}
		return out.(attachmentBeginOut), nil
	}
	send := func() (attachmentCommitOut, error) {
		bo, err := begin()
		if err != nil {
			return attachmentCommitOut{}, err
		}
		put, err := http.NewRequestWithContext(ctx, bo.Method, bo.URL, bytes.NewReader(body.Bytes()))
		if err != nil {
			return attachmentCommitOut{}, err
		}
		put.ContentLength = int64(body.Len())
		put.Header.Set("Content-Type", bo.Headers["Content-Type"])
		resp, err := attachClient().Do(put)
		if err != nil {
			return attachmentCommitOut{}, fmt.Errorf("the bucket refused the upload: %w", ops.ErrUnavailable)
		}
		io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
		resp.Body.Close()
		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
			return attachmentCommitOut{}, fmt.Errorf("the bucket answered %d to the upload: %w", resp.StatusCode, ops.ErrUnavailable)
		}
		out, err := r.Invoke(ctx, "attachment.commit", attachmentCommitIn{Repo: repo, Ticket: bo.Ticket})
		if err != nil {
			return attachmentCommitOut{}, err
		}
		return out.(attachmentCommitOut), nil
	}
	if d.Bulk == nil {
		return send()
	}
	var out attachmentCommitOut
	err = d.Bulk.DoNamed(ctx, "attachment", repo, func() error {
		var err error
		out, err = send()
		return err
	})
	return out, err
}

// UploadReceiver is UploadForm on the API's own path, for a client
// with Basic credentials and no script: the same gates, authenticated
// the way every op under /api/ is.
// MountUploadReceiver mounts the multipart receiver AND re-opens the op
// routes it would otherwise swallow.
//
// The receiver takes a trailing wildcard — POST .../attachments/{target...}
// — because a target is a ticket id, a page name or a post hash and may
// carry slashes. That wildcard claims every literal name under the same
// prefix, and the ops mounted there are reached through the router at
// "/api/", a PREFIX match that loses to it. `attachment.begin` and
// `attachment.commit` escaped this by moving to the singular
// "/attachment/"; vouch, reject and remove did not, and answered
// "expected a multipart form with a file" to everyone, administrators
// included, so no API client could moderate an attachment at all
// (G-148, jva). The OpenAPI meanwhile documented them as JSON ops.
//
// Rather than remember which names are taken, the literal op routes are
// registered on the SAME mux, where ServeMux's own precedence settles
// it: a literal last segment matches a strict subset of the wildcard,
// so the op wins. Derived from the registry, so an op added under this
// prefix tomorrow is mounted the day it is written rather than the day
// somebody notices it answering 400.
func MountUploadReceiver(mux *http.ServeMux, d Deps, r *ops.Registry, verify Verify, capsFor CapsFor, router http.Handler) {
	const claimed = "/api/v1/r/{repo}/attachments/"
	mux.Handle("POST "+claimed+"{target...}", UploadReceiver(d, r, verify, capsFor))
	if router == nil {
		return
	}
	for _, op := range r.All() {
		if op.Method != http.MethodPost || !strings.HasPrefix(op.Pattern, claimed) {
			continue
		}
		if strings.ContainsAny(op.Pattern[len(claimed):], "{}/") {
			continue // itself a wildcard: nothing to rescue
		}
		mux.Handle(op.Method+" "+op.Pattern, router)
	}
}

func UploadReceiver(d Deps, r *ops.Registry, verify Verify, capsFor CapsFor) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		repo, target := req.PathValue("repo"), req.PathValue("target")
		ctx := req.Context()
		if verify != nil {
			user, pass, _ := req.BasicAuth()
			switch verify(repo, user, pass, ops.Token) {
			case Denied:
				w.Header().Set("WWW-Authenticate", `Basic realm="quarry"`)
				writeError(w, http.StatusUnauthorized, errors.New("authentication required"))
				return
			case Insufficient:
				writeError(w, http.StatusForbidden, errors.New("this account does not have the capability this request needs"))
				return
			}
			ctx = ops.WithUser(ctx, user)
			if capsFor != nil {
				ctx = ops.WithCaps(ctx, capsFor(user, repo))
			}
		}
		out, err := UploadForm(ctx, d, r, repo, target, req)
		if err != nil {
			writeError(w, uploadStatus(err), err)
			return
		}
		writeJSON(w, http.StatusOK, out)
	})
}