Artifact 1217a08bfda8edb07a86d1f5a866defa5b9da384b1729203aa0028bcf0426597:
- File
internal/api/register.go
— part of check-in
[0cf9a57e88]
at
2026-08-17 21:14:09
on branch trunk
— zitcli: byte-bounded content cache for hash-versioned reads
zit cat costs ~147ms even for a small file, against a 3ms process floor, and every file view forked one. Under load that capped the forge at 32 req/s, degrading to 24 at twenty concurrent readers.
hash-versioned file page: 393ms -> 18ms 50 concurrent: 201 req/s, still climbing
Bounded by BYTES, not entries: values are file contents, and an entry-counted cache holding a 30MB blob is a memory leak with a polite name. Per repository, so a busy store cannot evict a quiet one's entries.
resolveVersion only expands hash PREFIXES. An earlier draft fell back to the index's branch table for names, which served tk a README from 2020: tk renamed its trunk to main, so a branch literally called trunk still exists and is six years stale, while zit resolves the word correctly. 0001 §2 makes zit the authority on names and that is not negotiable — so a symbolic version stays uncached, and the way to fix that is to make links carry the resolved hash. (user: dmestas, size: 77596) [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 // Health reports this store's ingest state for the fleet admin // surface. Stated as a func returning a local type rather than // exposing the pipeline, for the same reason Sync is: the ops layer // names what it needs and the composition root owns the wiring. // nil means "no health available" (test wiring). Health func() RepoHealth // Mirror pulls from an upstream into this store and reports whether // anything arrived. It must run under the same single-writer lease // Guard enforces — a pull WRITES artifacts, so two nodes mirroring // the same store concurrently is the exact race the lease exists to // prevent. nil means this store cannot be mirrored. Mirror func(url string) (bool, error) } // RepoHealth is one store's ingest state, as the fleet admin page needs // it. Durations are seconds and times are RFC3339 so the shape survives // the JSON surface unchanged. type RepoHealth struct { // LastOK is when a round last succeeded, empty if none ever has. LastOK string `json:"lastOk"` // RoundSeconds is how long the most recent round took. RoundSeconds float64 `json:"roundSeconds"` // LastCount is artifacts published in the last round, Total since // this process started. LastCount int `json:"lastCount"` Total int `json:"total"` // Fallbacks counts artifacts that would not parse and were indexed // as opaque content. Fallbacks int `json:"fallbacks"` // LastErr is the most recent round's failure, empty when healthy. // Failures counts CONSECUTIVE failures: 1 is a blip, 900 is wedged. LastErr string `json:"lastErr"` LastErrAt string `json:"lastErrAt"` Failures int `json:"failures"` } // 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) } }() } // WriteTag records a tag artifact on a check-in through the lease // holder — a verdict is a write like any other, and two nodes must not // both record one. The index picks it up on the next ingest round. func (r *Repo) WriteTag(name, checkin, value string) error { if err := r.write(func() error { return r.Zit.TagAdd(name, checkin, value, false) }); err != nil { return err } return r.synced() } // 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 // CI is the run record; nil when no engine is configured, which is // how a forge without CI simply has no CI pages. CI CIRuns // Mirrors is the upstream-tracking store; nil disables the mirror // ops entirely, which is how a forge that mirrors nothing simply has // no mirror surface. Mirrors MirrorStore } // MirrorStore is the mirror configuration seam. Stated in this package's // own types so the ops layer does not depend on the OLTP package's, // matching how CIRuns is stated. type MirrorStore interface { Mirrors() ([]Mirror, error) MirrorFor(repo string) (Mirror, bool, error) SetMirror(repo, url string, everySeconds int) error DeleteMirror(repo string) error // RecordMirrorRun stamps an attempt; err nil means it succeeded. RecordMirrorRun(repo string, err error) error } // Mirror is one store's upstream configuration and recent history. type Mirror struct { Repo string `json:"repo"` URL string `json:"url"` Every int `json:"every"` // seconds Enabled bool `json:"enabled"` // LastAt is the last ATTEMPT and LastOK the last success, kept apart // so a store that has been failing for a week still reports when it // was last actually current. LastAt string `json:"lastAt"` LastOK string `json:"lastOk"` LastErr string `json:"lastErr"` Failures int `json:"failures"` } // 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 } // PullMirror runs a single mirror: pull, stamp the attempt, and re-ingest // only if artifacts actually arrived. It is shared by the mirror.run op // and the background scheduler (via the composition root) so a manual pull and a scheduled one // cannot drift in behaviour. // // The attempt is recorded BEFORE the ingest round, and the ingest error // is deliberately not folded into the mirror's error: a pull that // succeeded and an index that then failed to fold are different // failures with different fixes, and reporting the second as the first // sends an operator to look at the network. func (d Deps) PullMirror(m Mirror) (bool, error) { repo, err := d.repo(m.Repo) if err != nil { return false, err } if repo.Mirror == nil { return false, fmt.Errorf("repository %q cannot be mirrored: %w", m.Repo, ops.ErrUnavailable) } changed, pullErr := repo.Mirror(m.URL) if err := d.Mirrors.RecordMirrorRun(m.Repo, pullErr); err != nil { log.Printf("mirror: %s: recording run: %v", m.Repo, err) } if pullErr != nil { return false, pullErr } if changed { if err := repo.synced(); err != nil { return true, fmt.Errorf("pulled, but indexing failed: %w", err) } } return changed, nil } // resolveVersion turns a hash PREFIX into a full check-in hash, and // passes every other reference through untouched. // // It deliberately does not resolve names. 0001 §2 makes zit the // authority on them, and an earlier version of this function ignored // that: it fell back to the index's branch table, so "trunk" resolved to // whatever check-in sat on a branch literally named trunk. On tk — which // renamed its trunk to main — that served a README from 2020 while zit // resolved the same word to the current tip on main. Names are aliases, // defaults and tags with rules that live in zit; a second implementation // here is a slower way to be wrong. // // The consequence is that a symbolic version is not cacheable, because // what it names can change under it. Making file links carry the // RESOLVED hash is how that gets fixed, and that resolution has to come // from zit. func (d Deps) resolveVersion(repo *Repo, ref string) string { if ref == "" { return "trunk" } if repo.Index == nil { return ref } if e, _, ok, err := repo.Index.CheckinByPrefix(ref); err == nil && ok { return e.Hash } return ref } // 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 fleetStatusIn struct{} // fleetRepo is one row of the fleet admin surface: identity, size, and // ingest health together. Health is admin-only because LastErr carries // operational detail (store paths, NATS subjects) that the anonymous // repo.list has no business leaking. type fleetRepo struct { Name string `json:"name"` Code string `json:"code"` Checkins int `json:"checkins"` Indexing bool `json:"indexing"` Health RepoHealth `json:"health"` } type fleetStatusOut struct { Repos []fleetRepo `json:"repos"` Count int `json:"count"` // Unhealthy counts stores whose last round failed — the number an // operator actually scans for. Unhealthy int `json:"unhealthy"` } // fleetSyncIn names one repository, or all of them when Repo is empty. type fleetSyncIn struct { Repo string `json:"repo" validate:"max=64"` } type fleetSyncResult struct { Repo string `json:"repo"` Artifacts int `json:"artifacts"` Err string `json:"err,omitempty"` } type repoCountsIn struct { Repo string `json:"repo" validate:"required,max=64"` } // repoCountsOut is the repository nav's content census. It is its own op // rather than part of repo.list because it is per-repository and read on // every page render; the index memoizes it per fold. type repoCountsOut struct { Checkins int `json:"checkins"` WikiPages int `json:"wikiPages"` Tickets int `json:"tickets"` ForumThreads int `json:"forumThreads"` Technotes int `json:"technotes"` } type mirrorListIn struct{} type mirrorListOut struct { Mirrors []Mirror `json:"mirrors"` Count int `json:"count"` } type mirrorSetIn struct { Repo string `json:"repo" validate:"required,max=64"` URL string `json:"url" validate:"required,max=2048"` // Every is the pull interval in seconds. The store floors it — these // upstreams are other people's servers. Every int `json:"every" validate:"min=0,max=604800,default=3600"` } type mirrorSetOut struct { Mirror Mirror `json:"mirror"` } type mirrorDeleteIn struct { Repo string `json:"repo" validate:"required,max=64"` } type mirrorDeleteOut struct { Repo string `json:"repo"` } type mirrorRunIn struct { // Repo names one store, or every configured mirror when empty. Repo string `json:"repo" validate:"max=64"` } type mirrorRunResult struct { Repo string `json:"repo"` // Changed reports whether the pull actually brought anything in. // Almost every scheduled pull is a no-op, so this is what separates // signal from noise in the log and on the page. Changed bool `json:"changed"` Err string `json:"err,omitempty"` } type mirrorRunOut struct { Results []mirrorRunResult `json:"results"` Failed int `json:"failed"` Changed int `json:"changed"` } type fleetSyncOut struct { Results []fleetSyncResult `json:"results"` // Failed counts results carrying an error. A bulk sync reports every // store's outcome rather than aborting on the first failure: with // eleven repositories, "one of them is broken" must not hide the // other ten's state. Failed int `json:"failed"` } 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"` } // CIRuns is the forge-side record of launched pipelines. quarry does // not model runs — dagnats owns them — this is only the mapping from a // run back to the check-in it was for. type CIRuns interface { CIRuns(repo string, limit int) ([]CIRun, error) CIRunsFor(repo, checkin string) ([]CIRun, error) } // CIRun is one launched pipeline, as the display needs it. type CIRun struct { RunID string `json:"runId"` Checkin string `json:"checkin"` Workflow string `json:"workflow"` Pipeline string `json:"pipeline"` Status string `json:"status"` Started string `json:"started"` } type ciListIn struct { Repo string `json:"repo" validate:"required,max=64"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` } type ciListOut struct { Runs []CIRun `json:"runs"` Count int `json:"count"` } type fileHistoryIn struct { Repo string `json:"repo" validate:"required,max=64"` Version string `json:"version" validate:"max=100"` Path string `json:"path" validate:"required,max=1024"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` } type fileHistoryOut struct { Path string `json:"path"` Version string `json:"version"` Changes []zitcli.FileChange `json:"changes"` Count int `json:"count"` } type archiveIn struct { Repo string `json:"repo" validate:"required,max=64"` Version string `json:"version" validate:"max=100"` } type archiveOut struct { Name string `json:"name"` Size int `json:"size"` // Content is base64: an archive is bytes and JSON has no binary // type. The HTML route decodes and offers it as a download. Content string `json:"content"` } type branchListIn struct { Repo string `json:"repo" validate:"required,max=64"` } type branchListOut struct { Branches []index.Branch `json:"branches"` Count int `json:"count"` } type treeIn struct { Repo string `json:"repo" validate:"required,max=64"` // Version is any name zit resolves — a branch, a tag, "trunk", or a // hash. Empty means the default branch's tip. Version string `json:"version" validate:"max=100"` // Path is the directory being listed; empty is the root. Path string `json:"path" validate:"max=1024"` } // treeEntry is one row of a directory listing. Dir entries are synthesised // from the flat path set zit returns — a store has no directory objects, // so a "directory" is a shared prefix and nothing more. type treeEntry struct { Name string `json:"name"` Path string `json:"path"` Dir bool `json:"dir"` Blob string `json:"blob,omitempty"` Files int `json:"files,omitempty"` // for a directory: what it contains, recursively Size int64 `json:"size,omitempty"` // Exec and Link come from the F card's permission (zit#96). A link's // blob content is its TARGET, so a viewer must not render it as file // content. Exec bool `json:"exec,omitempty"` Link bool `json:"link,omitempty"` Binary bool `json:"binary,omitempty"` } type treeOut struct { Version string `json:"version"` Path string `json:"path"` Entries []treeEntry `json:"entries"` Readme string `json:"readme,omitempty"` // rendered, when this directory has one ReadmeIn string `json:"readmeIn,omitempty"` // which file it came from } 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"` // CI is the recorded verdict, read from the store's tag artifacts // rather than the engine — so it answers even with dagnats down, // and it travels with a clone. CI string `json:"ci,omitempty"` 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[fleetStatusIn, fleetStatusOut]{ Name: "fleet.status", Doc: "Report every served store's ingest health.", Kind: ops.Query, Auth: ops.Admin, Route: "GET /api/v1/fleet", Run: func(_ context.Context, _ fleetStatusIn) (fleetStatusOut, error) { names := make([]string, 0, len(d.Repos)) for name := range d.Repos { names = append(names, name) } sort.Strings(names) out := fleetStatusOut{Count: len(names)} for _, name := range names { repo := d.Repos[name] if repo == nil { continue } row := fleetRepo{Name: name, Code: repo.Code} if repo.Indexing != nil { row.Indexing = repo.Indexing() } if repo.Health != nil { row.Health = repo.Health() } if repo.Index != nil { row.Checkins, _ = repo.Index.CheckinCount() } if row.Health.Failures > 0 { out.Unhealthy++ } out.Repos = append(out.Repos, row) } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[fleetSyncIn, fleetSyncOut]{ Name: "fleet.sync", Doc: "Run an ingest round now — one store, or every store when `repo` is empty.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/fleet/sync", Run: func(_ context.Context, in fleetSyncIn) (fleetSyncOut, error) { names := make([]string, 0, len(d.Repos)) if in.Repo != "" { if _, err := d.repo(in.Repo); err != nil { return fleetSyncOut{}, err } names = append(names, in.Repo) } else { for name := range d.Repos { names = append(names, name) } sort.Strings(names) } var out fleetSyncOut for _, name := range names { repo := d.Repos[name] if repo == nil || repo.Sync == nil { continue } res := fleetSyncResult{Repo: name} n, err := repo.Sync() res.Artifacts = n if err != nil { res.Err = err.Error() out.Failed++ } out.Results = append(out.Results, res) } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[mirrorListIn, mirrorListOut]{ Name: "mirror.list", Doc: "List configured upstream mirrors and their recent history.", Kind: ops.Query, Auth: ops.Admin, Route: "GET /api/v1/mirrors", Run: func(_ context.Context, _ mirrorListIn) (mirrorListOut, error) { if d.Mirrors == nil { return mirrorListOut{}, nil // mirroring unconfigured: an empty list, not an error } ms, err := d.Mirrors.Mirrors() if err != nil { return mirrorListOut{}, err } return mirrorListOut{Mirrors: ms, Count: len(ms)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[mirrorSetIn, mirrorSetOut]{ Name: "mirror.set", Doc: "Configure a store's upstream and pull interval.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/mirrors", Run: func(_ context.Context, in mirrorSetIn) (mirrorSetOut, error) { if d.Mirrors == nil { return mirrorSetOut{}, fmt.Errorf("mirroring is not configured: %w", ops.ErrUnavailable) } // The store must be one quarry serves. Configuring a // mirror for a name that resolves to nothing produces a // row the scheduler will skip forever, which reads as // "mirroring is broken" rather than "that repo is a typo". if _, err := d.repo(in.Repo); err != nil { return mirrorSetOut{}, err } if err := d.Mirrors.SetMirror(in.Repo, in.URL, in.Every); err != nil { return mirrorSetOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err) } m, _, err := d.Mirrors.MirrorFor(in.Repo) if err != nil { return mirrorSetOut{}, err } return mirrorSetOut{Mirror: m}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[mirrorDeleteIn, mirrorDeleteOut]{ Name: "mirror.delete", Doc: "Stop mirroring a store and forget its upstream.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/mirrors/delete", Run: func(_ context.Context, in mirrorDeleteIn) (mirrorDeleteOut, error) { if d.Mirrors == nil { return mirrorDeleteOut{}, fmt.Errorf("mirroring is not configured: %w", ops.ErrUnavailable) } if err := d.Mirrors.DeleteMirror(in.Repo); err != nil { return mirrorDeleteOut{}, err } return mirrorDeleteOut{Repo: in.Repo}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[mirrorRunIn, mirrorRunOut]{ Name: "mirror.run", Doc: "Pull from upstream now — one store, or every configured mirror when `repo` is empty.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/mirrors/run", Run: func(_ context.Context, in mirrorRunIn) (mirrorRunOut, error) { if d.Mirrors == nil { return mirrorRunOut{}, fmt.Errorf("mirroring is not configured: %w", ops.ErrUnavailable) } ms, err := d.Mirrors.Mirrors() if err != nil { return mirrorRunOut{}, err } var out mirrorRunOut for _, m := range ms { if in.Repo != "" && m.Repo != in.Repo { continue } res := mirrorRunResult{Repo: m.Repo} changed, err := d.PullMirror(m) res.Changed = changed if err != nil { res.Err = err.Error() out.Failed++ } else if changed { out.Changed++ } out.Results = append(out.Results, res) } if in.Repo != "" && len(out.Results) == 0 { return out, fmt.Errorf("no mirror configured for %q: %w", in.Repo, ops.ErrNotFound) } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[repoCountsIn, repoCountsOut]{ Name: "repo.counts", Doc: "Count a repository's content by section — what the navigation shows.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/counts", Run: func(_ context.Context, in repoCountsIn) (repoCountsOut, error) { repo, err := d.repo(in.Repo) if err != nil { return repoCountsOut{}, err } c, err := repo.Index.Counts() if err != nil { return repoCountsOut{}, err } return repoCountsOut{ Checkins: c.Checkins, WikiPages: c.WikiPages, Tickets: c.Tickets, ForumThreads: c.ForumThreads, Technotes: c.Technotes, }, 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(ctx 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(ctx, 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[ciListIn, ciListOut]{ Name: "ci.list", Doc: "List a repository's CI runs, newest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/ci", Run: func(_ context.Context, in ciListIn) (ciListOut, error) { if _, err := d.repo(in.Repo); err != nil { return ciListOut{}, err } if d.CI == nil { return ciListOut{}, nil // no engine: an empty list, not an error } runs, err := d.CI.CIRuns(in.Repo, in.Limit) if err != nil { return ciListOut{}, err } return ciListOut{Runs: runs, Count: len(runs)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[fileHistoryIn, fileHistoryOut]{ Name: "file.history", Doc: "List the check-ins that changed one file, newest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/history/{version}/{path...}", Run: func(ctx context.Context, in fileHistoryIn) (fileHistoryOut, error) { repo, err := d.repo(in.Repo) if err != nil { return fileHistoryOut{}, err } version := d.resolveVersion(repo, in.Version) // zit answers this directly (zit#93); quarry has no index of // per-file history and building one would duplicate the DAG. changes, err := repo.Zit.FileHistory(ctx, version, in.Path, in.Limit) if err != nil { return fileHistoryOut{}, err } return fileHistoryOut{Path: in.Path, Version: version, Changes: changes, Count: len(changes)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[archiveIn, archiveOut]{ Name: "archive.get", Doc: "Download a check-in's files as a reproducible gzip tar archive.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/archive/{version}", Run: func(ctx context.Context, in archiveIn) (archiveOut, error) { repo, err := d.repo(in.Repo) if err != nil { return archiveOut{}, err } version := d.resolveVersion(repo, in.Version) // Resolve to the check-in a name currently means, so the // archive's directory and filename say WHICH commit this is // rather than "trunk", which will mean something else // tomorrow. short := version if b, ok, err := repo.Index.TipOf(version); err == nil && ok { short = b.Tip[:min(12, len(b.Tip))] version = b.Tip } else if len(short) > 12 { short = short[:12] } name := in.Repo + "-" + short raw, err := repo.Zit.Tarball(ctx, version, name, maxArchiveBytes) if err != nil { // BLOCKED on zit#97: `zit tarball` ignores -R and demands // a checkout in the working directory, which a server // process never has. The route is correct and stays; // it starts answering the moment zit honours the flag. log.Printf("archive %s@%s: %v", in.Repo, version, err) return archiveOut{}, fmt.Errorf("%w: %v", ops.ErrUnavailable, err) } return archiveOut{Name: name + ".tar.gz", Size: len(raw), Content: base64.StdEncoding.EncodeToString(raw)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[branchListIn, branchListOut]{ Name: "branch.list", Doc: "List branches with their tip check-in, most recently active first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/branch", Run: func(ctx context.Context, in branchListIn) (branchListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return branchListOut{}, err } bs, err := repo.Index.Branches() if err != nil { return branchListOut{}, err } return branchListOut{Branches: bs, Count: len(bs)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[treeIn, treeOut]{ Name: "tree.list", Doc: "List one directory of a check-in, with the directory's README rendered.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/tree/{version}/{path...}", Run: func(ctx context.Context, in treeIn) (treeOut, error) { repo, err := d.repo(in.Repo) if err != nil { return treeOut{}, err } version := d.resolveVersion(repo, in.Version) // Resolve a moving name to the check-in it currently means. // Two reasons: the listing is then cacheable (a hash cannot // move, "trunk" can), and the page's links carry the exact // check-in a reader was looking at rather than whatever is // newest by the time they click. if b, ok, err := repo.Index.TipOf(version); err == nil && ok { version = b.Tip } prefix := strings.Trim(in.Path, "/") // --under asks zit for this subtree only, so drawing one // directory no longer materialises every path in the // repository (zit#96). files, err := repo.Zit.LsUnder(ctx, version, prefix) if err != nil { return treeOut{}, fmt.Errorf("%w: %v", ops.ErrNotFound, err) } if prefix != "" { prefix += "/" } // A store keeps no directories, only paths. One level of the // tree is therefore the distinct next segment of every path // under this prefix — files carry their blob, directories // carry how much lives beneath them. dirs := map[string]int{} var entries []treeEntry for _, f := range files { if !strings.HasPrefix(f.Name, prefix) { continue } rest := f.Name[len(prefix):] if i := strings.IndexByte(rest, '/'); i >= 0 { dirs[rest[:i]]++ continue } entries = append(entries, treeEntry{ Name: rest, Path: prefix + rest, Blob: f.Blob, Size: f.Size, Exec: f.Executable(), Link: f.Symlink(), Binary: f.Binary, }) } for name, n := range dirs { entries = append(entries, treeEntry{ Name: name, Path: prefix + name, Dir: true, Files: n, }) } // Directories first, then names — the ordering every file // browser uses, because a tree read alphabetically with files // interleaved is hard to scan. sort.Slice(entries, func(i, j int) bool { if entries[i].Dir != entries[j].Dir { return entries[i].Dir } return entries[i].Name < entries[j].Name }) out := treeOut{Version: version, Path: strings.Trim(in.Path, "/"), Entries: entries} // The README of THIS directory, rendered — what a forge shows // under the listing. for _, e := range entries { if e.Dir || !isReadme(e.Name) { continue } body, err := repo.Zit.Cat(ctx, e.Path, version) if err != nil { break // unreadable README is not a failed listing } html, err := renderBody(repo.Index, in.Repo, mimetypeOf(e.Name), body) if err != nil { break } out.Readme, out.ReadmeIn = html, e.Name break } return out, 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(ctx 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(ctx 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(ctx, 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(ctx, 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(ctx context.Context, in fileGetIn) (fileGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return fileGetOut{}, err } version := d.resolveVersion(repo, in.Hash) raw, err := repo.Zit.Cat(ctx, 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(ctx 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) } entries := []index.TimelineEntry{e} if err := repo.Index.AttachCI(entries); err != nil { return checkinGetOut{}, err } out := checkinGetOut{ Hash: e.Hash, CI: entries[0].CI, 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(ctx, 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 // isReadme matches the names a forge renders under a directory listing. // Case-insensitive because repositories are inconsistent about it and a // reader does not care. func isReadme(name string) bool { lower := strings.ToLower(name) // The set fossil's browse.c recognizes, minus readme.html — rendering // repository HTML is its own decision, tracked separately. for _, stem := range []string{"readme.md", "readme.markdown", "readme.wiki", "readme.txt", "readme"} { if lower == stem { return true } } return false } // mimetypeOf maps a filename to the markup dispatch renderBody expects. // Markup is chosen by EXTENSION, and a name without a recognized one is // plain text. An earlier version of this guessed markdown for a bare // README "by convention", which is a GitHub-era convention that predates // nothing in a Fossil corpus: libspatialite's README is an autotools // plain-text file whose shell commands are prefixed with the root prompt // `#`, and markdown reads `# sudo make install` as a heading. Rendering // it as markdown turned its build instructions into a dozen <h2>s and // collapsed every hard line break in the command listings. // // This is fossil's rule (wiki_render_by_mimetype): known markup types // render, everything else falls through to escaped <pre class=textPlain> // with its line structure intact. GitHub does the same — a bare README // is served as plain text there too. func mimetypeOf(name string) string { switch { case hasExt(name, ".md", ".markdown"): return "text/x-markdown" case hasExt(name, ".wiki"): return "text/x-fossil-wiki" case hasExt(name, ".pikchr"): return "text/x-pikchr" default: return "text/plain" } } func hasExt(name string, exts ...string) bool { lower := strings.ToLower(name) for _, e := range exts { if strings.HasSuffix(lower, e) { return true } } return false } // maxArchiveBytes caps an archive quarry will build. zit writes the // tarball to a file, so quarry buffers it to answer — this is the memory // one anonymous request can ask for. A tree beyond it is refused rather // than half-served. const maxArchiveBytes = 256 << 20