Artifact 64c825958f9565e45ab11ca9317436414d96ccaa13bc89237f6f7ce5f2728725:
- File
internal/api/register.go
— part of check-in
[4a3165db34]
at
2026-08-18 11:17:05
on branch trunk
— repo.list: filter, sort and page the listing
The front page rendered every repository unsorted and unbounded — the same shape that made the timeline unusable, and it is at 23 now. Adds a name filter, sort by name, recent activity or size, and a limit, with Total alongside Count so a page can say '5 of 23'.
Sorting happens BEFORE the limit. The other order gives an arbitrary subset in the right sequence, which reads as correct and is not.
Activity comes from the event table's index — one walk stopping at the first row — so the column that makes a forge listing worth reading costs nothing.
Adds repo.get, so the settings page and the CLI stop scanning the whole listing to read one repository's settings. (user: dmestas, size: 98627) [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/work" "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 *Repos 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 // CanRead gates reading a repository at all. nil means every // repository is public, which is v1's behaviour and what tests want. // // It takes a context because the answer depends on WHO is asking, // and the identity rides there. Returning false must make the // repository indistinguishable from one that does not exist — see // Deps.repoFor. CanRead func(ctx context.Context, repo string) bool // Accounts is forge identity: who exists and what they may do. nil // disables the account ops, which is how a forge with no accounts // configured simply has no account surface. Accounts AccountStore // Private reports which repositories are private, for the listing's // badge. Separate from CanRead, which answers a different question: // this is "is it marked private", that is "may you see it". Private func() map[string]bool // Descriptions supplies each repository's one-line summary for the // listing; nil simply renders none. Descriptions func() map[string]string // Registry adds and removes served repositories at runtime; nil // leaves the fleet fixed at whatever --repo named, which is the old // behaviour and what tests want. Registry RepoRegistry // Bulk bounds operations expensive in MEMORY as well as time. // archive buffers up to 256 MiB per request on an ANONYMOUS route, // so ten concurrent requests would be 2.5 GiB resident and ten // subprocesses. That is latent rather than live today — archive 503s // on zit#97, so nothing is buffered yet — which is exactly why the // bound belongs here now: the endpoint should be born bounded rather // than acquire a limit after someone finds it. // // nil runs unbounded, which is the old behaviour and suits tests. Bulk *work.Lane // 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 } // AccountStore is forge identity. Stated in this package's own types so // the ops layer does not depend on the OLTP package's, matching how // CIRuns and MirrorStore are stated. // // The password VERIFIER never appears here in any direction. Passwords // go in; nothing that could verify one comes back. type AccountStore interface { Accounts() ([]Account, error) AddAccount(name, password string, admin bool) error SetPassword(name, password string) error DeleteAccount(name string) error Grant(account, repo, role string) error Revoke(account, repo string) error GrantsFor(account string) (map[string]string, error) } // Account is one login as the admin surface sees it: never the verifier. type Account struct { Name string `json:"name"` Admin bool `json:"admin"` Created string `json:"created"` } // RepoRegistry is the seam for changing which repositories are served. // Stated here rather than as the composition root's concrete type for // the same reason MirrorStore is: the op layer names what it needs, and // main owns how a repository is actually opened and torn down. // // Add must be atomic in effect — a repository is serving and persisted, // or neither. A registration that starts serving but is not recorded // disappears on the next restart, which is the worst of both. type RepoRegistry interface { // Add returns where the repository was placed, which the caller may // have left to the server. Add(spec RepoSpec) (path string, err error) Remove(name string) error Update(name, description, visibility string) error } // RepoSpec says which repository to serve and where it comes from. // Exactly one origin applies: an existing store at Path, a new empty one // (Create), or a clone of Clone. A struct rather than positional // arguments because this list has grown twice already. type RepoSpec struct { Name string // Path may be empty, in which case the node puts the repository // under its repositories root. Nobody picks a filesystem path when // they create a repository on a forge, and requiring one made the // API unusable by anyone who was not also the server's operator. Path string // Description is the one line the repository list shows. Description string // Visibility is "public" or "private"; empty means public. Visibility string // Create runs `zit init` at Path first — the "host my project here" // case, which quarry could not do at all. Create bool // Clone copies the repository served at this URL into Path. quarry // also records a mirror for it unless Mirror is false: adopting a // remote repository and then not tracking it is almost never what // anyone meant. Clone string Mirror bool // MirrorEvery is the pull interval in seconds; zero takes the // default. MirrorEvery int } // 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 } // visibilityOf turns the API's boolean into the stored word. func visibilityOf(private bool) string { if private { return "private" } return "public" } func errNoAccounts() error { return fmt.Errorf("this node has no account store: %w", ops.ErrUnavailable) } // scrub removes a secret from an error's text before it travels. // // The store's errors quote what they were given, and for these two ops // what they were given is a password. An error is the one value that // reliably reaches a log, an HTTP body and a terminal, so it is the one // place a secret must not be. func scrub(err error, secret string) string { msg := err.Error() if secret == "" { return msg } return strings.ReplaceAll(msg, secret, "[redacted]") } // 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 without a visibility check. Use it // only where the caller has already been gated, or where no reader is // involved (the mirror scheduler, CI). func (d Deps) repo(name string) (*Repo, error) { if r, ok := d.Repos.Get(name); ok { return r, nil } return nil, fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound) } // repoFor resolves a store for a READER, and is what every request-path // op should use. // // A repository the caller may not see reports NOT FOUND, never // forbidden. A 403 confirms the repository exists, which is precisely // what a private repository must not confirm: "no such repository" and // "you may not see this repository" have to be one answer, or the // existence of a name leaks to anyone who can spell it. func (d Deps) repoFor(ctx context.Context, name string) (*Repo, error) { r, err := d.repo(name) if err != nil { return nil, err } if d.CanRead != nil && !d.CanRead(ctx, name) { return nil, fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound) } return r, nil } type versionIn struct{} type versionOut struct { Version string `json:"version"` } // repoListIn filters and orders the listing. A forge's front page is a // listing, and an unfiltered, unsorted, unbounded one is the shape that // made the timeline unusable — render everything, sort nothing. type repoListIn struct { // Q filters by name substring, case-insensitive. Q string `json:"q" validate:"max=64"` // Sort is active (most recent event first), name, or size. Sort string `json:"sort" validate:"oneof=active|name|size"` // Limit caps the page; 0 means no limit, which is what internal // callers and a small fleet want. Limit int `json:"limit" validate:"min=0,max=500"` } type repoGetIn struct { Repo string `json:"repo" validate:"required,max=64"` } type repoInfo struct { Name string `json:"name"` // LastActive is the newest event's date, empty when the repository // has none. It is what "sort by activity" orders on and what a // reader actually scans a forge listing for. LastActive string `json:"lastActive"` Description string `json:"description"` Private bool `json:"private"` 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 is how many this page holds; Total how many matched the // filter before the limit. Both, because "23 of 140" is the thing a // reader needs and one number cannot say it. Count int `json:"count"` Total int `json:"total"` } 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 accountListIn struct{} // accountGrant is one repository grant, for the listing. type accountGrant struct { Repo string `json:"repo"` Role string `json:"role"` } type accountRow struct { Account Grants []accountGrant `json:"grants"` } type accountListOut struct { Accounts []accountRow `json:"accounts"` Count int `json:"count"` } // accountAddIn carries a plaintext password, which makes this one of the // two ops that must never be logged with its input. The ops layer's // error path logs context; these must not hand it one. type accountAddIn struct { Name string `json:"name" validate:"required,max=64"` Password string `json:"password" validate:"required,max=1024"` Admin bool `json:"admin"` } type accountAddOut struct { Name string `json:"name"` Admin bool `json:"admin"` } type accountPasswdIn struct { Name string `json:"name" validate:"required,max=64"` Password string `json:"password" validate:"required,max=1024"` } type accountDeleteIn struct { Name string `json:"name" validate:"required,max=64"` } type accountNameOut struct { Name string `json:"name"` } type accountGrantIn struct { Name string `json:"name" validate:"required,max=64"` Repo string `json:"repo" validate:"required,max=64"` Role string `json:"role" validate:"required,oneof=read|write|admin"` } type accountGrantOut struct { Name string `json:"name"` Repo string `json:"repo"` Role string `json:"role"` } type accountRevokeIn struct { Name string `json:"name" validate:"required,max=64"` Repo string `json:"repo" validate:"required,max=64"` } type repoAddIn struct { Name string `json:"name" validate:"required,max=64"` // Path is optional: omitted, the server places the repository under // its repositories root. Path string `json:"path" validate:"max=4096"` Description string `json:"description" validate:"max=300"` // Private creates the repository unreadable to anyone without a role // on it. Absent means public — the safe default is the one that does // not surprise, and hiding something by accident is recoverable // while publishing it by accident is not... but a forge whose repos // silently vanish is worse, so public it is, and taking a repository // private stays the deliberate act. Private bool `json:"private"` // Create makes an empty repository at path; Clone copies one from a // URL. Both are optional and mutually exclusive; with neither, path // must already hold a repository. Create bool `json:"create"` Clone string `json:"clone" validate:"max=2048"` // NoMirror suppresses the mirror a clone would otherwise record. // Negative because tracking what you just adopted is the default. NoMirror bool `json:"noMirror"` MirrorEvery int `json:"mirrorEvery" validate:"min=0,max=604800"` } type repoAddOut struct { Name string `json:"name"` // Path is where the server actually placed it, which the caller may // not have chosen. Path string `json:"path"` // Mirrored reports whether a mirror was recorded, so the caller does // not have to infer it from the flags it sent. Mirrored bool `json:"mirrored"` } // repoUpdateIn is the repository's settings, applied WHOLE. A settings // form submits every field it manages, so partial-update semantics // would only add a way for the two to disagree about what "absent" // means. type repoUpdateIn struct { Name string `json:"name" validate:"required,max=64"` Description string `json:"description" validate:"max=300"` Private bool `json:"private"` } type repoUpdateOut struct { Name string `json:"name"` Description string `json:"description"` Private bool `json:"private"` } type repoRemoveIn struct { Name string `json:"name" validate:"required,max=64"` } type repoRemoveOut struct { Name string `json:"name"` } 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(ctx 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.repoFor(ctx, 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.repoFor(ctx, 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.repoFor(ctx, 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(ctx 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(ctx context.Context, in repoListIn) (repoListOut, error) { // Filtered by visibility: a listing that names a private // repository has leaked it, whatever the page then refuses // to show. Name leakage is the usual way this is got wrong. needle := strings.ToLower(in.Q) var names []string for _, name := range d.Repos.Names() { if d.CanRead != nil && !d.CanRead(ctx, name) { continue } if needle != "" && !strings.Contains(strings.ToLower(name), needle) { continue } names = append(names, name) } var desc map[string]string if d.Descriptions != nil { desc = d.Descriptions() } var priv map[string]bool if d.Private != nil { priv = d.Private() } rows := make([]repoInfo, 0, len(names)) for _, name := range names { r, ok := d.Repos.Get(name) if !ok { continue } info := repoInfo{Name: name, Description: desc[name], Private: priv[name]} if r.Indexing != nil { info.Indexing = r.Indexing() } if ix := r.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 } if last, ok, err := ix.LastEvent(); err == nil && ok { info.LastActive = last } } rows = append(rows, info) } // Sorted BEFORE the limit, or a page is an arbitrary subset // in the right order — which reads as correct and is not. switch in.Sort { case "size": sort.SliceStable(rows, func(i, j int) bool { return rows[i].Checkins > rows[j].Checkins }) case "active": sort.SliceStable(rows, func(i, j int) bool { return rows[i].LastActive > rows[j].LastActive }) } out := repoListOut{Total: len(rows)} if in.Limit > 0 && len(rows) > in.Limit { rows = rows[:in.Limit] } out.Repos, out.Count = rows, len(rows) 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(ctx context.Context, _ fleetStatusIn) (fleetStatusOut, error) { names := d.Repos.Names() out := fleetStatusOut{Count: len(names)} for _, name := range names { repo, ok := d.Repos.Get(name) if !ok || 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(ctx context.Context, in fleetSyncIn) (fleetSyncOut, error) { var names []string if in.Repo != "" { if _, err := d.repoFor(ctx, in.Repo); err != nil { return fleetSyncOut{}, err } names = append(names, in.Repo) } else { names = d.Repos.Names() } var out fleetSyncOut for _, name := range names { repo, ok := d.Repos.Get(name) if !ok || 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(ctx 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(ctx 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.repoFor(ctx, 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(ctx 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(ctx 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[accountListIn, accountListOut]{ Name: "account.list", Doc: "List forge accounts and their per-repository grants.", Kind: ops.Query, Auth: ops.Admin, Route: "GET /api/v1/accounts", Run: func(ctx context.Context, _ accountListIn) (accountListOut, error) { if d.Accounts == nil { return accountListOut{}, errNoAccounts() } accts, err := d.Accounts.Accounts() if err != nil { return accountListOut{}, err } out := accountListOut{Count: len(accts)} for _, a := range accts { row := accountRow{Account: a} grants, err := d.Accounts.GrantsFor(a.Name) if err != nil { return accountListOut{}, err } repos := make([]string, 0, len(grants)) for repo := range grants { repos = append(repos, repo) } sort.Strings(repos) for _, repo := range repos { row.Grants = append(row.Grants, accountGrant{Repo: repo, Role: grants[repo]}) } out.Accounts = append(out.Accounts, row) } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[accountAddIn, accountAddOut]{ Name: "account.add", Doc: "Create a forge account. The password is hashed with argon2id and never stored or returned in plaintext.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/accounts", Run: func(ctx context.Context, in accountAddIn) (accountAddOut, error) { if d.Accounts == nil { return accountAddOut{}, errNoAccounts() } if err := d.Accounts.AddAccount(in.Name, in.Password, in.Admin); err != nil { // Scrubbed deliberately: the store's error can quote what // it was given, and what it was given is a password. return accountAddOut{}, fmt.Errorf("%w: %s", ops.ErrInvalid, scrub(err, in.Password)) } return accountAddOut{Name: in.Name, Admin: in.Admin}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[accountPasswdIn, accountNameOut]{ Name: "account.passwd", Doc: "Set an account's password.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/accounts/passwd", Run: func(ctx context.Context, in accountPasswdIn) (accountNameOut, error) { if d.Accounts == nil { return accountNameOut{}, errNoAccounts() } if err := d.Accounts.SetPassword(in.Name, in.Password); err != nil { return accountNameOut{}, fmt.Errorf("%w: %s", ops.ErrInvalid, scrub(err, in.Password)) } return accountNameOut{Name: in.Name}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[accountDeleteIn, accountNameOut]{ Name: "account.delete", Doc: "Delete an account and all of its grants.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/accounts/delete", Run: func(ctx context.Context, in accountDeleteIn) (accountNameOut, error) { if d.Accounts == nil { return accountNameOut{}, errNoAccounts() } if err := d.Accounts.DeleteAccount(in.Name); err != nil { return accountNameOut{}, err } return accountNameOut{Name: in.Name}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[accountGrantIn, accountGrantOut]{ Name: "account.grant", Doc: "Grant an account read, write or admin on one repository.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/accounts/grant", Run: func(ctx context.Context, in accountGrantIn) (accountGrantOut, error) { if d.Accounts == nil { return accountGrantOut{}, errNoAccounts() } // The repository must be one quarry serves. Granting against // a name that resolves to nothing produces a row that looks // like access and grants none. if _, err := d.repoFor(ctx, in.Repo); err != nil { return accountGrantOut{}, err } if err := d.Accounts.Grant(in.Name, in.Repo, in.Role); err != nil { return accountGrantOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err) } return accountGrantOut{Name: in.Name, Repo: in.Repo, Role: in.Role}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[accountRevokeIn, accountGrantOut]{ Name: "account.revoke", Doc: "Remove an account's grant on one repository.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/accounts/revoke", Run: func(ctx context.Context, in accountRevokeIn) (accountGrantOut, error) { if d.Accounts == nil { return accountGrantOut{}, errNoAccounts() } if err := d.Accounts.Revoke(in.Name, in.Repo); err != nil { return accountGrantOut{}, err } return accountGrantOut{Name: in.Name, Repo: in.Repo}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[repoGetIn, repoInfo]{ Name: "repo.get", Doc: "Describe one repository: its settings and headline counts.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/info", Run: func(ctx context.Context, in repoGetIn) (repoInfo, error) { repo, err := d.repoFor(ctx, in.Repo) if err != nil { return repoInfo{}, err } info := repoInfo{Name: in.Repo} if d.Descriptions != nil { info.Description = d.Descriptions()[in.Repo] } if d.Private != nil { info.Private = d.Private()[in.Repo] } if repo.Indexing != nil { info.Indexing = repo.Indexing() } if ix := repo.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 } if last, ok, err := ix.LastEvent(); err == nil && ok { info.LastActive = last } } return info, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[repoAddIn, repoAddOut]{ Name: "repo.add", Doc: "Serve a repository: an existing store at `path`, a new empty one (`create`), or a clone of `clone`. Cloning also records a mirror unless `noMirror`.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/repos", Run: func(ctx context.Context, in repoAddIn) (repoAddOut, error) { if d.Registry == nil { return repoAddOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable) } if in.Create && in.Clone != "" { return repoAddOut{}, fmt.Errorf( "create and clone are alternatives, not both: %w", ops.ErrInvalid) } spec := RepoSpec{ Name: in.Name, Path: in.Path, Description: in.Description, Visibility: visibilityOf(in.Private), Create: in.Create, Clone: in.Clone, Mirror: in.Clone != "" && !in.NoMirror, MirrorEvery: in.MirrorEvery, } path, err := d.Registry.Add(spec) if err != nil { return repoAddOut{}, err } return repoAddOut{Name: in.Name, Path: path, Mirrored: spec.Mirror}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[repoUpdateIn, repoUpdateOut]{ Name: "repo.update", Doc: "Set a repository's settings: description and visibility. Fields are applied whole.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/repos/update", Run: func(ctx context.Context, in repoUpdateIn) (repoUpdateOut, error) { if d.Registry == nil { return repoUpdateOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable) } if _, err := d.repoFor(ctx, in.Name); err != nil { return repoUpdateOut{}, err } if err := d.Registry.Update(in.Name, in.Description, visibilityOf(in.Private)); err != nil { return repoUpdateOut{}, err } return repoUpdateOut{Name: in.Name, Description: in.Description, Private: in.Private}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[repoRemoveIn, repoRemoveOut]{ Name: "repo.remove", Doc: "Stop serving a repository. The store on disk is left untouched.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/repos/remove", Run: func(ctx context.Context, in repoRemoveIn) (repoRemoveOut, error) { if d.Registry == nil { return repoRemoveOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable) } if err := d.Registry.Remove(in.Name); err != nil { return repoRemoveOut{}, err } return repoRemoveOut{Name: in.Name}, 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(ctx context.Context, in repoCountsIn) (repoCountsOut, error) { repo, err := d.repoFor(ctx, 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(ctx context.Context, in timelineIn) (timelineOut, error) { repo, err := d.repoFor(ctx, 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(ctx context.Context, in wikiListIn) (wikiListOut, error) { repo, err := d.repoFor(ctx, 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(ctx context.Context, in ticketListIn) (ticketListOut, error) { repo, err := d.repoFor(ctx, 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(ctx context.Context, in ticketGetIn) (ticketGetOut, error) { repo, err := d.repoFor(ctx, 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.repoFor(ctx, 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(ctx context.Context, in ciListIn) (ciListOut, error) { if _, err := d.repoFor(ctx, 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.repoFor(ctx, 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.repoFor(ctx, 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 // Bounded: this buffers the whole archive to answer, so the // memory cost is per CONCURRENT request, not per request. var raw []byte err = d.Bulk.Do(ctx, func() error { var terr error raw, terr = repo.Zit.Tarball(ctx, version, name, maxArchiveBytes) return terr }) if errors.Is(err, work.ErrBusy) { return archiveOut{}, fmt.Errorf("archive queue is full, retry shortly: %w", ops.ErrUnavailable) } 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.repoFor(ctx, 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.repoFor(ctx, 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.repoFor(ctx, 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.repoFor(ctx, 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.repoFor(ctx, 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.repoFor(ctx, 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(ctx context.Context, in wikiHistoryIn) (wikiHistoryOut, error) { repo, err := d.repoFor(ctx, 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(ctx context.Context, in wikiGetIn) (wikiGetOut, error) { repo, err := d.repoFor(ctx, 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(ctx context.Context, in technoteListIn) (technoteListOut, error) { repo, err := d.repoFor(ctx, 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(ctx context.Context, in technoteGetIn) (technoteGetOut, error) { repo, err := d.repoFor(ctx, 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(ctx context.Context, in forumListIn) (forumListOut, error) { repo, err := d.repoFor(ctx, 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(ctx context.Context, in forumGetIn) (forumGetOut, error) { repo, err := d.repoFor(ctx, 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.repoFor(ctx, 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.repoFor(ctx, 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.repoFor(ctx, 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(ctx context.Context, in chatHistoryIn) (chatHistoryOut, error) { repo, err := d.repoFor(ctx, 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.repoFor(ctx, 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