Artifact a3719a8f59628165c3cac2165be4380a4a47b28fc589f2cd42a4c9281dc05044:
- File
internal/api/register.go
— part of check-in
[9ac012f7f2]
at
2026-08-17 00:57:10
on branch trunk
— tickets: display them the way fossil models them
The page dumped every folded field into one table, which read as noise and lost most of the ticket. Fossil's model has three parts and quarry was collapsing all of them into one:
- Scalar state (status, priority, resolution…) folds last-write-wins. - A leading + is the APPEND form: 'J +comment more' grows the field rather than replacing it. quarry stored a separate field literally named '+comment' — sqlite's tickets use it 255 times. - icomment belongs to the CHANGE, never to the ticket. Folding it left one comment standing where a conversation was: the busiest tcl ticket showed 1 of its 84.
Per-change plumbing (icomment, mimetype, login, username) no longer appears as ticket state. The page now leads with the title, shows fossil's header order, renders the description and every comment through their own recorded mimetype, and attributes each — including fossil's 'claiming to be' when the typed name differs from the login, which on an open tracker it may.
Verified on tcl 408840ff: 11 clean fields, 84 comments, 5 attachments. (user: dmestas, size: 50438) [annotate] [blame] [check-ins using]
// Package api owns RegisterAll — the one explicit wiring point where every // op joins the registry (0003 §2: no init() magic; the wiring stays // greppable) — plus the HTTP router that serves the registered ops. This // is the sanctioned place Run closures over the data planes are built; // the arch gate keeps every other consumer on the op side of the seam. package api import ( "bytes" "context" "encoding/base64" "errors" "fmt" "html" "log" "net/url" "regexp" "sort" "strings" "time" "unicode/utf8" "craftdesign.group/quarry/internal/chat" "craftdesign.group/quarry/internal/highlight" "craftdesign.group/quarry/internal/index" "craftdesign.group/quarry/internal/markup" "craftdesign.group/quarry/internal/ops" "craftdesign.group/quarry/internal/zitcli" ) // Repo is one registered store's data-plane pair. type Repo struct { Index *index.Index Zit *zitcli.Client // Sync runs one ingest round; write ops call it so a successful // response means the index already reflects the write (read-your- // writes). nil skips (the background poll converges instead). Sync func() (int, error) // Guard runs a write under the store's single-writer lease (0001 // §3): it refuses when another node holds the lease or the // coordinator is unreachable. nil runs the write directly // (single-node default). Guard func(func() error) error // Code is the store's project code — the chat channel key (0001 // §5), stable across nodes and repo renames. Code string // Indexing reports whether this repo's first ingest is still // running. A large store takes minutes to fold, and during that // window its counts read as zero — which looks broken unless the // surface says so. nil means "never indexing" (test wiring). Indexing func() bool } // ChatPlane is the per-repo live channel (0001 §5). A nil Deps.Chat // disables chat (no event plane). type ChatPlane interface { Post(code string, m chat.Message) error History(code string, limit int) ([]chat.Message, error) } // NotifyEvent is one notification trigger quarry raises for the notify // companion to route (0001 §5): quarry names the workflow and the // recipient and hands over a payload; notify owns delivery. type NotifyEvent struct { Workflow string // notify workflow name (quarry-mention, quarry-activity) To string // subscriber id — a user, or "repo:<name>" topic Payload map[string]any // repo, actor, summary, link TxnID string // idempotency key (dedups replays) } // NotifyPlane fires triggers best-effort. A nil Deps.Notify disables // notifications. type NotifyPlane interface { Trigger(ctx context.Context, e NotifyEvent) error } // WebhookPlane queues one activity event for third-party delivery // (Principle 6). Deliberately stated in primitives rather than the // webhook package's own type: the ops layer names what happened, and // the composition root owns how it is delivered. A nil Deps.Hooks // disables webhooks. type WebhookPlane interface { Raise(id, repo, kind, actor, summary, link string) error } // mentionRE matches an @handle mention — the token notify routes to a // subscriber. The @ must start a word (not follow a name char), so an // email address's @ is not a mention. var mentionRE = regexp.MustCompile(`(^|[^a-zA-Z0-9._-])@([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})`) // mentions returns the distinct @handles named in text, without the @. func mentions(text string) []string { seen := map[string]bool{} var out []string for _, m := range mentionRE.FindAllStringSubmatch(text, -1) { if !seen[m[2]] { seen[m[2]] = true out = append(out, m[2]) } } return out } // fireNotify raises an event best-effort: never blocks the op, never // fails it. A disabled plane (nil) is a no-op. // // An ACTIVITY event is also the webhook surface's event — the same // thing happened, and Principle 6 asks that a third party be able to // see it without speaking NATS. Mentions are deliberately not // forwarded: they are addressed to one person, not to the repository. // Queueing is a publish, so it does not slow the op down; the delivery // worker does the HTTP. func (d Deps) fireNotify(e NotifyEvent) { if d.Hooks != nil && e.Workflow == "quarry-activity" { repo, _ := e.Payload["repo"].(string) actor, _ := e.Payload["actor"].(string) summary, _ := e.Payload["summary"].(string) link, _ := e.Payload["link"].(string) if err := d.Hooks.Raise(e.TxnID, repo, kindOf(e.TxnID), actor, summary, link); err != nil { log.Printf("webhook: queue %s: %v", e.TxnID, err) } } if d.Notify == nil { return } go func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := d.Notify.Trigger(ctx, e); err != nil { log.Printf("notify: %s: %v", e.Workflow, err) } }() } // synced runs the repo's ingest round after a successful write. func (r *Repo) synced() error { if r.Sync == nil { return nil } _, err := r.Sync() return err } // write runs fn under the repo's write lease (or directly if unleased). func (r *Repo) write(fn func() error) error { if r.Guard == nil { return fn() } return r.Guard(fn) } // hasControl reports whether s carries an ASCII control byte (below // 0x20, or DEL) — such bytes have no place in a wiki name or ticket id // and would otherwise reach the zit subprocess and fault. func hasControl(s string) bool { for i := 0; i < len(s); i++ { if s[i] < 0x20 || s[i] == 0x7f { return true } } return false } // author resolves the recorded author: the authenticated user, or // "anonymous" on an ungated repo. func author(ctx context.Context) string { if u := ops.User(ctx); u != "" { return u } return "anonymous" } // Deps carries what op implementations close over. Repos may be empty // (`quarry spec` runs storeless); ops answer unknown repos with // not-found, and the spec stays complete either way. type Deps struct { Version string Repos map[string]*Repo Chat ChatPlane Notify NotifyPlane Hooks WebhookPlane } // kindOf recovers the event kind from an activity idempotency key, // which is built as "activity:<kind>:<hash>". Keeping one key format // means the webhook kind and the dedup key cannot disagree. func kindOf(txnID string) string { rest, ok := strings.CutPrefix(txnID, "activity:") if !ok { return "activity" } kind, _, _ := strings.Cut(rest, ":") return kind } // repo resolves one registered store; unknown names are not-found. func (d Deps) repo(name string) (*Repo, error) { if r, ok := d.Repos[name]; ok { return r, nil } return nil, fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound) } type versionIn struct{} type versionOut struct { Version string `json:"version"` } type repoListIn struct{} type repoInfo struct { Name string `json:"name"` Checkins int `json:"checkins"` TipDate string `json:"tipDate"` TipComment string `json:"tipComment"` // Indexing is true while the first ingest is still folding, so a // zero count reads as "catching up", not "empty". Indexing bool `json:"indexing"` } type wikiSaveIn struct { Repo string `json:"repo" validate:"required,max=64"` Name string `json:"name" validate:"required,max=100"` Body string `json:"body" validate:"required,max=1048576"` Mimetype string `json:"mimetype" validate:"max=64"` } type wikiSaveOut struct { Hash string `json:"hash"` } type ticketNewIn struct { Repo string `json:"repo" validate:"required,max=64"` Title string `json:"title" validate:"required,max=200"` Status string `json:"status" validate:"max=40"` Comment string `json:"comment" validate:"max=65536"` } type ticketNewOut struct { ID string `json:"id"` Hash string `json:"hash"` } type ticketSetIn struct { Repo string `json:"repo" validate:"required,max=64"` ID string `json:"id" validate:"required,min=4,max=40"` Title string `json:"title" validate:"max=200"` Status string `json:"status" validate:"max=40"` Resolution string `json:"resolution" validate:"max=40"` Comment string `json:"comment" validate:"max=65536"` } type ticketSetOut struct { Hash string `json:"hash"` } type docsIn struct{} type docsOut struct { Markdown string `json:"markdown"` HTML string `json:"html"` } type repoListOut struct { Repos []repoInfo `json:"repos"` Count int `json:"count"` } type timelineIn struct { Repo string `json:"repo" validate:"required,max=64"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` Before string `json:"before" validate:"max=96"` Branch string `json:"branch" validate:"max=64"` Y string `json:"y" validate:"oneof=all|ci|w|t|e|f"` } type timelineOut struct { Entries []index.TimelineEntry `json:"entries"` Count int `json:"count"` // Next is the `before` cursor for the following (older) page; // empty when this page is the last. Next string `json:"next"` } type wikiListIn struct { Repo string `json:"repo" validate:"required,max=64"` } type wikiListOut struct { Pages []index.WikiTitle `json:"pages"` Count int `json:"count"` } type wikiGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Name string `json:"name" validate:"required,max=100"` Rev string `json:"rev" validate:"max=64"` // artifact hash prefix; empty = latest } type wikiGetOut struct { Hash string `json:"hash"` Title string `json:"title"` Date string `json:"date"` User string `json:"user"` Mimetype string `json:"mimetype"` Source string `json:"source"` HTML string `json:"html"` } type wikiHistoryIn struct { Repo string `json:"repo" validate:"required,max=64"` Name string `json:"name" validate:"required,max=100"` } type technoteListIn struct { Repo string `json:"repo" validate:"required,max=64"` } type technoteListOut struct { Notes []index.Technote `json:"notes"` Count int `json:"count"` } type technoteGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Event string `json:"event" validate:"required,max=64"` } type technoteGetOut struct { Event string `json:"event"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` // C card: the title, for the edit form Body string `json:"body"` // raw source, for the edit form HTML string `json:"html"` } type forumListIn struct { Repo string `json:"repo" validate:"required,max=64"` } type forumListOut struct { Threads []index.ForumThread `json:"threads"` Count int `json:"count"` } type forumGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Root string `json:"root" validate:"required,min=4,max=64"` } type forumRendered struct { Hash string `json:"hash"` Parent string `json:"parent"` Date string `json:"date"` User string `json:"user"` HTML string `json:"html"` } type forumGetOut struct { Root string `json:"root"` Title string `json:"title"` Posts []forumRendered `json:"posts"` } type forumPostIn struct { Repo string `json:"repo" validate:"required,max=64"` Title string `json:"title" validate:"required,max=200"` Body string `json:"body" validate:"required,max=1048576"` // Mimetype is the N card. Empty omits it, which Fossil reads as // text/x-fossil-wiki — the same dispatch the read side applies. Mimetype string `json:"mimetype" validate:"max=64"` } type forumPostOut struct { Hash string `json:"hash"` } type forumReplyIn struct { Repo string `json:"repo" validate:"required,max=64"` // Root names the thread being answered — the same path parameter // forum.get takes, so the two share one consistent template. Root string `json:"root" validate:"required,min=4,max=64"` Body string `json:"body" validate:"required,max=1048576"` Mimetype string `json:"mimetype" validate:"max=64"` } type forumReplyOut struct { Hash string `json:"hash"` } type technoteAddIn struct { Repo string `json:"repo" validate:"required,max=64"` Body string `json:"body" validate:"required,max=1048576"` Mimetype string `json:"mimetype" validate:"max=64"` // Comment is the C card, which a timeline renders as the note's // title. It is write-once at creation: zit refuses to attach the // card later, so a note saved without one shows its event id // forever. Comment string `json:"comment" validate:"max=200"` // Event reuses an existing technote's id to record an edit; empty // mints a new technote. Event string `json:"event" validate:"max=64"` } type technoteAddOut struct { Event string `json:"event"` Hash string `json:"hash"` } type chatHistoryIn struct { Repo string `json:"repo" validate:"required,max=64"` Limit int `json:"limit" validate:"min=0,max=500,default=100"` } type chatHistoryOut struct { Messages []chat.Message `json:"messages"` Count int `json:"count"` } type chatPostIn struct { Repo string `json:"repo" validate:"required,max=64"` Text string `json:"text" validate:"required,max=4096"` } type chatPostOut struct { OK bool `json:"ok"` } type wikiHistoryOut struct { Title string `json:"title"` Edits []index.WikiRev `json:"edits"` Count int `json:"count"` } type fileGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Hash string `json:"hash" validate:"required,min=4,max=64"` Path string `json:"path" validate:"required,max=512"` } type fileGetOut struct { Hash string `json:"hash"` Path string `json:"path"` Size int `json:"size"` Binary bool `json:"binary"` Content string `json:"content"` HTML string `json:"html"` // highlighted, sanitizer-gated; empty when no lexer matched } type attachmentGetIn struct { Repo string `json:"repo" validate:"required,max=64"` // Hash is the attachment's CONTENT artifact, not the A-card // artifact — the A card names what to serve. Hash string `json:"hash" validate:"required,min=4,max=64"` } type attachmentGetOut struct { Name string `json:"name"` Size int `json:"size"` // Content is base64: an attachment is arbitrary bytes and JSON has // no binary type. The HTML route decodes and streams it as a // download; the API answer stays a normal JSON document. Content string `json:"content"` } type searchIn struct { Repo string `json:"repo" validate:"required,max=64"` Q string `json:"q" validate:"required,max=200"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` } type searchOut struct { Results []index.SearchResult `json:"results"` Count int `json:"count"` } type diffGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Hash string `json:"hash" validate:"required,min=4,max=64"` } type diffGetOut struct { From string `json:"from"` To string `json:"to"` Files []zitcli.DiffEntry `json:"files"` Unified string `json:"unified"` Note string `json:"note"` // set when the diff could not be fully computed } type checkinGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Hash string `json:"hash" validate:"required,min=4,max=64"` } type checkinGetOut struct { Hash string `json:"hash"` Branch string `json:"branch"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` Parents []string `json:"parents"` Files []zitcli.LsEntry `json:"files"` } type ticketListIn struct { Repo string `json:"repo" validate:"required,max=64"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` Before string `json:"before" validate:"max=96"` } // ticketSummary is the list row: id plus the two fields every tracker // listing needs, pulled out of the folded state. type ticketSummary struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Updated string `json:"updated"` } type ticketListOut struct { Tickets []ticketSummary `json:"tickets"` Count int `json:"count"` // Next is the `before` cursor for the following (older-updated) // page; empty when this page is the last. Next string `json:"next"` } type ticketGetIn struct { Repo string `json:"repo" validate:"required,max=64"` ID string `json:"id" validate:"required,min=4,max=40"` } // ticketComment is one discussion entry with its body rendered. type ticketComment struct { Date string `json:"date"` Login string `json:"login"` Username string `json:"username"` HTML string `json:"html"` } type ticketGetOut struct { Ticket index.TicketState `json:"ticket"` // DescriptionHTML and Comments are the rendered forms. Fossil renders // a ticket's body and every comment through the wiki with the // mimetype recorded ALONGSIDE each one, so a plaintext comment on a // markdown tracker still reads as it was written. DescriptionHTML string `json:"descriptionHtml"` Comments []ticketComment `json:"comments"` // Attachments are the files currently attached to this ticket. // Fossil records them as their own artifacts targeting the ticket // id, so they are not part of TicketState's folded field set. Attachments []index.Attachment `json:"attachments"` } func fieldValue(fields []index.TicketFieldSet, name string) string { for _, f := range fields { if f.Name == name { return f.Value } } return "" } // resolver implements markup.Resolver over one repo's index. Wiki names // resolve to existing pages; hash prefixes resolve to uniquely matching // check-ins or tickets. An unresolvable or ambiguous reference renders // as plain text, never as a dead link. type resolver struct { ix *index.Index repo string } func (r resolver) ResolveHash(prefix string) string { if r.ix == nil { return "" } if e, _, ok, err := r.ix.CheckinByPrefix(prefix); err == nil && ok { return "/r/" + r.repo + "/checkin/" + e.Hash } if t, ok, err := r.ix.TicketByPrefix(prefix); err == nil && ok { return "/r/" + r.repo + "/ticket/" + t.ID } return "" } func (r resolver) ResolveWiki(name string) string { if r.ix == nil { return "" } if _, ok, err := r.ix.WikiPage(name); err == nil && ok { return "/r/" + r.repo + "/wiki/" + url.PathEscape(name) } return "" } // fossilRoute splits a Fossil site-relative URL into its verb and the // rest ("/doc/trunk/README.md" -> "doc", "trunk/README.md"). var fossilRoute = regexp.MustCompile(`^/([a-z]+)(?:[/?](.*))?$`) // ResolvePath maps Fossil's own site-relative URLs — which imported // wiki, technote and forum content is full of — onto quarry routes. // Fossil verbs quarry has no page for return "" so the caller drops the // link instead of leaving a 404 behind (libfossil's wiki alone carries // 500 /finfo, 211 /dir and 105 /timeline references). func (r resolver) ResolvePath(p string) string { m := fossilRoute.FindStringSubmatch(p) if m == nil { return "" } verb, rest := m[1], m[2] base := "/r/" + r.repo switch verb { case "doc": // /doc/<version>/<path> — embedded documentation at a check-in. // quarry serves the same bytes from its file route, and zit // resolves the symbolic version. if ver, path, ok := strings.Cut(rest, "/"); ok && path != "" { return base + "/file/" + ver + "/" + path } case "info", "artifact", "whatis": // /info/<hash> resolves to whatever the artifact is. if h, _, _ := strings.Cut(rest, "?"); h != "" { if u := r.ResolveHash(h); u != "" { return u } } case "timeline": return base + "/timeline" case "wiki": // /wiki?name=Page if v, err := url.ParseQuery(rest); err == nil { if n := v.Get("name"); n != "" { if u := r.ResolveWiki(n); u != "" { return u } } } case "forum", "forumpost", "forumthread": return base + "/forum" case "technote", "event": return base + "/technote" } // finfo, dir, uv, leaves, brlist, … — real Fossil pages quarry does // not have yet. Dropping the link beats sending a reader to a 404. return "" } // RegisterAll wires every op. func RegisterAll(r *ops.Registry, d Deps) error { if err := ops.Register(r, ops.Op[versionIn, versionOut]{ Name: "meta.version", Doc: "Report the quarry build version.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/version", Run: func(_ context.Context, _ versionIn) (versionOut, error) { return versionOut{Version: d.Version}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiSaveIn, wikiSaveOut]{ Name: "wiki.save", Doc: "Record a wiki page edit (creates the page on first save).", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/wiki/{name}", Run: func(ctx context.Context, in wikiSaveIn) (wikiSaveOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiSaveOut{}, err } if hasControl(in.Name) { return wikiSaveOut{}, fmt.Errorf("wiki name has control characters: %w", ops.ErrInvalid) } mt := in.Mimetype if mt == "" { mt = "text/x-markdown" } var hash string if err := repo.write(func() (err error) { hash, err = repo.Zit.WikiCommit(in.Name, author(ctx), mt, []byte(in.Body)) return err }); err != nil { return wikiSaveOut{}, err } if err := repo.synced(); err != nil { return wikiSaveOut{}, err } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": "edited wiki page " + in.Name, "link": "/r/" + in.Repo + "/wiki/" + in.Name}, TxnID: "activity:wiki:" + hash, }) return wikiSaveOut{Hash: hash}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketNewIn, ticketNewOut]{ Name: "ticket.new", Doc: "Open a new ticket.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/ticket", Run: func(ctx context.Context, in ticketNewIn) (ticketNewOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketNewOut{}, err } status := in.Status if status == "" { status = "Open" } fields := map[string]string{"title": in.Title, "status": status} if in.Comment != "" { fields["comment"] = in.Comment } var id, hash string if err := repo.write(func() (err error) { id, hash, err = repo.Zit.TicketNew(author(ctx), fields) return err }); err != nil { return ticketNewOut{}, err } if err := repo.synced(); err != nil { return ticketNewOut{}, err } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": "opened ticket " + in.Title, "link": "/r/" + in.Repo + "/ticket/" + id}, TxnID: "activity:ticket-new:" + hash, }) return ticketNewOut{ID: id, Hash: hash}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketSetIn, ticketSetOut]{ Name: "ticket.set", Doc: "Record a ticket change; empty fields are left untouched.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/ticket/{id}", Run: func(ctx context.Context, in ticketSetIn) (ticketSetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketSetOut{}, err } // The route accepts the id prefixes pages link with; the zit // verb wants the full stored id. t, ok, err := repo.Index.TicketByPrefix(in.ID) if err != nil { return ticketSetOut{}, err } if !ok { return ticketSetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound) } fields := map[string]string{} for k, v := range map[string]string{ "title": in.Title, "status": in.Status, "resolution": in.Resolution, "comment": in.Comment, } { if v != "" { fields[k] = v } } if len(fields) == 0 { return ticketSetOut{}, fmt.Errorf("no fields to set: %w", ops.ErrInvalid) } var hash string if err := repo.write(func() (err error) { hash, err = repo.Zit.TicketSet(t.ID, author(ctx), fields) return err }); err != nil { return ticketSetOut{}, err } if err := repo.synced(); err != nil { return ticketSetOut{}, err } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": "updated ticket " + t.ID[:min(10, len(t.ID))], "link": "/r/" + in.Repo + "/ticket/" + t.ID[:min(10, len(t.ID))]}, TxnID: "activity:ticket-set:" + hash, }) return ticketSetOut{Hash: hash}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[docsIn, docsOut]{ Name: "meta.docs", Doc: "The API reference, generated from this registry and rendered.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/docs", Run: func(_ context.Context, _ docsIn) (docsOut, error) { // r is complete by the time any op runs; the reference // documents the same registry that serves it. md := ops.Docs(r, "quarry") rendered, err := markup.Render(md, resolver{}) if err != nil { return docsOut{}, err } return docsOut{Markdown: string(md), HTML: string(rendered)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[repoListIn, repoListOut]{ Name: "repo.list", Doc: "List the served repositories with their tip check-in.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/repos", Run: func(_ context.Context, _ repoListIn) (repoListOut, error) { names := make([]string, 0, len(d.Repos)) for name := range d.Repos { names = append(names, name) } sort.Strings(names) out := repoListOut{Count: len(names)} for _, name := range names { info := repoInfo{Name: name} if r := d.Repos[name]; r != nil && r.Indexing != nil { info.Indexing = r.Indexing() } if ix := d.Repos[name].Index; ix != nil { info.Checkins, _ = ix.CheckinCount() if tip, _, err := ix.Timeline(1, "", "", "ci"); err == nil && len(tip) == 1 { info.TipDate, info.TipComment = tip[0].Date, tip[0].Comment } } out.Repos = append(out.Repos, info) } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[timelineIn, timelineOut]{ Name: "timeline.list", Doc: "List a repository's events — check-ins, wiki edits, ticket changes — newest first, paged by `before`.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/timeline", Run: func(_ context.Context, in timelineIn) (timelineOut, error) { repo, err := d.repo(in.Repo) if err != nil { return timelineOut{}, err } entries, more, err := repo.Index.Timeline(in.Limit, in.Before, in.Branch, in.Y) if err != nil { return timelineOut{}, err } out := timelineOut{Entries: entries, Count: len(entries)} if more && len(entries) > 0 { last := entries[len(entries)-1] out.Next = last.Date + "~" + last.Hash } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiListIn, wikiListOut]{ Name: "wiki.list", Doc: "List a repository's wiki pages with their latest edit.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/wiki", Run: func(_ context.Context, in wikiListIn) (wikiListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiListOut{}, err } pages, err := repo.Index.WikiTitles() if err != nil { return wikiListOut{}, err } return wikiListOut{Pages: pages, Count: len(pages)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketListIn, ticketListOut]{ Name: "ticket.list", Doc: "List a repository's tickets with their folded current state.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/ticket", Run: func(_ context.Context, in ticketListIn) (ticketListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketListOut{}, err } all, more, err := repo.Index.TicketSummaries(in.Limit, in.Before) if err != nil { return ticketListOut{}, err } out := ticketListOut{Count: len(all)} for _, t := range all { out.Tickets = append(out.Tickets, ticketSummary{ ID: t.ID, Title: t.Title, Status: t.Status, Updated: t.Updated, }) } if more && len(all) > 0 { last := all[len(all)-1] out.Next = last.Updated + "~" + last.ID } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketGetIn, ticketGetOut]{ Name: "ticket.get", Doc: "Fetch one ticket: folded state plus change history.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/ticket/{id}", Run: func(_ context.Context, in ticketGetIn) (ticketGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketGetOut{}, err } t, ok, err := repo.Index.TicketByPrefix(in.ID) if err != nil { return ticketGetOut{}, err } if !ok { return ticketGetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound) } // Attachments key off the FULL ticket id, not the prefix the // caller passed. att, err := repo.Index.AttachmentsFor(t.ID) if err != nil { return ticketGetOut{}, err } out := ticketGetOut{Ticket: t, Attachments: att} if t.Description != "" { html, err := renderBody(repo.Index, in.Repo, t.DescriptionMimetype, []byte(t.Description)) if err != nil { return ticketGetOut{}, err } out.DescriptionHTML = html } for _, c := range t.Comments { html, err := renderBody(repo.Index, in.Repo, c.Mimetype, []byte(c.Body)) if err != nil { return ticketGetOut{}, err } out.Comments = append(out.Comments, ticketComment{ Date: c.Date, Login: c.Login, Username: c.Username, HTML: html, }) } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[attachmentGetIn, attachmentGetOut]{ Name: "attachment.get", Doc: "Fetch one attachment's content by its content-artifact hash.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/attachment/{hash}", Run: func(_ context.Context, in attachmentGetIn) (attachmentGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return attachmentGetOut{}, err } // The A card is the authority on what this blob is called and // whether it is still attached. Serving a blob the index does // not know as a live attachment would turn this route into a // general read-any-artifact endpoint. name, ok, err := repo.Index.AttachmentName(in.Hash) if err != nil { return attachmentGetOut{}, err } if !ok { return attachmentGetOut{}, fmt.Errorf("no attachment %q: %w", in.Hash, ops.ErrNotFound) } raw, err := repo.Zit.ArtifactRaw(in.Hash, maxAttachmentBytes) if err != nil { if errors.Is(err, zitcli.ErrNoEntry) { return attachmentGetOut{}, fmt.Errorf("attachment content unavailable: %w", ops.ErrNotFound) } return attachmentGetOut{}, err } return attachmentGetOut{Name: name, Size: len(raw), Content: base64.StdEncoding.EncodeToString(raw)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[searchIn, searchOut]{ Name: "search.query", Doc: "Full-text search over a repository's check-ins, wiki, and tickets.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/search", Run: func(_ context.Context, in searchIn) (searchOut, error) { repo, err := d.repo(in.Repo) if err != nil { return searchOut{}, err } results, err := repo.Index.Search(in.Q, in.Limit) if err != nil { return searchOut{}, err } return searchOut{Results: results, Count: len(results)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[diffGetIn, diffGetOut]{ Name: "checkin.diff", Doc: "Diff one check-in against its primary parent.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/checkin/{hash}/diff", Run: func(_ context.Context, in diffGetIn) (diffGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return diffGetOut{}, err } e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash) if err != nil { return diffGetOut{}, err } if !ok { return diffGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound) } out := diffGetOut{To: e.Hash} if len(parents) == 0 { // A root check-in has nothing to diff against; its // whole file set is the change. files, err := repo.Zit.Ls(e.Hash) if err != nil { return diffGetOut{}, err } for _, f := range files { out.Files = append(out.Files, zitcli.DiffEntry{Status: "A", Name: f.Name}) } return out, nil } out.From = parents[0] unified, brief, err := repo.Zit.Diff(out.From, e.Hash) if err != nil { return diffGetOut{}, err } out.Unified, out.Files = unified, brief // zit exits 0 with empty output when changed content is // unavailable (shunned) — flag it rather than serving what // looks like a legitimate no-op diff. if out.Unified == "" && len(out.Files) > 0 { out.Note = "diff content unavailable — the changed file content may have been shunned from the store" } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[fileGetIn, fileGetOut]{ Name: "file.get", Doc: "Fetch one file's content at a check-in.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/file/{hash}/{path...}", Run: func(_ context.Context, in fileGetIn) (fileGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return fileGetOut{}, err } // A hash prefix resolves through the index; anything else — // "trunk", "tip", a tag — is a name only zit resolves, and // zit is the authority on names (0001 §2). Fossil's /doc // links use exactly those symbolic versions. version := in.Hash if e, _, ok, err := repo.Index.CheckinByPrefix(in.Hash); err != nil { return fileGetOut{}, err } else if ok { version = e.Hash } raw, err := repo.Zit.Cat(in.Path, version) if errors.Is(err, zitcli.ErrNoEntry) { // Clean public message; the zit detail stays behind. return fileGetOut{}, fmt.Errorf("no file %q at this check-in: %w", in.Path, ops.ErrNotFound) } if err != nil { return fileGetOut{}, err } out := fileGetOut{Hash: version, Path: in.Path, Size: len(raw)} // Binary content stays out of the JSON surface; the page // says "binary" and the artifact read verb serves bytes. if utf8.Valid(raw) && !bytes.ContainsRune(raw, 0) { out.Content = string(raw) // Best-effort highlighting; sanitized like all rendered // HTML even though quarry generated it. if hl, ok := highlight.Code(raw, "", in.Path); ok { out.HTML = markup.Sanitize(hl) } } else { out.Binary = true } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[checkinGetIn, checkinGetOut]{ Name: "checkin.get", Doc: "Describe one check-in: metadata, parents, and its file set.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/checkin/{hash}", Run: func(_ context.Context, in checkinGetIn) (checkinGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return checkinGetOut{}, err } e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash) if err != nil { return checkinGetOut{}, err } if !ok { return checkinGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound) } out := checkinGetOut{ Hash: e.Hash, Branch: e.Branch, Date: e.Date, User: e.User, Comment: e.Comment, Parents: parents, } // File set is an on-demand zit read (0001 §2), not index // state — the index answers "which check-in", the store // answers "what does it hold". if repo.Zit != nil { files, err := repo.Zit.Ls(e.Hash) if err != nil { return checkinGetOut{}, err } out.Files = files } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiHistoryIn, wikiHistoryOut]{ Name: "wiki.history", Doc: "List every recorded edit of one wiki page, newest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/wiki/{name}/history", Run: func(_ context.Context, in wikiHistoryIn) (wikiHistoryOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiHistoryOut{}, err } edits, err := repo.Index.WikiHistory(in.Name) if err != nil { return wikiHistoryOut{}, err } if len(edits) == 0 { return wikiHistoryOut{}, fmt.Errorf("no wiki page %q: %w", in.Name, ops.ErrNotFound) } return wikiHistoryOut{Title: in.Name, Edits: edits, Count: len(edits)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiGetIn, wikiGetOut]{ Name: "wiki.get", Doc: "Fetch one wiki page, rendered — latest, or the revision named by rev.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/wiki/{name}", Run: func(_ context.Context, in wikiGetIn) (wikiGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiGetOut{}, err } page, ok, err := repo.Index.WikiRevision(in.Name, in.Rev) if err != nil { return wikiGetOut{}, err } if !ok { return wikiGetOut{}, fmt.Errorf("no wiki page %q rev %q: %w", in.Name, in.Rev, ops.ErrNotFound) } html, err := renderBody(repo.Index, in.Repo, page.Mimetype, page.Body) if err != nil { return wikiGetOut{}, err } return wikiGetOut{ Hash: page.Hash, Title: page.Title, Date: page.Date, User: page.User, Mimetype: page.Mimetype, Source: string(page.Body), HTML: html, }, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[technoteListIn, technoteListOut]{ Name: "technote.list", Doc: "List a repository's technotes at their latest edit, newest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/technote", Run: func(_ context.Context, in technoteListIn) (technoteListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return technoteListOut{}, err } notes, err := repo.Index.Technotes() if err != nil { return technoteListOut{}, err } return technoteListOut{Notes: notes, Count: len(notes)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[technoteGetIn, technoteGetOut]{ Name: "technote.get", Doc: "Fetch one technote, rendered, by event-id prefix.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/technote/{event}", Run: func(_ context.Context, in technoteGetIn) (technoteGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return technoteGetOut{}, err } n, ok, err := repo.Index.TechnoteByEvent(in.Event) if err != nil { return technoteGetOut{}, err } if !ok { return technoteGetOut{}, fmt.Errorf("no technote %q: %w", in.Event, ops.ErrNotFound) } html, err := renderBody(repo.Index, in.Repo, n.Mimetype, []byte(n.Body)) if err != nil { return technoteGetOut{}, err } return technoteGetOut{Event: n.Event, Date: n.Date, User: n.User, Comment: n.Comment, Body: n.Body, HTML: html}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[forumListIn, forumListOut]{ Name: "forum.list", Doc: "List a repository's forum threads, most-recently-active first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/forum", Run: func(_ context.Context, in forumListIn) (forumListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return forumListOut{}, err } threads, err := repo.Index.ForumThreads() if err != nil { return forumListOut{}, err } return forumListOut{Threads: threads, Count: len(threads)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[forumGetIn, forumGetOut]{ Name: "forum.get", Doc: "Fetch one forum thread: every post, rendered, in reading order.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/forum/{root}", Run: func(_ context.Context, in forumGetIn) (forumGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return forumGetOut{}, err } title, posts, ok, err := repo.Index.ForumThreadPosts(in.Root) if err != nil { return forumGetOut{}, err } if !ok { return forumGetOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound) } out := forumGetOut{Root: in.Root, Title: title} for _, p := range posts { // Dispatch on the post's own N card (absent = Fossil wiki, // matching fossil's forum_render). html, err := renderBody(repo.Index, in.Repo, p.Mimetype, []byte(p.Body)) if err != nil { return forumGetOut{}, err } out.Posts = append(out.Posts, forumRendered{ Hash: p.Hash, Parent: p.Parent, Date: p.Date, User: p.User, HTML: html, }) } return out, nil }, }); err != nil { return err } if err := registerAuthoring(r, d); err != nil { return err } if d.Chat != nil { return registerChat(r, d) } return nil } // registerAuthoring wires the forum and technote write ops (v2 write // plane, on zit#76's verbs). Fossil has the same surfaces — technoteedit // and forumedit — so these are the web faces of record types the format // already carries, not new inventions. func registerAuthoring(r *ops.Registry, d Deps) error { if err := ops.Register(r, ops.Op[forumPostIn, forumPostOut]{ Name: "forum.post", Doc: "Open a new forum thread.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/forum", Run: func(ctx context.Context, in forumPostIn) (forumPostOut, error) { repo, err := d.repo(in.Repo) if err != nil { return forumPostOut{}, err } // Same default as wiki.save and technote.add: quarry's own // forms are markdown, and an omitted N card would be read // back as text/x-fossil-wiki (fossil's rule), so a post // typed as markdown would render as wiki markup. mt := in.Mimetype if mt == "" { mt = "text/x-markdown" } var hash string if err := repo.write(func() (err error) { hash, err = repo.Zit.ForumPost(in.Title, author(ctx), mt, []byte(in.Body)) return err }); err != nil { return forumPostOut{}, err } if err := repo.synced(); err != nil { return forumPostOut{}, err } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": "opened forum thread " + in.Title, "link": "/r/" + in.Repo + "/forum/" + hash[:min(16, len(hash))]}, TxnID: "activity:forum-post:" + hash, }) return forumPostOut{Hash: hash}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[forumReplyIn, forumReplyOut]{ Name: "forum.reply", Doc: "Reply to a forum post within its thread.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/forum/{root}", Run: func(ctx context.Context, in forumReplyIn) (forumReplyOut, error) { repo, err := d.repo(in.Repo) if err != nil { return forumReplyOut{}, err } // The route carries the prefix pages link with; zit wants a // post hash it can resolve, so widen it through the index. post := in.Root if _, posts, ok, perr := repo.Index.ForumThreadPosts(in.Root); perr == nil && ok && len(posts) > 0 { post = posts[0].Hash // replying to a thread answers its root } var hash string if err := repo.write(func() (err error) { hash, err = repo.Zit.ForumReply(post, author(ctx), replyMimetype(in.Mimetype), []byte(in.Body)) return err }); err != nil { if errors.Is(err, zitcli.ErrNoEntry) { return forumReplyOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound) } return forumReplyOut{}, err } if err := repo.synced(); err != nil { return forumReplyOut{}, err } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": "replied in a forum thread", "link": "/r/" + in.Repo + "/forum/" + in.Root}, TxnID: "activity:forum-reply:" + hash, }) return forumReplyOut{Hash: hash}, nil }, }); err != nil { return err } return ops.Register(r, ops.Op[technoteAddIn, technoteAddOut]{ Name: "technote.add", Doc: "Record a technote; passing an existing event id records an edit.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/technote", Run: func(ctx context.Context, in technoteAddIn) (technoteAddOut, error) { repo, err := d.repo(in.Repo) if err != nil { return technoteAddOut{}, err } mt := in.Mimetype if mt == "" { mt = "text/x-markdown" } // An edit names an existing technote. Pages link event-id // prefixes, so widen to the full id — passing a prefix would // mint a NEW technote under that truncated id instead. event := in.Event if event != "" { n, ok, err := repo.Index.TechnoteByEvent(event) if err != nil { return technoteAddOut{}, err } if !ok { return technoteAddOut{}, fmt.Errorf("no technote %q: %w", in.Event, ops.ErrNotFound) } event = n.Event } var id, hash string if err := repo.write(func() (err error) { id, hash, err = repo.Zit.TechnoteAdd(author(ctx), event, mt, in.Comment, []byte(in.Body)) return err }); err != nil { return technoteAddOut{}, err } if err := repo.synced(); err != nil { return technoteAddOut{}, err } what := "added technote" if in.Event != "" { what = "edited technote" } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": what, "link": "/r/" + in.Repo + "/technote/" + id[:min(16, len(id))]}, TxnID: "activity:technote:" + hash, }) return technoteAddOut{Event: id, Hash: hash}, nil }, }) } // registerChat wires the per-repo live channel ops (0001 §5). Called // from RegisterAll only when a chat plane is configured. func registerChat(r *ops.Registry, d Deps) error { if err := ops.Register(r, ops.Op[chatHistoryIn, chatHistoryOut]{ Name: "chat.history", Doc: "Recent messages in a repository's chat channel, oldest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/chat", Run: func(_ context.Context, in chatHistoryIn) (chatHistoryOut, error) { repo, err := d.repo(in.Repo) if err != nil { return chatHistoryOut{}, err } limit := in.Limit if limit <= 0 { limit = 100 } msgs, err := d.Chat.History(repo.Code, limit) if err != nil { return chatHistoryOut{}, err } return chatHistoryOut{Messages: msgs, Count: len(msgs)}, nil }, }); err != nil { return err } return ops.Register(r, ops.Op[chatPostIn, chatPostOut]{ Name: "chat.post", Doc: "Post a message to a repository's chat channel.", Kind: ops.Command, // A chat post mutates no store (0001 §5: transport is NATS, not // the DAG), so it is not lease-gated and stays anonymous-postable // in v1; the author is the authenticated user when present. Auth: ops.Anonymous, Route: "POST /api/v1/r/{repo}/chat", Run: func(ctx context.Context, in chatPostIn) (chatPostOut, error) { repo, err := d.repo(in.Repo) if err != nil { return chatPostOut{}, err } from := author(ctx) if err := d.Chat.Post(repo.Code, chat.Message{ Repo: in.Repo, User: from, Text: in.Text, }); err != nil { return chatPostOut{}, err } // Mentions route to notify (0001 §5), one trigger per handle. for _, who := range mentions(in.Text) { d.fireNotify(NotifyEvent{ Workflow: "quarry-mention", To: who, Payload: map[string]any{ "repo": in.Repo, "from": from, "text": in.Text, "link": "/r/" + in.Repo + "/chat", }, TxnID: "mention:" + in.Repo + ":" + who + ":" + from + ":" + in.Text, }) } return chatPostOut{OK: true}, nil }, }) } // renderBody renders an artifact body: markdown/Fossil-wiki through the // sanitizer-gated markup pipeline (so [[wiki]] and hash refs resolve), // anything else as escaped text. func renderBody(ix *index.Index, repo, mimetype string, body []byte) (string, error) { // Dispatch exactly as fossil's wiki_render_by_mimetype does: an // ABSENT mimetype means Fossil wiki markup, not markdown. Treating // it as markdown silently mangles half of every fossil-born corpus // (libfossil: 217 of 443 wiki artifacts carry no N card). res := resolver{ix: ix, repo: repo} switch mimetype { case "text/x-markdown", "text/markdown": rendered, err := markup.Render(body, res) if err != nil { return "", err } return string(rendered), nil case "", "text/x-fossil-wiki": rendered, err := markup.RenderFossilWiki(body, res) if err != nil { return "", err } return string(rendered), nil case "text/x-pikchr": rendered, err := markup.RenderPikchr(body) if err != nil { // A diagram that will not compile shows its source and the // compiler's complaint, never a broken page. return "<pre>" + html.EscapeString(string(body)) + "</pre>" + "<p><small>pikchr: " + html.EscapeString(err.Error()) + "</small></p>", nil } return string(rendered), nil default: return "<pre>" + html.EscapeString(string(body)) + "</pre>", nil } } // replyMimetype applies the same N-card default the other authoring ops // use: an omitted card is text/x-fossil-wiki by fossil's rule, which is // not what quarry's markdown forms produce. func replyMimetype(in string) string { if in == "" { return "text/x-markdown" } return in } // maxAttachmentBytes caps a single attachment read. quarry buffers the // blob to answer through the op layer, so this is the memory an // anonymous request can ask for; larger attachments are refused rather // than truncated. const maxAttachmentBytes = 32 << 20