Skip to content
quarry Artifact [45d138823d]
Login

Artifact [45d138823d]

Artifact 45d138823db2d3316763a27260c57825d227547ab9d4258693dadd840accd123:


// uv.go is RFC 0020's pages: a repository's unversioned files, listed
// with fossil's columns and published or removed by a holder of y, the
// download route that redirects to the bucket, and the releases page
// that reads releases/<name>/ as one release per folder.
//
// No page here renders a file's content. A download is a redirect to
// the bucket, and the releases page reads a manifest's fields through
// the op, never its text.
package web

import (
	"context"
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"sort"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/uv"
)

// uvView is the listing page.
type uvView struct {
	Bound bool
	// CanWrite is y on a repository with a bucket; CanBind is a, for
	// the pointer to the bucket settings.
	CanWrite bool
	CanBind  bool
	Limit    string
	Sort     string
	Error    string
	Done     string
	Rows     []uvRow
	Removed  []uvRow
}

type uvRow struct {
	Name  string
	URL   string
	Size  string
	Hash  string
	Mtime int64
	// When is the mtime as the page's clock reads it.
	When string
	Who  string
}

// uvURL addresses one file under the repository's base URL: each
// segment escaped, the slashes kept, so the link reads as the name.
func uvURL(base, name string) string {
	segs := strings.Split(name, "/")
	for i, s := range segs {
		segs[i] = url.PathEscape(s)
	}
	return base + "/uv/" + strings.Join(segs, "/")
}

func uvRowOf(base string, f uv.File) uvRow {
	return uvRow{Name: f.Name, URL: uvURL(base, f.Name), Size: byteSize(int(f.Size)), Hash: f.Hash,
		Mtime: f.Mtime, When: time.Unix(f.Mtime, 0).UTC().Format(time.RFC3339), Who: f.Who}
}

// sortUV orders rows by name, or newest first for "age", as fossil's
// /uvlist does with byage=1.
func sortUV(rows []uvRow, by string) {
	sort.SliceStable(rows, func(i, j int) bool {
		if by == "age" && rows[i].Mtime != rows[j].Mtime {
			return rows[i].Mtime > rows[j].Mtime
		}
		return rows[i].Name < rows[j].Name
	})
}

// releasesView is the releases page, release.list as drawn.
type releasesView struct {
	Bound   bool
	CanBind bool
	Groups  []releaseGroup
}

type releaseGroup struct {
	Name   string
	Newest bool
	// Link is the release's own page; Single says the group is drawn
	// as that page rather than as an entry of the list.
	Link        string
	Single      bool
	CheckinURL  string
	When        string
	Files       []uvRow
	Sums        string
	Manifest    *releaseManifestView
	ManifestErr string
	Notes       *releaseNotesView
}

type releaseManifestView struct {
	Version       string
	Date          string
	Checkin       string
	Tag           string
	Artifacts     []releaseArtifactView
	Source        *releaseArtifactView
	Omitted       []releaseOmittedView
	Qualification []releaseQualificationView
}

type releaseArtifactView struct {
	Name          string
	Target        string
	Size          string
	SHA256        string
	Qualification string
	URL           string
	// SumsSHA256 is SHA256SUMS's digest for the file, set only when it
	// differs from the manifest's.
	SumsSHA256 string
}

type releaseOmittedView struct {
	Target string `json:"target"`
	Reason string `json:"reason"`
}

type releaseQualificationView struct {
	Target string
	Notes  []string
}

type releaseNotesView struct {
	Heading string
	HTML    template.HTML
}

// releaseListWire mirrors the release.list op.
type releaseListWire struct {
	Bound    bool `json:"bound"`
	Releases []struct {
		Name     string    `json:"name"`
		Newest   bool      `json:"newest"`
		Mtime    int64     `json:"mtime"`
		Files    []uv.File `json:"files"`
		Sums     string    `json:"sums"`
		Manifest *struct {
			Version       string                     `json:"version"`
			Date          string                     `json:"date"`
			Checkin       string                     `json:"checkin"`
			Tag           string                     `json:"tag"`
			Artifacts     []releaseArtifactWire      `json:"artifacts"`
			Source        *releaseArtifactWire       `json:"source"`
			Omitted       []releaseOmittedView       `json:"omitted"`
			Qualification []releaseQualificationView `json:"qualification"`
		} `json:"manifest"`
		ManifestErr string `json:"manifestErr"`
		Notes       *struct {
			Heading string `json:"heading"`
			HTML    string `json:"html"`
		} `json:"notes"`
	} `json:"releases"`
}

