package web
import (
"bytes"
"fmt"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"craftdesign.group/quarry/internal/ops"
)
// The new-repository flow: choose where the repository's history lives,
// set it up, review it, create it, and (for a copy) watch it arrive.
//
// Four paths, in two groups, because the first question is who owns the
// history afterwards: this forge (start empty, import and own) or an
// upstream it keeps following (a GitHub or a Fossil mirror). Every path
// ends at the one POST /new, which is repo.add.
// newRepoForm is what the setup and review pages carry: the reader's
// answers so far, sent back and forth as form fields rather than kept
// anywhere, so a Back or a refused create comes back filled in.
type newRepoForm struct {
Kind string // empty | import | github | fossil
Name string
// Owner is the team to create it in; empty for the reader's own
// namespace (RFC 0025). Teams are the ones the reader may choose,
// and User is the reader, whom the empty choice means.
Owner string
Teams []string
User string
Description string
Private bool
From string
Source string // import: fossil | git
Format string // flat | zit (Git only)
Every int // mirrors: seconds between pulls
Readme bool
Ignore bool
License string
// GitToken is the access token for a Git upstream that is not
// public. Asked for on the review page and posted once with the
// create: it is never written back into a page.
GitToken string
}
// newRepoKinds are the four paths, in the order the start page offers them.
var newRepoKinds = map[string]bool{"empty": true, "import": true, "github": true, "fossil": true}
// newRepoEvery are the pull intervals a mirror is offered.
var newRepoEvery = []struct {
Seconds int
Label string
}{{900, "Every 15 minutes"}, {3600, "Every hour"}, {21600, "Every 6 hours"}, {86400, "Every day"}}
// newRepoKindOf reads the path a form came from. The older form's
// spellings (create, own, clone, git) are kept, so a script that posts
// them still creates what it asked for.
func newRepoKindOf(kind string) string {
switch kind {
case "own", "import":
return "import"
case "clone", "fossil":
return "fossil"
case "git", "github":
return "github"
}
return "empty"
}
// newRepoFormOf reads a posted form. Unset choices take their defaults:
// flat manifests, hourly pulls, a README for a new repository.
func newRepoFormOf(req *http.Request) newRepoForm {
f := newRepoForm{
Kind: newRepoKindOf(req.FormValue("kind")),
Name: strings.TrimSpace(req.FormValue("name")),
Owner: strings.TrimSpace(req.FormValue("owner")),
Description: strings.TrimSpace(req.FormValue("description")),
Private: req.FormValue("private") == "true",
From: strings.TrimSpace(req.FormValue("from")),
Source: req.FormValue("source"),
Format: req.FormValue("format"),
Readme: req.FormValue("readme") == "true",
Ignore: req.FormValue("ignore") == "true",
License: req.FormValue("license"),
GitToken: strings.TrimSpace(req.FormValue("token")),
}
f.Every, _ = strconv.Atoi(req.FormValue("every"))
if f.Every <= 0 {
f.Every = 3600
}
if f.Source != "git" {
f.Source = "fossil"
}
if f.Kind == "github" {
f.Source = "git"
}
if f.Format != "zit" || f.Source != "git" || f.Kind == "fossil" || f.Kind == "empty" {
f.Format = "flat"
}
if f.Name == "" && f.From != "" {
f.Name = nameFromURL(f.From)
}
return f
}
// nameFromURL is the last path segment of an upstream's address, which
// is what a copy of it is usually called: github.com/NetBSD/src is src.
func nameFromURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return ""
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
name := strings.TrimSuffix(parts[len(parts)-1], ".git")
if name == "" || name == "home" {
name = strings.Split(u.Hostname(), ".")[0]
}
return name
}
// Git, Mirror and ZitOnly say what the form will make.
func (f newRepoForm) Git() bool {
return f.Kind == "github" || (f.Kind == "import" && f.Source == "git")
}
func (f newRepoForm) Mirror() bool { return f.Kind == "github" || f.Kind == "fossil" }
func (f newRepoForm) ZitOnly() bool { return f.Git() && f.Format == "zit" }
// Upstream is the source as a reader names it: owner/name on GitHub,
// the host elsewhere.
func (f newRepoForm) Upstream() string {
u, err := url.Parse(f.From)
if err != nil || u.Host == "" {
return f.From
}
if u.Host == "github.com" {
return strings.TrimSuffix(strings.Trim(u.Path, "/"), ".git")
}
return u.Host + strings.TrimSuffix(u.Path, "/")
}
// EveryLabel is the pull interval as the setup page offered it.
func (f newRepoForm) EveryLabel() string {
for _, e := range newRepoEvery {
if e.Seconds == f.Every {
return e.Label
}
}
return "Every " + (time.Duration(f.Every) * time.Second).String()
}
// Title is the setup page's heading for the path.
func (f newRepoForm) Title() string {
switch f.Kind {
case "import":
return "Import and own"
case "github":
return "Mirror a GitHub repository"
case "fossil":
return "Mirror a Fossil repository"
}
return "Start a new repository"
}
// Values are the form's answers as repo.add's input.
func (f newRepoForm) Values() url.Values {
v := url.Values{"name": {f.Name}, "description": {f.Description}}
if f.Owner != "" {
v.Set("namespace", f.Owner)
}
if f.Private {
v.Set("private", "true")
}
switch f.Kind {
case "empty":
v.Set("create", "true")
if f.Readme {
v.Set("readme", "true")
}
if f.Ignore {
v.Set("ignore", "true")
}
v.Set("license", f.License)
return v
case "import":
v.Set("noMirror", "true")
default:
v.Set("mirrorEvery", strconv.Itoa(f.Every))
}
v.Set("clone", f.From)
if f.Git() {
v.Set("git", "true")
}
if f.ZitOnly() {
v.Set("zitOnly", "true")
}
if f.Git() && f.GitToken != "" {
v.Set("gitToken", f.GitToken)
}
return v
}
// newRepoCards are the start page's four paths.
var newRepoCards = []struct {
Group string
Kind, Icon, Title, Lead, Own, Verb string
}{
{"Make this forge the home", "empty", "i-file", "Start a new repository", "An empty repository, ready for your first check-in.", "Changes are made here", "Start empty"},
{"Make this forge the home", "import", "i-dir", "Import and own", "Bring an existing repository here once, then maintain it on this forge.", "This becomes the source of truth", "Import a repository"},
{"Keep following an upstream", "github", "i-branch", "Mirror from GitHub", "Follow a GitHub repository's code, branches and tags.", "GitHub stays authoritative", "Set up GitHub mirror"},
{"Keep following an upstream", "fossil", "i-branch", "Mirror from Fossil", "Keep a copy of a Fossil repository and pull its updates.", "The upstream stays authoritative", "Set up Fossil mirror"},
}
var newRepoPage = page(template.Must(template.Must(base.Clone()).Funcs(template.FuncMap{
"newRepoCards": func() any { return newRepoCards },
}).Parse(`{{define "content"}}
<div class="repo-start" data-hook="new-start">
<div class="flow-intro"><h1>New repository</h1><p class="mirror-lead">Start something here, bring existing work, or keep a copy of an upstream.</p></div>
{{$group := ""}}{{range $i, $c := newRepoCards}}{{if ne $c.Group $group}}{{if $group}}</div></div>{{end}}{{$group = $c.Group}}
<div class="start-group"><h2>{{$c.Group}}</h2><div class="start-grid">{{end}}
<article class="start-card" data-hook="new-path" data-kind="{{$c.Kind}}"><div class="start-card-title"><svg class="ic" aria-hidden="true"><use href="#{{$c.Icon}}"></use></svg><h3>{{$c.Title}}</h3></div><p>{{$c.Lead}}</p><div class="start-card-foot"><span class="start-ownership">{{$c.Own}}</span><a class="btn" href="/new/{{$c.Kind}}">{{$c.Verb}}</a></div></article>
{{end}}</div></div>
</div>
{{end}}`)))
// newRepoSetupPage is one path's setup: the GitHub mirror in the grouped
// layout with its summary beside it, the others as one column.
var newRepoSetupPage = page(template.Must(template.Must(base.Clone()).Funcs(template.FuncMap{
"newRepoEvery": func() any { return newRepoEvery },
}).Parse(`{{define "content"}}{{with .NewRepo}}
{{if eq .Kind "github"}}
<div class="mirror-setup" data-hook="new-setup" data-kind="github">
<div class="mirror-intro">
<p class="mirror-crumbs"><a href="/new">New repository</a> <span aria-hidden="true">/</span> Mirror from GitHub</p>
<h1>Mirror a GitHub repository</h1>
<p class="mirror-lead">Keep a copy here. GitHub remains the source of truth.</p>
</div>
{{if $.Error}}<p class="status err" role="alert"><strong>{{$.Error}}</strong></p>{{end}}
<form method="post" action="/new/review" class="mirror-form" data-signals="{owner: '{{js .Owner}}', name: '{{js .Name}}', from: '{{js .From}}', format: '{{js .Format}}', vis: '{{if .Private}}Private{{else}}Public{{end}}'}">
<input type="hidden" name="kind" value="github">
<div class="mirror-layout">
<div class="mirror-fields">
<section class="mirror-section" aria-labelledby="source-title">
<div class="mirror-section-head"><h2 id="source-title">Repository</h2></div>
<div class="mirror-field"><label for="source-url">GitHub repository</label><input id="source-url" name="from" type="url" value="{{.From}}" placeholder="https://github.com/owner/name" required spellcheck="false" autocomplete="off" data-bind:from></div>
<div class="mirror-pair">
{{- /* Where it is made: the reader's own namespace, or a team they
manage (RFC 0025). Shown only when there is a choice. */ -}}
{{if .Teams}}<div class="mirror-field"><label for="new-owner">Owner</label><select id="new-owner" name="owner" data-bind:owner data-on:change="@get('/new/name?name=' + encodeURIComponent($name) + '&owner=' + encodeURIComponent($owner))"><option value=""{{if not .Owner}} selected{{end}}>{{.User}}</option>{{range .Teams}}<option value="{{.}}"{{if eq . $.NewRepo.Owner}} selected{{end}}>{{.}}</option>{{end}}</select></div>{{end}}
<div class="mirror-field"><label for="mirror-name">Name on this forge</label><input id="mirror-name" name="name" value="{{.Name}}" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._-]*" autocomplete="off" spellcheck="false" data-bind:name data-on:input__debounce.250ms="@get('/new/name?name=' + encodeURIComponent(evt.target.value) + '&owner=' + encodeURIComponent($owner))">
{{- /* Whether the name is free, said as it is typed: a taken name
found only after the review and the create is a form that wasted
the reader's time. The review asks again, so without script the
answer comes one step later, never at the end. */ -}}
<p id="name-check" class="name-check" aria-live="polite"{{if .Name}} data-init="@get('/new/name?name={{urlquery .Name}}&owner={{urlquery .Owner}}')"{{end}}></p></div>
<div class="mirror-field"><span id="visibility-label" class="mirror-label">Visibility here</span><div class="mirror-visibility" role="group" aria-labelledby="visibility-label"><label for="public"><input type="radio" id="public" name="private" value="false"{{if not .Private}} checked{{end}} data-on:change="$vis = 'Public'"> Public</label><label for="private"><input type="radio" id="private" name="private" value="true"{{if .Private}} checked{{end}} data-on:change="$vis = 'Private'"> Private</label></div></div>
</div>
</section>
<section class="mirror-section" aria-labelledby="format-title">
<div class="mirror-section-head"><h2 id="format-title">Manifest format</h2><small class="tl-meta-inline">Choose at creation</small></div>
<div class="mirror-format-options">
<label class="mirror-format" for="flat"><span class="mirror-format-title"><input id="flat" name="format" type="radio" value="flat"{{if ne .Format "zit"}} checked{{end}} data-on:change="$format = 'flat'"> <strong>Flat manifests</strong></span><span class="mirror-format-clients">Fossil + zit</span><span class="mirror-format-detail">Compatible with both clients.</span><span class="mirror-format-badge">Default</span></label>
<label class="mirror-format" for="sub"><span class="mirror-format-title"><input id="sub" name="format" type="radio" value="zit"{{if eq .Format "zit"}} checked{{end}} data-on:change="$format = 'zit'"> <strong>Sub-manifests</strong></span><span class="mirror-format-clients">zit only</span><span class="mirror-format-detail">Reusable pieces for large source trees.</span></label>
</div>
</section>
<section class="mirror-section mirror-last" aria-labelledby="schedule-title">
<div class="mirror-section-head"><h2 id="schedule-title">Keep it current</h2></div>
<div class="mirror-field mirror-frequency"><label for="frequency">Check GitHub for updates</label><select id="frequency" name="every">{{range newRepoEvery}}<option value="{{.Seconds}}"{{if eq .Seconds $.NewRepo.Every}} selected{{end}}>{{.Label}}</option>{{end}}</select></div>
</section>
</div>
<aside class="mirror-summary" aria-labelledby="summary-heading">
<header><h2 id="summary-heading">Your mirror</h2><strong class="mirror-summary-name" data-text="$name || '—'">{{or .Name "—"}}</strong><p><span class="mirror-tag" data-text="$vis">{{if .Private}}Private{{else}}Public{{end}}</span> <span class="mirror-tag">Read-only copy</span></p></header>
<div class="mirror-summary-body"><div class="mirror-flow"><span>GitHub</span><strong data-text="$from.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '') || '—'">{{or .Upstream "—"}}</strong><span class="mirror-flow-arrow" aria-hidden="true">↓</span><span>This forge</span><strong data-text="$name || '—'">{{or .Name "—"}}</strong></div>
<dl><dt>Format</dt><dd data-text="$format == 'zit' ? 'Sub-manifests' : 'Flat manifests'">{{if .ZitOnly}}Sub-manifests{{else}}Flat manifests{{end}}</dd><dt>Clients</dt><dd data-text="$format == 'zit' ? 'zit only' : 'Fossil + zit'">{{if .ZitOnly}}zit only{{else}}Fossil + zit{{end}}</dd><dt>Includes</dt><dd>Code, branches and tags</dd></dl>
<button type="submit" class="mirror-review-button" data-hook="new-review">Review mirror</button>
</div></aside>
</div>
</form>
</div>
{{else}}
<div class="repo-basic" data-hook="new-setup" data-kind="{{.Kind}}">
<div class="flow-intro"><p class="mirror-crumbs"><a href="/new">New repository</a> <span aria-hidden="true">/</span> {{.Title}}</p><h1>{{.Title}}</h1></div>
{{if $.Error}}<p class="status err" role="alert"><strong>{{$.Error}}</strong></p>{{end}}
<form method="post" action="/new/review" class="basic-form" data-signals="{source: '{{js .Source}}', owner: '{{js .Owner}}', name: '{{js .Name}}'}">
<input type="hidden" name="kind" value="{{.Kind}}">
{{if ne .Kind "empty"}}<section class="mirror-section"><h2>Source</h2>
{{if eq .Kind "import"}}<div class="mirror-field"><label for="basic-source-type">Repository type</label><select id="basic-source-type" name="source" data-bind:source><option value="fossil"{{if eq .Source "fossil"}} selected{{end}}>Fossil repository</option><option value="git"{{if eq .Source "git"}} selected{{end}}>Git repository</option></select></div>{{end}}
<div class="mirror-field"><label for="basic-source">Repository URL</label><input type="url" id="basic-source" name="from" value="{{.From}}" placeholder="https://fossil-scm.org/home" required spellcheck="false" autocomplete="off"></div>
</section>{{end}}
<section class="mirror-section"><h2>Repository here</h2>
{{- /* Where it is made: the reader's own namespace, or a team they
manage (RFC 0025). Shown only when there is a choice. */ -}}
{{if .Teams}}<div class="mirror-field"><label for="new-owner">Owner</label><select id="new-owner" name="owner" data-bind:owner data-on:change="@get('/new/name?name=' + encodeURIComponent($name) + '&owner=' + encodeURIComponent($owner))"><option value=""{{if not .Owner}} selected{{end}}>{{.User}}</option>{{range .Teams}}<option value="{{.}}"{{if eq . $.NewRepo.Owner}} selected{{end}}>{{.}}</option>{{end}}</select></div>{{end}}
<div class="mirror-field"><label for="basic-name">Name</label><input id="basic-name" name="name" value="{{.Name}}"{{if eq .Kind "empty"}} required{{end}} pattern="[A-Za-z0-9][A-Za-z0-9._-]*" maxlength="64" autocomplete="off" spellcheck="false" data-bind:name data-on:input__debounce.250ms="@get('/new/name?name=' + encodeURIComponent(evt.target.value) + '&owner=' + encodeURIComponent($owner))">
{{- /* Whether the name is free, said as it is typed: a taken name
found only after the review and the create is a form that wasted
the reader's time. The review asks again, so without script the
answer comes one step later, never at the end. */ -}}
<p id="name-check" class="name-check" aria-live="polite"{{if .Name}} data-init="@get('/new/name?name={{urlquery .Name}}&owner={{urlquery .Owner}}')"{{end}}></p></div>
<div class="mirror-field"><label for="basic-description">Description <small class="tl-meta-inline">optional</small></label><input id="basic-description" name="description" value="{{.Description}}" maxlength="300"></div>
<label class="mirror-notify" for="basic-private"><input type="checkbox" id="basic-private" name="private" value="true"{{if .Private}} checked{{end}}> Private</label>
</section>
{{if eq .Kind "empty"}}<section class="mirror-section"><h2>Initial files</h2>
<label class="mirror-notify" for="basic-readme"><input type="checkbox" id="basic-readme" name="readme" value="true"{{if .Readme}} checked{{end}}> Add a README</label>
<label class="mirror-notify" for="basic-ignore"><input type="checkbox" id="basic-ignore" name="ignore" value="true"{{if .Ignore}} checked{{end}}> Add a .zitignore</label>
<div class="mirror-field"><label for="basic-license">License</label><select id="basic-license" name="license"><option value="">none</option><option value="mit"{{if eq .License "mit"}} selected{{end}}>MIT</option><option value="bsd-2"{{if eq .License "bsd-2"}} selected{{end}}>BSD 2-Clause</option></select></div>
</section>{{end}}
{{if eq .Kind "import"}}<section class="mirror-section" data-show="$source == 'git'"{{if ne .Source "git"}} style="display: none"{{end}}><h2>Manifest format</h2><div class="mirror-field"><label for="basic-format">Clients that can read it</label><select id="basic-format" name="format"><option value="flat">Flat manifests · Fossil + zit</option><option value="zit"{{if eq .Format "zit"}} selected{{end}}>Sub-manifests · zit only</option></select></div></section>{{end}}
{{if eq .Kind "fossil"}}<section class="mirror-section"><h2>Keep it current</h2><div class="mirror-field mirror-frequency"><label for="basic-frequency">Pull updates</label><select id="basic-frequency" name="every">{{range newRepoEvery}}<option value="{{.Seconds}}"{{if eq .Seconds $.NewRepo.Every}} selected{{end}}>{{.Label}}</option>{{end}}</select></div></section>{{end}}
<div class="flow-actions"><a class="btn" href="/new">Back</a><button type="submit" data-hook="new-review">Review repository</button></div>
</form>
</div>
{{end}}{{end}}{{end}}`)))
// newRepoReviewPage is the last look before anything is made: what
// will live here, and for a mirror what stays upstream. Every answer
// rides along as a hidden field, so Create sends exactly what is shown.
var newRepoReviewPage = page(template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}{{with .NewRepo}}
<div class="repo-review" data-hook="new-review-page" data-kind="{{.Kind}}">
<div class="flow-intro"><p class="mirror-crumbs"><a href="/new">New repository</a> <span aria-hidden="true">/</span> {{.Title}} <span aria-hidden="true">/</span> Review</p><h1>Review {{if .Mirror}}mirror{{else}}repository{{end}}</h1></div>
{{if $.Error}}<p class="status err" role="alert"><strong>{{$.Error}}</strong></p>{{end}}
<form method="post" action="/new" class="acting" data-signals:busy="false">
<input type="hidden" name="kind" value="{{.Kind}}"><input type="hidden" name="owner" value="{{.Owner}}"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="description" value="{{.Description}}">
<input type="hidden" name="private" value="{{if .Private}}true{{else}}false{{end}}"><input type="hidden" name="from" value="{{.From}}"><input type="hidden" name="source" value="{{.Source}}">
<input type="hidden" name="format" value="{{.Format}}"><input type="hidden" name="every" value="{{.Every}}">
<input type="hidden" name="readme" value="{{if .Readme}}true{{end}}"><input type="hidden" name="ignore" value="{{if .Ignore}}true{{end}}"><input type="hidden" name="license" value="{{.License}}">
<div class="review-layout"><section class="review-details">
<div class="review-source-box"><span class="mirror-tag">{{.Title}}</span><h2>{{if .Owner}}{{.Owner}}/{{end}}{{.Name}}</h2>{{if .From}}<p class="mirror-fine">{{if .Mirror}}Following{{else}}From{{end}} {{.Upstream}}</p>{{end}}</div>
<dl class="review-definition">
<dt>Name</dt><dd>{{.Name}}</dd>
{{if .Description}}<dt>Description</dt><dd>{{.Description}}</dd>{{end}}
<dt>Visibility</dt><dd>{{if .Private}}Private{{else}}Public{{end}}</dd>
{{if .From}}<dt>Source</dt><dd>{{.From}}</dd>{{end}}
{{if .Git}}<dt>Format</dt><dd>{{if .ZitOnly}}Sub-manifests · zit only{{else}}Flat manifests · Fossil + zit{{end}}</dd>{{end}}
{{if .Mirror}}<dt>Updates</dt><dd>{{.EveryLabel}}</dd><dt>Local commits</dt><dd>Refused; changes come from the upstream</dd>{{end}}
{{if eq .Kind "empty"}}<dt>Initial files</dt><dd>{{if or .Readme .Ignore .License}}{{if .Readme}}README {{end}}{{if .Ignore}}.zitignore {{end}}{{if .License}}{{.License}} license{{end}}{{else}}none{{end}}</dd>{{end}}
</dl>
{{- /* The token for a private upstream is asked for here, on the last
page, so it is posted once with the create and never written back
into a page the way every other answer rides along. */ -}}
{{if .Git}}<div class="mirror-field review-token" data-hook="new-token"><label for="new-token">Access token <small class="tl-meta-inline">only for a private repository</small></label>
<input id="new-token" name="token" type="password" autocomplete="off" spellcheck="false" maxlength="1024" placeholder="github_pat_…">
<p class="mirror-fine">A fine-grained GitHub token with read-only access to this repository's contents. It is sealed, sent only to {{.Upstream}}{{if .Mirror}} on each pull{{end}}, and never shown again.{{if not .Private}} The copy here is <strong>public</strong>: anyone can read what the token fetches.{{end}}</p></div>{{end}}
{{if .ZitOnly}}<label class="mirror-confirm" for="zit-only-ack"><input type="checkbox" id="zit-only-ack" name="ack" value="zit" required> I understand that stock Fossil cannot read sub-manifests.</label>{{end}}
</section>
<aside class="review-action-box"><h2>Ready to begin</h2>
<button type="submit" data-hook="new-create" data-on:click="$busy = true" data-class:is-busy="$busy"><span class="b-idle">Create {{if .Mirror}}mirror{{else}}repository{{end}}</span><span class="b-busy">Creating…</span></button>
<button type="submit" formaction="/new/{{.Kind}}" formnovalidate class="flow-back-button" data-hook="new-back">Back to setup</button>
</aside></div>
</form>
</div>
{{end}}{{end}}`)))
// newRepoImport is a copy in progress as its creator watches it.
type newRepoImport struct {
Name string `json:"name"`
State string `json:"state"`
Error string `json:"error"`
Bytes int64 `json:"bytes"`
// Checkins of Total is a Git import's count. Its store appears only
// when it completes, so for a Git import the count is the progress
// and the bytes are not.
Checkins int `json:"checkins"`
Total int `json:"total"`
// Confirm is what a stopped import waits for its owner to agree
// to (api.ImportConfirmSubmanifests), empty for one that failed.
Confirm string `json:"confirm"`
}
// Waiting says the import stopped to ask, rather than failed.
func (i newRepoImport) Waiting() bool { return i.State == "failed" && i.Confirm != "" }
// Copied is how far the copy has come, in the units a reader expects:
// check-ins for a Git import that has counted, otherwise what has landed
// on disk.
func (i newRepoImport) Copied() string {
switch {
case i.Total > 0:
return fmt.Sprintf("%s of %s check-ins", thousands(i.Checkins), thousands(i.Total))
case i.Bytes == 0:
return "starting"
case i.Bytes >= 1<<30:
return fmt.Sprintf("%.1f GB so far", float64(i.Bytes)/(1<<30))
case i.Bytes >= 1<<20:
return fmt.Sprintf("%.1f MB so far", float64(i.Bytes)/(1<<20))
case i.Bytes >= 1<<10:
return fmt.Sprintf("%d KB so far", i.Bytes>>10)
}
return fmt.Sprintf("%d bytes so far", i.Bytes)
}
// newRepoProgressSource is the status block, the part the stream
// replaces; while the copy runs it opens the stream that replaces it.
const newRepoProgressSource = `{{define "new-progress"}}{{with .NewImport}}
<div id="import-status" class="import-status" data-hook="new-progress" data-state="{{.State}}"{{if eq .State "copying"}} data-init="@get('/new/progress/{{.Name}}/live')"{{end}}>
<div class="import-status-head"><strong>{{.Name}}</strong><span class="mirror-tag">{{if eq .State "ready"}}Ready{{else if .Waiting}}Waiting for you{{else if eq .State "failed"}}Failed{{else}}Importing{{end}}</span></div>
<ol class="import-steps">
<li class="done"><span class="import-step-mark" aria-hidden="true">✓</span><span class="import-step-label">Repository registered</span></li>
<li class="{{if eq .State "copying"}}running{{else if .Waiting}}waiting{{else if eq .State "failed"}}failed{{else}}done{{end}}"><span class="import-step-mark" aria-hidden="true">{{if eq .State "failed"}}!{{else if eq .State "ready"}}✓{{else}}2{{end}}</span><span class="import-step-label">Copying history</span><span class="import-step-state">{{if eq .State "copying"}}{{.Copied}}{{else if .Waiting}}waiting for your answer{{else if eq .State "failed"}}stopped{{else}}done{{end}}</span></li>
<li class="{{if eq .State "ready"}}done{{end}}"><span class="import-step-mark" aria-hidden="true">{{if eq .State "ready"}}✓{{else}}3{{end}}</span><span class="import-step-label">Ready to browse</span></li>
</ol>
{{if .Waiting}}<form method="post" action="/new/progress/{{.Name}}/confirm" class="import-confirm" data-hook="new-confirm">
<div class="status warn" role="status"><strong>Import {{.Name}} with sub-manifests?</strong><p>Its history is {{.Error}}. Flat manifests list every file in every check-in, which at this size makes the import many times slower. Sub-manifests are smaller and faster; zit reads them, stock Fossil cannot.</p></div>
<label class="mirror-confirm" for="confirm-ack"><input type="checkbox" id="confirm-ack" name="ack" value="zit" required> I understand that stock Fossil cannot read sub-manifests.</label>
<div class="flow-actions"><button type="submit" data-hook="new-confirm-submit">Import with sub-manifests</button><a href="/new" data-hook="new-confirm-leave">Leave it stopped</a></div>
</form>
{{else if eq .State "failed"}}<div class="status err" role="alert"><strong>The import stopped.</strong><p>{{.Error}}</p></div>{{end}}
<div class="flow-actions">{{if eq .State "ready"}}<a class="btn" href="/r/{{.Name}}" data-hook="new-open">Open repository</a>{{else if and (eq .State "failed") (not .Waiting)}}<a class="btn" href="/new">Start again</a>{{end}}</div>
</div>
{{end}}{{end}}`
var newRepoProgressPage = page(template.Must(template.Must(base.Clone()).Parse(newRepoProgressSource + `{{define "content"}}
<div class="repo-progress">
<div class="flow-intro"><p class="mirror-crumbs"><a href="/new">New repository</a> <span aria-hidden="true">/</span> {{.NewImport.Name}}</p><h1>Preparing {{.NewImport.Name}}</h1></div>
{{template "new-progress" .}}
</div>
{{end}}`)))
// newRepoProgressFrag renders just the status block, for the stream.
var newRepoProgressFrag = template.Must(template.New("frag").Parse(newRepoProgressSource))
// newRepoImportOf asks repo.import for name's copy, as the caller may see it.
func newRepoImportOf(r *ops.Registry, req *http.Request, name string) (newRepoImport, error) {
out, err := invoke(req.Context(), r, "repo.import", url.Values{"name": {name}})
if err != nil {
return newRepoImport{}, err
}
var imp newRepoImport
remarshal(out, &imp)
return imp, nil
}
// newRepoRoutes serves the flow. Creation is open to any signed-in
// account (repo.add), so every page's floor is Token, not Admin.
func newRepoRoutes(mux *http.ServeMux, r *ops.Registry, verify Verify, sessions Sessions, flat bool) {
gate := func(w http.ResponseWriter, req *http.Request) (*http.Request, bool) {
return gateAt(verify, sessions, "", ops.Token, w, req)
}
setup := func(w http.ResponseWriter, req *http.Request, f newRepoForm, msg string, status int) {
if id, ok := req.Context().Value(ctxIdentity{}).(identity); ok {
f.User = id.user
}
// A node that names repositories flat has no namespaces to
// offer (RFC 0025).
if out, err := invoke(req.Context(), r, "team.list", url.Values{"manages": {"true"}}); err == nil && !flat {
var tl struct {
Teams []teamRow `json:"teams"`
}
remarshal(out, &tl)
for _, t := range tl.Teams {
f.Teams = append(f.Teams, t.Name)
}
}
renderStatus(r, w, req, newRepoSetupPage, pageData{Title: f.Title(), OwnH1: true, NewRepo: &f, Error: msg}, status)
}
// nameTaken is why name cannot be created by this reader, or "" when
// it can (repo.name: the rules repo.add applies). A check that
// cannot be made says nothing; the create still decides.
nameTaken := func(req *http.Request, owner, name string) string {
out, err := invoke(req.Context(), r, "repo.name", url.Values{"name": {name}, "namespace": {owner}})
if err != nil {
return ""
}
var a struct {
Free bool `json:"free"`
Why string `json:"why"`
}
remarshal(out, &a)
if a.Free {
return ""
}
return a.Why
}
// GET /new/name: the name field's answer as it is typed, patched in
// under the field.
mux.HandleFunc("GET /new/name", func(w http.ResponseWriter, req *http.Request) {
req, ok := gate(w, req)
if !ok {
return
}
name := strings.TrimSpace(req.URL.Query().Get("name"))
owner := strings.TrimSpace(req.URL.Query().Get("owner"))
p := `<p id="name-check" class="name-check" aria-live="polite"></p>`
if name != "" {
if why := nameTaken(req, owner, name); why != "" {
p = `<p id="name-check" class="name-check err" aria-live="polite" data-hook="name-taken">` + template.HTMLEscapeString(why) + `</p>`
} else {
p = `<p id="name-check" class="name-check ok" aria-live="polite" data-hook="name-free">` + template.HTMLEscapeString(name) + ` is free.</p>`
}
}
patchElements(w, p)
})
mux.HandleFunc("GET /new", func(w http.ResponseWriter, req *http.Request) {
req, ok := gate(w, req)
if !ok {
return
}
render(r, w, req, newRepoPage, pageData{Title: "New repository", OwnH1: true})
})
mux.HandleFunc("GET /new/{kind}", func(w http.ResponseWriter, req *http.Request) {
req, ok := gate(w, req)
if !ok {
return
}
kind := req.PathValue("kind")
if !newRepoKinds[kind] {
http.NotFound(w, req)
return
}
f := newRepoForm{Kind: kind, Source: "fossil", Format: "flat", Every: 3600, Readme: true}
if kind == "github" {
f.Source = "git"
}
setup(w, req, f, "", http.StatusOK)
})
// Back from the review: the setup again, with the answers in it.
mux.HandleFunc("POST /new/{kind}", func(w http.ResponseWriter, req *http.Request) {
req, ok := gate(w, req)
if !ok {
return
}
kind := req.PathValue("kind")
if !newRepoKinds[kind] {
http.NotFound(w, req)
return
}
// The path names the kind; read before the answers, which
// depend on it (a format is only a Git import's).
req.ParseForm()
req.Form.Set("kind", kind)
setup(w, req, newRepoFormOf(req), "", http.StatusOK)
})
mux.HandleFunc("POST /new/review", func(w http.ResponseWriter, req *http.Request) {
req, ok := gate(w, req)
if !ok {
return
}
f := newRepoFormOf(req)
if f.Kind != "empty" && f.From == "" {
setup(w, req, f, "Give the repository's address.", http.StatusBadRequest)
return
}
if f.Name == "" {
setup(w, req, f, "Give the repository a name.", http.StatusBadRequest)
return
}
if why := nameTaken(req, f.Owner, f.Name); why != "" {
setup(w, req, f, why, http.StatusConflict)
return
}
render(r, w, req, newRepoReviewPage, pageData{Title: "Review " + f.Name, OwnH1: true, NewRepo: &f})
})
mux.HandleFunc("POST /new", func(w http.ResponseWriter, req *http.Request) {
req, ok := gate(w, req)
if !ok {
return
}
f := newRepoFormOf(req)
// The review page asks for this; a form that skipped it has
// not been told that Fossil cannot read what it is making.
if f.ZitOnly() && req.FormValue("ack") != "zit" {
renderStatus(r, w, req, newRepoReviewPage, pageData{Title: "Review " + f.Name, OwnH1: true, NewRepo: &f,
Error: "Confirm that stock Fossil cannot read sub-manifests."}, http.StatusBadRequest)
return
}
out, err := invoke(req.Context(), r, "repo.add", f.Values())
if err != nil {
// A refused create comes back as its setup with the reason
// on it: the reader has a correction to make, and an error
// page would throw their answers away.
setup(w, req, f, err.Error(), http.StatusBadRequest)
return
}
var added struct {
Name string `json:"name"`
}
remarshal(out, &added)
if f.Kind == "empty" {
http.Redirect(w, req, "/r/"+added.Name, http.StatusSeeOther)
return
}
http.Redirect(w, req, "/new/progress/"+url.PathEscape(added.Name), http.StatusSeeOther)
})
mux.HandleFunc("GET /new/progress/{name}", func(w http.ResponseWriter, req *http.Request) {
req, ok := gate(w, req)
if !ok {
return
}
name := req.PathValue("name")
imp, err := newRepoImportOf(r, req, name)
if err != nil {
fail(r, w, req, err)
return
}
render(r, w, req, newRepoProgressPage, pageData{Title: "Preparing " + name, OwnH1: true, NewImport: &imp})
})
mux.HandleFunc("POST /new/progress/{name}/confirm", func(w http.ResponseWriter, req *http.Request) {
req, ok := gate(w, req)
if !ok {
return
}
name := req.PathValue("name")
imp, err := newRepoImportOf(r, req, name)
if err != nil {
fail(r, w, req, err)
return
}
// The page asks for this, as the review page does for a
// sub-manifest import chosen up front.
if req.FormValue("ack") != "zit" {
renderStatus(r, w, req, newRepoProgressPage, pageData{Title: "Preparing " + name, OwnH1: true, NewImport: &imp,
Error: "Confirm that stock Fossil cannot read sub-manifests."}, http.StatusBadRequest)
return
}
if _, err := invoke(req.Context(), r, "repo.import.confirm", url.Values{"name": {name}}); err != nil {
fail(r, w, req, err)
return
}
http.Redirect(w, req, "/new/progress/"+url.PathEscape(name), http.StatusSeeOther)
})
mux.HandleFunc("GET /new/progress/{name}/live", func(w http.ResponseWriter, req *http.Request) {
req, ok := gate(w, req)
if !ok {
return
}
newRepoProgressStream(r, w, req, req.PathValue("name"))
})
}
// newRepoProgressStream holds the progress page's stream open while the
// copy runs, sending the status block whenever it changes, and ends
// once the copy has finished either way.
func newRepoProgressStream(r *ops.Registry, w http.ResponseWriter, req *http.Request, name string) {
w, fl, ok := liveStream(w)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
fmt.Fprint(w, ": live\n\n")
fl.Flush()
poll := time.NewTicker(2 * time.Second)
defer poll.Stop()
last := ""
for {
select {
case <-req.Context().Done():
return
case <-poll.C:
imp, err := newRepoImportOf(r, req, name)
if err != nil {
return
}
var buf bytes.Buffer
if err := newRepoProgressFrag.ExecuteTemplate(&buf, "new-progress", pageData{NewImport: &imp}); err != nil {
return
}
if frag := buf.String(); frag != last {
last = frag
fmt.Fprint(w, "event: datastar-patch-elements\n")
for _, line := range strings.Split(strings.TrimSpace(frag), "\n") {
fmt.Fprintf(w, "data: elements %s\n", line)
}
fmt.Fprint(w, "\n")
fl.Flush()
}
if imp.State != "copying" {
return
}
}
}
}