type releaseArtifactWire struct {
	Name          string `json:"name"`
	Target        string `json:"target"`
	Size          int64  `json:"size"`
	SHA256        string `json:"sha256"`
	Qualification string `json:"qualification"`
	File          string `json:"file"`
	SumsSHA256    string `json:"sumsSha256"`
}

func (a releaseArtifactWire) view(base string) releaseArtifactView {
	v := releaseArtifactView{Name: a.Name, Target: a.Target, SHA256: a.SHA256, Qualification: a.Qualification, SumsSHA256: a.SumsSHA256}
	if a.Size > 0 {
		v.Size = byteSize(int(a.Size))
	}
	if a.File != "" {
		v.URL = uvURL(base, a.File)
	}
	return v
}

// releaseSummary mirrors the release.summary op.
type releaseSummary struct {
	Count  int    `json:"count"`
	Latest string `json:"latest"`
	Mtime  int64  `json:"mtime"`
}

// releasesBox is the Files page's Releases block, GitHub's sidebar
// entry: how many, and the newest, from the catalog alone.
type releasesBox struct {
	Count  int
	Latest string
	When   string
	Link   string
	All    string
}

// releasesBoxFor is the block for a repository with releases, nil for
// one without or when the catalog cannot say.
func releasesBoxFor(r *ops.Registry, req *http.Request, repo string) *releasesBox {
	out, err := invoke(req.Context(), r, "release.summary", url.Values{"repo": {repo}})
	if err != nil {
		return nil
	}
	var sum releaseSummary
	remarshal(out, &sum)
	if sum.Count == 0 {
		return nil
	}
	base := hooksOf(req.Context()).repoURL(repo)
	return &releasesBox{Count: sum.Count, Latest: sum.Latest, Link: releaseURL(base, sum.Latest),
		All: base + "/releases", When: time.Unix(sum.Mtime, 0).UTC().Format(time.RFC3339)}
}

// releaseURL is one release's page.
func releaseURL(base, name string) string { return base + "/releases/" + url.PathEscape(name) }

func releasesViewOf(base string, w releaseListWire) *releasesView {
	v := &releasesView{Bound: w.Bound}
	for _, r := range w.Releases {
		g := releaseGroup{Name: r.Name, Newest: r.Newest, ManifestErr: r.ManifestErr,
			Link: releaseURL(base, r.Name), When: time.Unix(r.Mtime, 0).UTC().Format(time.RFC3339)}
		for _, f := range r.Files {
			row := uvRowOf(base, f)
			row.Name = strings.TrimPrefix(f.Name, "releases/"+r.Name+"/")
			g.Files = append(g.Files, row)
		}
		if r.Sums != "" {
			g.Sums = uvURL(base, r.Sums)
		}
		if m := r.Manifest; m != nil {
			mv := &releaseManifestView{Version: m.Version, Date: m.Date, Checkin: m.Checkin, Tag: m.Tag,
				Omitted: m.Omitted, Qualification: m.Qualification}
			for _, a := range m.Artifacts {
				mv.Artifacts = append(mv.Artifacts, a.view(base))
			}
			if m.Source != nil {
				s := m.Source.view(base)
				mv.Source = &s
			}
			g.Manifest = mv
			if m.Checkin != "" {
				// The op keeps only a hash here, so this names a check-in.
				g.CheckinURL = base + "/checkin/" + url.PathEscape(m.Checkin)
			}
		}
		if r.Notes != nil {
			// Rendered and sanitized by the op, the way changelog.get
			// renders the forge's own.
			g.Notes = &releaseNotesView{Heading: r.Notes.Heading, HTML: template.HTML(r.Notes.HTML)}
		}
		v.Groups = append(v.Groups, g)
	}
	return v
}

// UVUploadFunc is the page's upload form carried into uv.put, which
// the composition root builds from the api layer's UVUploadForm. It
// answers the name published.
type UVUploadFunc func(ctx context.Context, repo string, w http.ResponseWriter, req *http.Request) (string, error)

// WithUVUpload attaches the unversioned upload form's path.
func WithUVUpload(fn UVUploadFunc) func(*handlerOpts) {
	return func(o *handlerOpts) { o.uvUpload = fn }
}

// uvFlash carries what a write did back to the listing in a flash
// cookie the server set, never in the URL, where anyone could word a
// link that shows their own sentence as this forge's. A refusal is
// marked so the page draws it as one.
func uvFlash(w http.ResponseWriter, req *http.Request, words string, refused bool) {
	if refused {
		words = "!" + words
	}
	setFlash(w, req, url.QueryEscape(words))
}

func uvTakeFlash(w http.ResponseWriter, req *http.Request) (done, refused string) {
	words, err := url.QueryUnescape(takeFlash(w, req))
	if err != nil || words == "" {
		return "", ""
	}
	if r, ok := strings.CutPrefix(words, "!"); ok {
		return "", r
	}
	return words, ""
}

func uvRoutes(mux *http.ServeMux, r *ops.Registry, caps Caps, verify Verify, sessions Sessions, upload UVUploadFunc) {
	list := func(req *http.Request, repo string) (files []uv.File, bound bool, limit int64, err error) {
		out, err := invoke(req.Context(), r, "uv.list", url.Values{"repo": {repo}})
		if err != nil {
			return nil, false, 0, err
		}
		var l struct {
			Files []uv.File `json:"files"`
			Bound bool      `json:"bound"`
			Limit int64     `json:"limit"`
		}
		remarshal(out, &l)
		return l.Files, l.Bound, l.Limit, nil
	}
	mux.HandleFunc("GET /r/{repo}/uv", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		base := hooksOf(req.Context()).repoURL(repo)
		files, bound, limit, err := list(req, repo)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		q := req.URL.Query()
		v := &uvView{Bound: bound, Sort: "name", CanBind: mayCap(caps, verify, sessions, repo, "a", ops.Admin, req)}
		v.Done, v.Error = uvTakeFlash(w, req)
		if q.Get("sort") == "age" {
			v.Sort = "age"
		}
		if bound {
			v.CanWrite = upload != nil && mayCap(caps, verify, sessions, repo, "y", ops.Token, req)
			v.Limit = byteSize(int(min(limit, api.UVFormLimit)))
		}
		for _, f := range files {
			if f.Deleted {
				v.Removed = append(v.Removed, uvRowOf(base, f))
			} else {
				v.Rows = append(v.Rows, uvRowOf(base, f))
			}
		}
		sortUV(v.Rows, v.Sort)
		sortUV(v.Removed, "age")
		render(r, w, req, uvListPage, pageData{Title: "Unversioned files", OwnH1: true, Repo: repo,
			UV: v, SettingsJS: v.CanWrite})
	})
	// The form: streamed by the api layer into uv.put, whose gates are
	// the ones that count; the letter here only spares a refusal.
	mux.HandleFunc("POST /r/{repo}/uv", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gateCap(caps, verify, sessions, repo, "y", ops.Token, w, req)
		if !ok {
			return
		}
		if upload == nil {
			fail(r, w, req, fmt.Errorf("this forge takes no uploads: %w", ops.ErrUnavailable))
			return
		}
		name, err := upload(req.Context(), repo, w, req)
		if err != nil {
			uvFlash(w, req, refusalWords(err), true)
		} else {
			uvFlash(w, req, "published "+name, false)
		}
		http.Redirect(w, req, hooksOf(req.Context()).repoURL(repo)+"/uv", http.StatusSeeOther)
	})
	// Remove names the version the row showed: uv.rm refuses if the file
	// has been replaced since, and dates the tombstone after that
	// version, so a file published this second can be removed now.
	mux.HandleFunc("POST /r/{repo}/uv/rm", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gateCap(caps, verify, sessions, repo, "y", ops.Token, w, req)
		if !ok {
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		name, seen := req.PostForm.Get("name"), req.PostForm.Get("seen")
		if _, err := invoke(req.Context(), r, "uv.rm", url.Values{"repo": {repo}, "name": {name},
			"ifMtime": {seen}}); err != nil {
			uvFlash(w, req, refusalWords(err), true)
		} else {
			uvFlash(w, req, "removed "+name, false)
		}
		http.Redirect(w, req, hooksOf(req.Context()).repoURL(repo)+"/uv", http.StatusSeeOther)
	})
	// The download (RFC 0020, the web): a redirect to the bucket, never
	// the bytes. A missing file is 404 whether or not the repository has
	// a bucket: a redirect to the listing would hand a script following
	// redirects (deploy.sh --release, curl -fL) a page in place of the
	// file it asked for.
	mux.HandleFunc("GET /r/{repo}/uv/{name...}", func(w http.ResponseWriter, req *http.Request) {
		repo, name := req.PathValue("repo"), req.PathValue("name")
		out, err := invoke(req.Context(), r, "uv.get", url.Values{"repo": {repo}, "name": {name}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var got struct {
			URL string `json:"url"`
		}
		remarshal(out, &got)
		w.Header().Set("Cache-Control", "no-store")
		http.Redirect(w, req, got.URL, http.StatusFound)
	})
	// GitHub's spelling: /releases/latest is a redirect to the newest
	// release's own page, so the address a reader keeps always moves.
	// The newest comes from the catalog alone. A folder named latest
	// is reached from the list; this route answers first.
	mux.HandleFunc("GET /r/{repo}/releases/latest", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		base := hooksOf(req.Context()).repoURL(repo)
		out, err := invoke(req.Context(), r, "release.summary", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var sum releaseSummary
		remarshal(out, &sum)
		w.Header().Set("Cache-Control", "no-store")
		if sum.Latest == "" {
			http.Redirect(w, req, base+"/releases", http.StatusFound)
			return
		}
		http.Redirect(w, req, releaseURL(base, sum.Latest), http.StatusFound)
	})
	mux.HandleFunc("GET /r/{repo}/releases/{name}", func(w http.ResponseWriter, req *http.Request) {
		repo, name := req.PathValue("repo"), req.PathValue("name")
		out, err := invoke(req.Context(), r, "release.list", url.Values{"repo": {repo}, "name": {name}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var wire releaseListWire
		remarshal(out, &wire)
		v := releasesViewOf(hooksOf(req.Context()).repoURL(repo), wire)
		if len(v.Groups) != 1 {
			fail(r, w, req, fmt.Errorf("no release %q: %w", name, ops.ErrNotFound))
			return
		}
		g := v.Groups[0]
		g.Single = true
		render(r, w, req, releasePage, pageData{Title: g.Name, OwnH1: true, Repo: repo, Release: &g})
	})
	mux.HandleFunc("GET /r/{repo}/releases", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "release.list", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var wire releaseListWire
		remarshal(out, &wire)
		v := releasesViewOf(hooksOf(req.Context()).repoURL(repo), wire)
		v.CanBind = mayCap(caps, verify, sessions, repo, "a", ops.Admin, req)
		render(r, w, req, releasesPage, pageData{Title: "Releases", OwnH1: true, Repo: repo, Releases: v})
	})
}

// uvUnbound is the no-bucket sentence both pages share.
const uvUnbound = `{{define "uv-unbound"}}<div class="uv-setup" data-hook="uv-unbound">
<h2>Set up a bucket for UV</h2>
<p>This repository has no bucket of its own, so it has no unversioned files yet. They live in an
S3-compatible bucket the repository binds (Cloudflare R2, Backblaze B2, Amazon S3, MinIO and the
like), the same binding its attachments use; the forge keeps only the list.</p>
<ol class="uv-steps">
<li><strong>Bind a bucket.</strong> {{if .CanBind}}In <a data-hook="uv-bind" href="{{repoURL .Repo}}/settings#attachments">Settings, under Attachments</a>,
give the endpoint, the bucket, its region and an access key that may read and write it, then probe it.
{{- else}} A repository admin does this in the repository's settings, under Attachments: an endpoint,
a bucket, its region and an access key that may read and write it.{{end}}</li>
<li><strong>Publish.</strong> Anyone holding the Write-UV letter (<code>y</code>) uploads from this page once
the bucket is bound, or from a fossil checkout: <code>fossil uv add FILE</code>, then
<code>fossil sync -u</code> against this forge.</li>
<li><strong>Releases.</strong> Files under <code>releases/&lt;version&gt;/</code> show as a release, with
<code>release-manifest.json</code> and <code>SHA256SUMS</code> read when present.</li>
</ol>
</div>{{end}}`

var uvListPage = page(template.Must(template.Must(base.Clone()).Parse(uvUnbound + `{{define "content"}}{{$r := .Repo}}{{with .UV}}
<div class="uv" data-hook="uv">
<div class="tn-head"><h1>Unversioned files</h1>{{if $.Counts.HasReleases}}<a class="tn-new" href="{{repoURL $r}}/releases">releases &rarr;</a>{{end}}</div>
<p class="tn-lede">Files published beside the repository rather than in it: release downloads,
generated documents. A name holds its newest version only, and a replaced file is gone. These
are fossil's unversioned files; <code>fossil uv</code> syncs them with this forge.</p>
{{if not .Bound}}{{template "uv-unbound" (dict "Repo" $r "CanBind" .CanBind)}}
{{else}}
{{with .Error}}<p class="uv-msg uv-err" data-hook="uv-error" role="alert">{{.}}</p>{{end}}
{{with .Done}}<p class="uv-msg" data-hook="uv-done" role="status">{{.}}</p>{{end}}
{{- /* Only where a write would land: a tracking mirror takes none,
       unversioned ones included, so the form would collect a refusal. */ -}}
{{if and .CanWrite (not $.Posture.Mirror.Tracking)}}
<form class="uv-up" data-hook="uv-upload" method="post" enctype="multipart/form-data" action="{{repoURL $r}}/uv">
<label class="uv-f uv-file"><span>File</span><input type="file" name="file" required></label>
<label class="uv-f"><span>Folder</span><input name="prefix" placeholder="releases/1.0/" maxlength="400" autocomplete="off" spellcheck="false"></label>
<label class="uv-f"><span>Name</span><input name="name" placeholder="the file's own" maxlength="500" autocomplete="off" spellcheck="false"></label>
<button class="btn btn-primary" type="submit">Publish</button>
<p class="uv-up-note">Up to {{.Limit}}. Publishing a name that exists replaces it, with no history to go back to.</p>
</form>{{end}}
{{if .Rows}}
<div class="uv-bar"><span class="uv-n">{{len .Rows}} {{plural (len .Rows) "file" "files"}}</span>
<span class="uv-sort">sort by <a data-hook="uv-sort" data-sort="name" href="{{repoURL $r}}/uv?sort=name"{{if eq .Sort "name"}} aria-current="true"{{end}}>name</a>
<a data-hook="uv-sort" data-sort="age" href="{{repoURL $r}}/uv?sort=age"{{if eq .Sort "age"}} aria-current="true"{{end}}>newest</a></span></div>
<div class="dt-wrap"><table class="dt dt-stack uv-t" data-hook="uv-list"><thead><tr><th>Name</th><th>Size</th><th>Hash</th><th>Modified</th><th>By</th>{{if .CanWrite}}<th class="dt-act"><span class="sr-only">remove</span></th>{{end}}</tr></thead><tbody>
{{range .Rows}}<tr data-hook="uv-row" data-name="{{.Name}}" data-mtime="{{.Mtime}}">
<td class="uv-name"><svg class="ic" aria-hidden="true"><use href="#i-file"/></svg><a href="{{.URL}}">{{.Name}}</a></td>
<td class="uv-size" data-label="size">{{.Size}}</td>
<td data-label="hash"><code class="uv-hash" data-hook="uv-hash" title="{{.Hash}}">{{printf "%.10s" .Hash}}</code></td>
<td data-label="modified">{{ago .When}}</td>
<td data-label="by" data-hook="uv-who">{{who $r .Who}}</td>
{{if $.UV.CanWrite}}<td class="dt-act"><form method="post" action="{{repoURL $r}}/uv/rm"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="seen" value="{{.Mtime}}"><button class="btn btn-danger" type="submit" data-hook="uv-remove" data-confirm="{{.Name}} is removed for everyone, and clones drop it on their next sync. The bytes are gone from the bucket.">Remove</button></form></td>{{end}}
</tr>
{{end}}</tbody></table></div>
{{else}}<p class="tn-empty" data-hook="uv-empty">Nothing published yet.{{if .CanWrite}} Use the form above, or <code>fossil uv add</code> then <code>fossil sync -u</code> against this forge.{{end}}</p>{{end}}
{{if .Removed}}<details class="uv-removed" data-hook="uv-removed">
<summary>{{len .Removed}} removed</summary>
<div class="dt-wrap"><table class="dt dt-stack uv-t"><thead><tr><th>Name</th><th>Removed</th><th>By</th></tr></thead><tbody>
{{range .Removed}}<tr class="muted" data-hook="uv-removed-row" data-name="{{.Name}}"><td class="uv-name"><del>{{.Name}}</del></td><td data-label="removed">{{ago .When}}</td><td data-label="by">{{who $r .Who}}</td></tr>
{{end}}</tbody></table></div>
</details>{{end}}
{{end}}
{{if .CanWrite}}<dialog id="confirm" class="confirm">
<form method="dialog">
<p class="confirm-why"></p>
<p class="confirm-acts"><button type="button" class="confirm-no">Cancel</button> <button type="button" class="confirm-do danger-btn">Continue</button></p>
</form>
</dialog>{{end}}
</div>{{end}}{{end}}`)))

// releaseGroupSource is one release as both the list and the release's
// own page draw it, so the manifest is rendered in one place.
const releaseGroupSource = `{{define "release-group"}}{{$g := .}}
<section class="rel-g{{if .Newest}} rel-newest{{end}}" data-hook="release" data-name="{{.Name}}" id="release-{{.Name}}">
<header class="rel-h">{{if .Single}}<h1 class="rel-title">{{.Name}}</h1>{{else}}<h2><a data-hook="release-link" href="{{.Link}}">{{.Name}}</a></h2>{{end}}
{{if .Newest}}<span class="latest-pill" data-hook="release-newest">Latest</span>{{end}}
<span class="rel-meta">{{with .Manifest}}{{if .Date}}{{.Date}}{{end}}{{if $g.CheckinURL}} · <a data-hook="release-checkin" href="{{$g.CheckinURL}}" title="the check-in it was built from"><code>{{printf "%.10s" .Checkin}}</code></a>{{end}}{{if .Tag}} · <span class="rel-tag">{{.Tag}}</span>{{end}}{{else}}{{ago .When}}{{end}}</span></header>
<div class="rel-body{{if .Notes}} with-notes{{end}}">
<div class="rel-main">
{{with .Manifest}}
<div class="dt-wrap"><table class="dt dt-stack" data-hook="release-manifest"><thead><tr><th>Target</th><th>File</th><th>Size</th><th>SHA-256</th><th>Qualified</th></tr></thead><tbody>
{{range .Artifacts}}<tr data-hook="release-artifact" data-target="{{.Target}}"><td class="rel-target">{{.Target}}</td><td class="rel-file" data-label="file">{{if .URL}}<a href="{{.URL}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td data-label="size">{{.Size}}</td><td data-label="sha-256"><code class="uv-hash" title="{{.SHA256}}">{{printf "%.12s" .SHA256}}</code>{{with .SumsSHA256}} <span class="uv-err" data-hook="release-sums-mismatch" title="SHA256SUMS says {{.}}">does not match SHA256SUMS</span>{{end}}</td><td{{if .Qualification}} data-label="qualified"{{end}}>{{.Qualification}}</td></tr>
{{end}}{{with .Source}}<tr data-hook="release-artifact" data-target="source"><td class="rel-target">source</td><td class="rel-file" data-label="file">{{if .URL}}<a href="{{.URL}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td data-label="size">{{.Size}}</td><td data-label="sha-256"><code class="uv-hash" title="{{.SHA256}}">{{printf "%.12s" .SHA256}}</code>{{with .SumsSHA256}} <span class="uv-err" data-hook="release-sums-mismatch" title="SHA256SUMS says {{.}}">does not match SHA256SUMS</span>{{end}}</td><td{{if .Qualification}} data-label="qualified"{{end}}>{{.Qualification}}</td></tr>{{end}}
</tbody></table></div>
{{if .Omitted}}<p class="rel-note" data-hook="release-omitted">Not built for {{range $i, $o := .Omitted}}{{if $i}} · {{end}}<span data-hook="release-omitted-target" data-name="{{$o.Target}}"><span class="rel-target">{{$o.Target}}</span>{{with $o.Reason}} <span class="rel-why" data-hook="release-omitted-reason">({{.}})</span>{{end}}</span>{{end}}.</p>{{end}}
{{if .Qualification}}<details class="rel-qual" data-hook="release-qualification"><summary>How each target was qualified</summary>
<dl>{{range .Qualification}}<dt>{{.Target}}</dt><dd>{{range $i, $n := .Notes}}{{if $i}} · {{end}}{{$n}}{{end}}</dd>{{end}}</dl></details>{{end}}
{{if $g.Sums}}<p class="rel-note">Checksums are also in <a data-hook="release-sums" href="{{$g.Sums}}">SHA256SUMS</a>.</p>{{end}}
{{else}}
<ul class="rel-files" data-hook="release-files">{{range .Files}}<li><a href="{{.URL}}">{{.Name}}</a> <span class="rel-size">{{.Size}}</span></li>{{end}}</ul>
{{with .ManifestErr}}<p class="rel-note uv-err" data-hook="release-manifest-error">{{.}}</p>{{end}}
{{if .Sums}}<p class="rel-note">No manifest; checksums come from <a data-hook="release-sums" href="{{.Sums}}">SHA256SUMS</a>.</p>
{{else}}<p class="rel-note">No manifest and no SHA256SUMS: nothing here vouches for these files.</p>{{end}}
{{end}}
</div>
{{with .Notes}}<aside class="rel-notes" data-hook="release-notes"><h3>{{.Heading}}</h3><div class="tkt-body readme">{{.HTML}}</div></aside>{{end}}
</div>
</section>
{{end}}`

var releasesPage = page(template.Must(template.Must(base.Clone()).Parse(uvUnbound + releaseGroupSource + `{{define "content"}}{{$r := .Repo}}{{with .Releases}}
<div class="rel" data-hook="releases">
<div class="tn-head"><h1>Releases</h1><a class="tn-new" href="{{repoURL $r}}/uv">all files &rarr;</a></div>
<p class="tn-lede">Each folder under <code>releases/</code> in the repository's unversioned files is one
release, newest first.</p>
{{if not .Bound}}{{template "uv-unbound" (dict "Repo" $r "CanBind" .CanBind)}}
{{else if not .Groups}}<p class="tn-empty" data-hook="releases-empty">Nothing is published under <code>releases/</code> yet.</p>
{{else}}{{range .Groups}}{{template "release-group" .}}
{{end}}{{end}}
</div>{{end}}{{end}}`)))

// releasePage is one release: the list's entry as a page, its notes in
// full beside it.
var releasePage = page(template.Must(template.Must(base.Clone()).Parse(releaseGroupSource + `{{define "content"}}{{$r := .Repo}}
<div class="rel rel-one" data-hook="release-page">
<p class="w-crumb"><a href="{{repoURL $r}}/releases">releases</a> / <span>{{.Release.Name}}</span></p>
{{template "release-group" .Release}}
</div>{{end}}`)))