Skip to content
quarry Artifact [accb03f229]
Login

Artifact [accb03f229]

Artifact accb03f229a46aa8b0ad91971e0f326f150a02c4516127a6aa3e75eddca00cc0:


// prefs.go is the preferences page of RFC 0011 §6: the family x channel
// matrix notify holds, the forge-local switches quarry owns, the
// repositories somebody muted, and the one control in quarry that needs
// JavaScript.
//
// Principle 3 holds everywhere but that control. The matrix, the
// switches and the mutes are one ordinary form, and every cell posts an
// EXPLICIT state: a checkbox alone posts nothing when it is unticked,
// and `me.prefs.set` reads the whole matrix as the intended end state,
// so an absent field and a deliberate off must not look the same.
//
// The push control is RFC 0011 §11's recorded exception. It is one
// inline expression on one button — no script file, no bundle — and
// without script it says so and nothing else on the page changes.
package web

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"html/template"
	"io"
	"log"
	"net/http"
	"net/url"
	"reflect"
	"strings"

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

// prefFamilyRows are RFC 0011 §6's four families in the order the page
// draws them, worded as somebody recognises them rather than as the
// workflow slugs quarry triggers. The timing column is the workflow's
// own shape (§6's table), stated so the matrix is not read as "email me
// every check-in".
var prefFamilyRows = []struct{ Key, Label, Timing string }{
	{"mention", "someone mentions me", "immediate"},
	{"reply", "a thread I follow gets a reply", "immediate"},
	{"landing", "my landing changes state", "immediate"},
	{"activity", "activity in repositories I have access to", "digest · every 2 hours, per repository"},
}

// prefColumns are the three channels, in the order `me.prefs` reports.
var prefColumns = []struct{ Key, Label string }{
	{"inapp", "in app"},
	{"push", "push"},
	{"email", "email"},
}

// prefsOut is `me.prefs`'s answer as the page reads it.
type prefsOut struct {
	Families map[string]map[string]bool `json:"families"`
	// Digests is family -> the window its batch gathers over, for the
	// families that batch at all.
	Digests map[string]struct {
		Amount int    `json:"amount"`
		Unit   string `json:"unit"`
		Chosen bool   `json:"chosen"`
	} `json:"digests"`
	Toasts  bool     `json:"toasts"`
	Desktop bool     `json:"notify_desktop"`
	Muted   []string `json:"muted"`
	Unknown []string `json:"unknown"`
	Push    bool     `json:"push"`
	Devices []struct {
		EndpointHash string `json:"endpoint_hash"`
		UA           string `json:"ua"`
		At           string `json:"at"`
		Pending      bool   `json:"pending_remote_delete"`
	} `json:"devices"`
	Applied struct {
		Local  bool `json:"local"`
		Notify bool `json:"notify"`
	} `json:"applied"`
}

// prefsView is the notifications matrix and push control's state — the
// "notifications" section of /me. Email carries only what the matrix's
// own email column needs to know (whether a cell can work at all); the
// email SECTION itself is drawn from accountEmailView directly, as its
// own sibling section on the page, not nested in here.
type prefsView struct {
	Rows    []prefRow
	Columns []prefCol
	Toasts  prefCell
	Desktop prefCell
	Muted   []string
	Mutable []string
	Unknown bool
	Push    pushView
	Saved   bool
	Applied struct{ Local, Notify bool }
	Banner  string
	Email   accountEmailView
}

// accountEmailView is /me's email section: the address account mail —
// an invite, an access change — actually goes to, which is never a
// preference and is never in the notifications matrix.
type accountEmailView struct {
	// Confirmed is the address quarry mails today; "" when none.
	Confirmed string
	// Pending is an address typed but not yet proven; "" when none.
	Pending string
	// Status is what the section says beneath the address itself.
	Status string
	// Available is false when the account's store has no email surface
	// at all (AccountEmail unimplemented) — the section still renders,
	// but its form is not, matching the matrix's "email-unset" note.
	Available bool
}

type prefCol struct{ Key, Label string }

// digestChoices are the cadences the control offers, in the order it
// draws them. The first CLEARS a choice: "the forge's own window"
// is a real answer, distinct from happening to pick the same number,
// and only it keeps following the default when an operator changes it.
var digestChoices = []struct{ Value, Label string }{
	{"default", "the forge's own window"},
	{"15 minutes", "every 15 minutes"},
	{"1 hours", "hourly"},
	{"2 hours", "every 2 hours"},
	{"4 hours", "every 4 hours"},
	{"1 days", "once a day"},
}

// digestWords says a window the way somebody reads it, for the families
// whose cadence cannot be changed and for the default's own label.
func digestWords(amount int, unit string) string {
	switch {
	case amount == 1 && unit == "days":
		return "once a day"
	case amount == 1 && unit == "hours":
		return "hourly"
	case unit == "minutes":
		return fmt.Sprintf("every %d minutes", amount)
	case unit == "hours":
		return fmt.Sprintf("every %d hours", amount)
	}
	return fmt.Sprintf("every %d %s", amount, unit)
}

type prefRow struct {
	Family, Label, Timing string
	// Digest is the cadence control, drawn only for a family that
	// batches. Selected is the value in force; Chosen says it is this
	// account's own rather than inherited, which is what lets the
	// default option name the window it would fall back to.
	Digest   bool
	Selected string
	Chosen   bool
	Options  []prefDigestOption
	Cells    []prefCell
}

// prefDigestOption is one cadence in the select.
type prefDigestOption struct {
	Value, Label string
	On           bool
}

// prefCell is one switch. Name is what it posts under — "family.channel"
// for a matrix cell, the bare switch name for the two local ones — and
// both states are always posted, which is what makes an unticked box a
// decision rather than an omission.
type prefCell struct {
	Name string
	// Label is what a screen reader hears in place of the tick: the
	// row and column in words, because "mention.inapp" is the field
	// name and not a sentence anybody said.
	Label string
	// Column is the channel key ("email", "push", "inapp") — the
	// template's way of knowing this is the email column without
	// parsing it back out of Name, so it can draw the "no address"
	// note there instead of a switch that cannot work.
	Column  string
	On      bool
	Unknown bool
}

// pushView is the push control's server-known state. "unsupported" is a
// forge with no notification companion, "on" an account with at least
// one device, "off" everything else. Whether THIS browser holds a
// subscription is not something a server can know, which is why the
// control redraws itself from /me/push.frag after subscribing.
type pushView struct {
	State string
	Words string
	// Err is what the last attempt ran into, as the control says it.
	// It is rendered by the SERVER on the redraw, so the words a person
	// reads are one string in one place rather than a sentence written
	// inside a click expression.
	Err string
	// Sent is set after a test push was accepted, so the control can
	// say what happens next rather than only that something happened.
	Sent    bool
	Devices []pushDeviceRow
}

// pushErrWords turns the frag's ?err= into the one line the control
// shows. An unknown code says nothing rather than echoing itself back.
func pushErrWords(code string) string {
	switch code {
	case "unsupported":
		return "This browser cannot do push. On iPhone, add quarry to the Home Screen first."
	case "blocked":
		return "Notifications are blocked for this site in your browser's settings."
	case "nosub":
		return "This browser holds no push subscription."
	case "failed":
		return "Push could not be changed just now. Try again."
	case "testfailed":
		return "The test could not be sent. The notification companion refused it."
	}
	return ""
}

// pushTestWords is what the control says after a test was accepted.
//
// It cannot say the notification arrived — nothing on this side can.
// Everything up to and including the push service answers success, and
// an operating system that is holding notifications back says nothing
// to anybody, which is exactly how a person ends up staring at a page
// that claims push works. So it says what IS known and names the one
// place left to look.
const pushTestWords = "Test sent. If nothing appeared within a few seconds, " +
	"the push left this forge and your device is holding it: check your operating " +
	"system's notification settings for this browser, and Do Not Disturb."

type pushDeviceRow struct {
	Hash, UA, At string
	Pending      bool
}

// pushEnableExpr is RFC 0011 §11's exception, entire: check the browser
// can do this at all, register the worker, ask for permission from the
// click that asked for push, subscribe with notify's VAPID key, post
// the subscription verbatim, and redraw the control. The base64url key
// is decoded inline (the helper every push guide names
// urlBase64ToUint8Array) rather than in a script file, because a file
// is what Principle 3 refuses.
//
// Every outcome ends at /me/push.frag, so what a person is told comes
// from the SERVER's own reading of the state and not from what the
// expression believes happened. A rejected fetch is a rejected promise
// — `r.ok` is checked, because fetch resolves a 500 like a 200 and a
// silent failure here looks exactly like success.
const pushEnableExpr = `(!('serviceWorker' in navigator) || !('PushManager' in window)) ? ` +
	`@get('/me/push.frag?err=unsupported') : ` +
	`navigator.serviceWorker.register('/sw.js').then(reg => ` +
	`Notification.requestPermission().then(p => p !== 'granted' ? ` +
	`@get('/me/push.frag?err=blocked') : ` +
	`fetch('/me/push/key', {credentials: 'same-origin'}).then(r => ` +
	`r.ok ? r.json() : Promise.reject(r.status)).then(k => ` +
	`reg.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: ` +
	`Uint8Array.from(atob(k.publicKey.replace(/-/g, '+').replace(/_/g, '/') + ` +
	`'==='.slice((k.publicKey.length + 3) % 4)), c => c.charCodeAt(0))})).then(s => ` +
	`fetch('/me/push', {method: 'POST', credentials: 'same-origin', ` +
	`headers: {'Content-Type': 'application/json'}, body: JSON.stringify(s)}).then(r => ` +
	`r.ok ? r : Promise.reject(r.status))).then(() => ` +
	`@get('/me/push.frag')))).catch(e => @get('/me/push.frag?err=failed'))`

// pushTestExpr asks for one real push. The permission is checked HERE
// rather than on the server because only the browser knows it: a site
// whose permission was revoked after subscribing still has a live
// subscription on notify's side, so the send would succeed and nothing
// would appear — the failure this control exists to explain.
const pushTestExpr = `(!('Notification' in window) || Notification.permission !== 'granted') ? ` +
	`@get('/me/push.frag?err=blocked') : ` +
	`@post('/me/push/test')`

// pushDisableExpr turns this browser's own subscription off. It is the
// only device a page can address: quarry stores an endpoint HASH and
// never the endpoint (RFC 0011 §6), so another browser's subscription
// can only be ended from that browser.
const pushDisableExpr = `(!('serviceWorker' in navigator)) ? ` +
	`@get('/me/push.frag?err=unsupported') : ` +
	`navigator.serviceWorker.ready.then(reg => ` +
	`reg.pushManager.getSubscription()).then(s => s ? ` +
	`s.unsubscribe().then(() => fetch('/me/push/delete', {method: 'POST', ` +
	`credentials: 'same-origin', headers: ` +
	`{'Content-Type': 'application/x-www-form-urlencoded'}, ` +
	`body: 'endpoint=' + encodeURIComponent(s.endpoint)}).then(r => ` +
	`r.ok ? r : Promise.reject(r.status))).then(() => ` +
	`@get('/me/push.frag')) : ` +
	`@get('/me/push.frag?err=nosub')).catch(e => @get('/me/push.frag?err=failed'))`

// prefsPageOf reads `me.prefs` and words the page.
func prefsPageOf(r *ops.Registry, req *http.Request, user string) (*prefsView, error) {
	ctx := ops.WithUser(req.Context(), user)
	out, err := invoke(ctx, r, "me.prefs", url.Values{})
	if err != nil {
		return nil, err
	}
	var p prefsOut
	remarshal(out, &p)
	v := prefsViewOf(r, req, user, p)
	v.Email = accountEmailViewOf(ctx, r)
	return v, nil
}

// accountEmailViewOf reads `me.email` and words the section above the
// matrix. A failed or unavailable read leaves Available false, which
// is what draws the section as a forge with no email surface at all
// rather than as an account with no address.
func accountEmailViewOf(ctx context.Context, r *ops.Registry) accountEmailView {
	out, err := invoke(ctx, r, "me.email", url.Values{})
	if err != nil {
		return accountEmailView{}
	}
	var e struct {
		Email   string `json:"email"`
		Pending string `json:"pending"`
	}
	remarshal(out, &e)
	v := accountEmailView{Confirmed: e.Email, Pending: e.Pending, Available: true}
	switch {
	case e.Email == "" && e.Pending == "":
		v.Status = "No address set."
	case e.Email == "" && e.Pending != "":
		v.Status = "Waiting for confirmation of " + e.Pending + "."
	case e.Pending != "":
		v.Status = "Confirmed. Waiting for confirmation of " + e.Pending + " to replace it."
	default:
		v.Status = "Confirmed."
	}
	return v
}

func prefsViewOf(r *ops.Registry, req *http.Request, user string, p prefsOut) *prefsView {
	unknown := map[string]bool{}
	for _, f := range p.Unknown {
		unknown[f] = true
	}
	v := &prefsView{Muted: p.Muted, Unknown: len(p.Unknown) > 0}
	v.Applied.Local, v.Applied.Notify = p.Applied.Local, p.Applied.Notify
	for _, col := range prefColumns {
		v.Columns = append(v.Columns, prefCol{Key: col.Key, Label: col.Label})
	}
	for _, f := range prefFamilyRows {
		row := prefRow{Family: f.Key, Label: f.Label, Timing: f.Timing}
		// A family notify reports a window for gets the control; one it
		// does not gets the words it always had. A cadence select on a
		// family with nowhere to store it would be a lie, and the
		// companion is the only thing that knows which is which.
		if w, ok := p.Digests[f.Key]; ok {
			row.Digest, row.Chosen = true, w.Chosen
			row.Selected = "default"
			if w.Chosen {
				row.Selected = fmt.Sprintf("%d %s", w.Amount, w.Unit)
			}
			for _, c := range digestChoices {
				label := c.Label
				if c.Value == "default" && !w.Chosen {
					// The default is in force, so it can name itself
					// rather than being a blank the reader must guess.
					label = digestWords(w.Amount, w.Unit) + " — the forge's own"
				}
				row.Options = append(row.Options, prefDigestOption{
					Value: c.Value, Label: label, On: c.Value == row.Selected})
			}
		}
		for _, col := range prefColumns {
			row.Cells = append(row.Cells, prefCell{
				Name:    f.Key + "." + col.Key,
				Label:   f.Label + ", " + col.Label,
				Column:  col.Key,
				On:      p.Families[f.Key][col.Key],
				Unknown: unknown[f.Key],
			})
		}
		v.Rows = append(v.Rows, row)
	}
	// The two forge-local switches are read from the store the STREAM
	// reads, when there is one: /me.sse decides what it emits from that
	// same seam, and a page that reported anything else would describe
	// a forge the reader is not using.
	v.Toasts = prefCell{Name: toastsPref, On: p.Toasts}
	v.Desktop = prefCell{Name: notifyDesktopPref, On: p.Desktop}
	v.Mutable = mutableRepos(r, req, p.Muted)
	v.Push = pushViewOf(p)
	return v
}

func pushViewOf(p prefsOut) pushView {
	pv := pushView{State: "unsupported", Words: "not available on this forge"}
	if p.Push {
		pv.State, pv.Words = "off", "not enabled"
	}
	for _, d := range p.Devices {
		pv.Devices = append(pv.Devices, pushDeviceRow{
			Hash: d.EndpointHash, UA: d.UA, At: d.At, Pending: d.Pending})
	}
	if p.Push && len(pv.Devices) > 0 {
		pv.State, pv.Words = "on", "enabled"
	}
	return pv
}

// mutableRepos are the repositories this account can see and has not
// already muted — the options of the "add" control. A failure offers
// none rather than failing the page: the list somebody came to read is
// still true without it.
func mutableRepos(r *ops.Registry, req *http.Request, muted []string) []string {
	out, err := invoke(req.Context(), r, "repo.list", nil)
	if err != nil {
		return nil
	}
	var rl struct {
		Repos []struct {
			Name string `json:"name"`
		} `json:"repos"`
	}
	remarshal(out, &rl)
	already := map[string]bool{}
	for _, m := range muted {
		already[m] = true
	}
	var names []string
	for _, repo := range rl.Repos {
		if !already[repo.Name] {
			names = append(names, repo.Name)
		}
	}
	return names
}

// prefsSetForm turns what the page posted into what `me.prefs.set`
// takes. Each family carries the channels that stay ON; the two
// switches are explicit; mute and unmute are list operations.
func prefsSetForm(req *http.Request) url.Values {
	form := url.Values{}
	for _, f := range prefFamilyRows {
		for _, col := range prefColumns {
			if lastValue(req.PostForm[f.Key+"."+col.Key]) == "on" {
				form.Add(f.Key, col.Key)
			}
		}
	}
	// Nothing is done here about a family notify could not be read:
	// `me.prefs.set` leaves the WHOLE matrix alone when the companion's
	// identities are unresolvable, because a matrix is written as one
	// end state and a per-family exception cannot be expressed in it.
	// The page disables those cells so the form agrees with the op.
	// Absent stays absent: `me.prefs.set` changes a switch nobody named
	// not at all, and posting a guessed "off" here is exactly the
	// silent turn-off that rule exists to prevent.
	// A cadence travels only when the family HAS the control: a select
	// the page did not draw posts nothing, and a family whose window
	// notify never reported must not arrive as a change.
	for _, f := range prefFamilyRows {
		if v := lastValue(req.PostForm["digest."+f.Key]); v != "" {
			form.Add("digest", f.Key+"="+v)
		}
	}
	for _, name := range []string{toastsPref, notifyDesktopPref} {
		if v := lastValue(req.PostForm[name]); v == "on" || v == "off" {
			form.Set(name, v)
		}
	}
	for _, key := range []string{"mute", "unmute"} {
		for _, repo := range req.PostForm[key] {
			if repo != "" {
				form.Add(key, repo)
			}
		}
	}
	return form
}

// lastValue reads the last of a repeated field. Every switch posts a
// hidden "off" and then, when ticked, an "on"; browsers send them in
// document order, so the last value is the state of the box.
func lastValue(vals []string) string {
	if len(vals) == 0 {
		return ""
	}
	return vals[len(vals)-1]
}

// prefsBanner says what a save actually did. A companion that did not
// answer leaves the notify half unwritten, and a page that reported
// "saved" would have told somebody a channel is off while it is still
// on.
func prefsBanner(v *prefsView) string {
	switch {
	case v.Applied.Notify:
		return "Saved."
	case v.Applied.Local:
		return "Saved here; the notification companion did not answer — push and email preferences will apply once it does."
	default:
		return "Nothing was saved: this forge keeps no preferences."
	}
}

// uaLabel is what a device is called in the list: the browser's own
// user-agent string, bounded. It is a label, not an identity.
func uaLabel(ua string) string {
	ua = strings.TrimSpace(ua)
	const limit = 200
	if len(ua) > limit {
		ua = strings.TrimSpace(ua[:limit])
	}
	return ua
}

// invokeJSON runs an op whose input is a JSON DOCUMENT rather than a
// form — the browser's PushSubscription, which is nested and therefore
// beyond DecodeValues (In/Out structs are flat for forms by design).
// Same decode-validate-invoke path otherwise, so the web route and the
// API route reach identical op semantics.
func invokeJSON(ctx context.Context, r *ops.Registry, name string, body any) (any, error) {
	d, ok := r.Lookup(name)
	if !ok {
		return nil, fmt.Errorf("web: unknown op %q", name)
	}
	raw, err := json.Marshal(body)
	if err != nil {
		return nil, err
	}
	inPtr := d.NewIn()
	if err := json.Unmarshal(raw, inPtr); err != nil {
		return nil, err
	}
	in := reflect.ValueOf(inPtr).Elem().Interface()
	if err := ops.Validate(in); err != nil {
		return nil, err
	}
	return r.Invoke(ctx, name, in)
}

// pushFragment renders the control on its own, for the redraw after a
// browser subscribes.
func pushFragment(v pushView) (string, error) {
	var b bytes.Buffer
	if err := notifyBase.ExecuteTemplate(&b, "push-control", v); err != nil {
		return "", err
	}
	return b.String(), nil
}

// registerPrefs wires the preferences page and the push routes. The
// push routes are the WEB plane's, not the API's: a service worker and
// a page carry the cookie session and no HTTP Basic credentials, which
// is why sw.js fetches these and not /api/v1/me/push.
func registerPrefs(mux *http.ServeMux, r *ops.Registry, sessions Sessions) {
	// mePage renders the whole account page directly — the settings
	// page's own idiom (SavedPanel/PanelError, echoed back in the SAME
	// response) rather than a redirect-and-reread: five sections share
	// one page, and "which one just saved" is not a thing a URL can
	// carry honestly any more than notify's applied.local/notify was.
	mePage := func(w http.ResponseWriter, req *http.Request, user string, v *meView,
		savedPanel, panelField, panelError string, code int) {
		_, hasMark := markOfKind("user", user)
		data := pageData{Title: "Your account", Me: v,
			SettingsJS: true, MarksOff: avatarTag == nil, HasMark: hasMark,
			SavedPanel: savedPanel, PanelField: panelField, PanelError: panelError}
		renderStatus(r, w, req, mePageTmpl, data, code)
	}
	mux.HandleFunc("GET /me", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next=%2Fme", http.StatusSeeOther)
			return
		}
		v, err := meAccountPageOf(r, req, user)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		mePage(w, req, user, v, "", "", "", http.StatusOK)
	})
	// The pages /me absorbed: an old bookmark or a link mailed before
	// this page existed still lands on the right account, at the right
	// fragment, rather than on a 404 or a page that no longer exists.
	mux.HandleFunc("GET /me/notifications", func(w http.ResponseWriter, req *http.Request) {
		http.Redirect(w, req, "/me#notifications", http.StatusSeeOther)
	})
	mux.HandleFunc("GET /me/sessions", func(w http.ResponseWriter, req *http.Request) {
		http.Redirect(w, req, "/me#sessions", http.StatusSeeOther)
	})
	mux.HandleFunc("POST /me/notifications", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next=%2Fme", http.StatusSeeOther)
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		ctx := ops.WithUser(req.Context(), user)
		out, err := invoke(ctx, r, "me.prefs.set", prefsSetForm(req))
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var p prefsOut
		remarshal(out, &p)
		pv := prefsViewOf(r, req, user, p)
		pv.Email = accountEmailViewOf(ctx, r)
		pv.Saved, pv.Banner = true, prefsBanner(pv)
		v, err := meAccountPageOf(r, req, user)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		v.Notify = pv
		mePage(w, req, user, v, "notifications", "", "", http.StatusOK)
	})
	// Set or change the address; nothing is confirmed until its code
	// is typed back in, so the section says "sent", not "done".
	mux.HandleFunc("POST /me/email", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next=%2Fme", http.StatusSeeOther)
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		ctx := ops.WithUser(req.Context(), user)
		panelError := ""
		if _, err := invoke(ctx, r, "me.email.set", url.Values{"email": {req.FormValue("email")}}); err != nil {
			panelError = err.Error()
		}
		v, err := meAccountPageOf(r, req, user)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		mePage(w, req, user, v, "email", "email", panelError, http.StatusOK)
	})
	mux.HandleFunc("POST /me/email/clear", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next=%2Fme", http.StatusSeeOther)
			return
		}
		ctx := ops.WithUser(req.Context(), user)
		panelError := ""
		if _, err := invoke(ctx, r, "me.email.clear", url.Values{}); err != nil {
			panelError = err.Error()
		}
		v, err := meAccountPageOf(r, req, user)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		mePage(w, req, user, v, "email", "email", panelError, http.StatusOK)
	})
	// The code typed back in from the mailed one. Never a link: a code
	// only the signed-in account itself may spend has nowhere to go but
	// back into the session that asked for it.
	mux.HandleFunc("POST /me/email/confirm", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next=%2Fme", http.StatusSeeOther)
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		ctx := ops.WithUser(req.Context(), user)
		panelError := ""
		if _, err := invoke(ctx, r, "me.email.confirm", url.Values{"code": {req.FormValue("code")}}); err != nil {
			panelError = err.Error()
		}
		v, err := meAccountPageOf(r, req, user)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		mePage(w, req, user, v, "email", "email", panelError, http.StatusOK)
	})
	// A person's own password. Verified against the current one,
	// refused if the two new copies disagree, and on success every
	// OTHER session ends — the browser making the change is the one
	// this request is running on, and stays signed in.
	mux.HandleFunc("POST /me/passwd", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next=%2Fme", http.StatusSeeOther)
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		savedPanel, panelError := "password", ""
		if req.FormValue("new") != req.FormValue("again") {
			panelError = "New passwords do not match."
			savedPanel = ""
		} else {
			ctx := ops.WithUser(req.Context(), user)
			_, err := invoke(ctx, r, "me.passwd", url.Values{
				"current": {req.FormValue("current")},
				"new":     {req.FormValue("new")},
				"keep":    {sessionAuditID(cookieToken(req))},
			})
			if err != nil {
				panelError, savedPanel = err.Error(), ""
			}
		}
		v, err := meAccountPageOf(r, req, user)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		mePage(w, req, user, v, savedPanel, "password", panelError, http.StatusOK)
	})
	// The VAPID key, for the control and for sw.js's resubscribe. A
	// session is required: the worker offers the cookie the browser
	// already holds, and an anonymous prober must not be told it worked.
	mux.HandleFunc("GET /me/push/key", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Error(w, "the push key needs a signed-in account", http.StatusUnauthorized)
			return
		}
		out, err := invoke(ops.WithUser(req.Context(), user), r, "me.push.key", url.Values{})
		if err != nil {
			http.Error(w, "the push key is unavailable", http.StatusInternalServerError)
			return
		}
		var k struct {
			PublicKey string `json:"publicKey"`
		}
		remarshal(out, &k)
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "no-store")
		json.NewEncoder(w).Encode(k)
	})
	// The browser's own PushSubscription, posted verbatim.
	mux.HandleFunc("POST /me/push", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Error(w, "a push subscription needs a signed-in account", http.StatusUnauthorized)
			return
		}
		var sub struct {
			Endpoint string `json:"endpoint"`
			Keys     struct {
				P256dh string `json:"p256dh"`
				Auth   string `json:"auth"`
			} `json:"keys"`
		}
		if err := json.NewDecoder(io.LimitReader(req.Body, 16<<10)).Decode(&sub); err != nil {
			http.Error(w, "that is not a push subscription", http.StatusBadRequest)
			return
		}
		if _, err := invokeJSON(ops.WithUser(req.Context(), user), r, "me.push.subscribe",
			map[string]any{
				"endpoint": sub.Endpoint,
				"keys":     map[string]string{"p256dh": sub.Keys.P256dh, "auth": sub.Keys.Auth},
				"ua":       uaLabel(req.UserAgent()),
			}); err != nil {
			log.Printf("web: push subscribe for %s: %v", user, err)
			http.Error(w, "the subscription could not be stored", http.StatusBadGateway)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, `{"ok":true}`)
	})
	// Turning one browser off again. The endpoint comes from that
	// browser, because quarry stores only a hash of it.
	mux.HandleFunc("POST /me/push/delete", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Error(w, "removing a push subscription needs a signed-in account",
				http.StatusUnauthorized)
			return
		}
		if _, err := invoke(ops.WithUser(req.Context(), user), r, "me.push.unsubscribe",
			url.Values{"endpoint": {req.FormValue("endpoint")}}); err != nil {
			log.Printf("web: push unsubscribe for %s: %v", user, err)
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/me#notifications", http.StatusSeeOther)
	})
	// One real push, on request. The whole chain answers "success"
	// whether or not anything reaches a screen — the push service
	// accepts, notify records sent, and an operating system holding
	// notifications back tells nobody — so the only honest test is the
	// notification itself.
	mux.HandleFunc("POST /me/push/test", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Error(w, "a test notification needs a signed-in account", http.StatusUnauthorized)
			return
		}
		if _, err := invoke(ops.WithUser(req.Context(), user), r, "me.push.test", url.Values{}); err != nil {
			log.Printf("web: push test for %s: %v", user, err)
			http.Redirect(w, req, "/me/push.frag?err=testfailed", http.StatusSeeOther)
			return
		}
		http.Redirect(w, req, "/me/push.frag?sent=1", http.StatusSeeOther)
	})
	// The control's own redraw, after a browser subscribed or dropped a
	// subscription: one patch, no page reload, and the state still comes
	// from the server rather than from what the expression believes.
	mux.HandleFunc("GET /me/push.frag", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Error(w, "the push control needs a signed-in account", http.StatusUnauthorized)
			return
		}
		v, err := prefsPageOf(r, req, user)
		if err != nil {
			http.Error(w, "the push control is unavailable", http.StatusInternalServerError)
			return
		}
		// What the browser ran into on its way here, worded by the
		// server: the control redraws with the reason attached rather
		// than failing silently or leaving a sentence in a signal.
		v.Push.Err = pushErrWords(req.URL.Query().Get("err"))
		v.Push.Sent = req.URL.Query().Get("sent") == "1"
		frag, err := pushFragment(v.Push)
		if err != nil {
			http.Error(w, "the push control failed to render", http.StatusInternalServerError)
			return
		}
		markOneShot(w)
		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Cache-Control", "no-store")
		fmt.Fprint(w, "event: datastar-patch-elements\n")
		for _, line := range strings.Split(frag, "\n") {
			fmt.Fprintf(w, "data: elements %s\n", line)
		}
		fmt.Fprint(w, "\n")
	})
}

// notifyBase carries the account page's shared partials on top of
// `base` — the styles, the email section, the notifications matrix and
// the push control — so any page that needs one of them clones THIS
// instead of `base` directly. One definition of each, borrowed by
// every clone rather than copied into it.
var notifyBase = template.Must(template.Must(base.Clone()).Parse(`
{{- /* Every top-level definition here is a NAMED template. Text
       outside a {{define}} would become the clone's own root body,
       silently replacing the header/nav chrome every page needs —
       so account-styles exists as a block rather than bare markup,
       and this file never puts anything outside a {{define}} again. */ -}}
{{define "account-styles"}}{{end}}
{{define "account-email-section"}}<section id="email" data-hook="account-email">
<h2>Email</h2>
{{if .Available}}
<p class="pref-note">Account mail — an invite, a change to your access — always goes to your
confirmed address. Everything in the notifications matrix is a choice; this is not.</p>
<p data-email-status="1">{{if .Confirmed}}<strong>{{.Confirmed}}</strong> — {{.Status}}
{{else}}{{.Status}}{{end}}</p>
<form method="post" action="/me/email" class="acct-email-form">
<label class="sr-only" for="acct-email-input">Email address</label>
<input id="acct-email-input" name="email" type="email" maxlength="200" required
  placeholder="[email protected]">
<button type="submit" class="btn">{{if .Confirmed}}Change{{else}}Set{{end}}</button>
</form>
{{- /* The code field only ever appears while an address is pending:
       a confirmed-with-nothing-waiting account has nothing to type in,
       and drawing the field anyway would invite a code for an address
       that no longer needs one. */}}
{{if .Pending}}<form method="post" action="/me/email/confirm" class="acct-email-form" data-hook="email-confirm-form">
<label class="sr-only" for="acct-email-code">Confirmation code</label>
<input id="acct-email-code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6"
  minlength="6" required placeholder="123456" autocomplete="one-time-code">
<button type="submit" class="btn">Confirm {{.Pending}}</button>
</form>{{end}}
{{if or .Confirmed .Pending}}<form method="post" action="/me/email/clear" class="inline">
<button type="submit" class="btn" title="removes both the confirmed and the waiting address">Clear</button>
</form>{{end}}
{{else}}<p class="pref-note">This forge keeps no email surface for accounts yet.</p>{{end}}
</section>{{end}}
{{- /* One form for the whole matrix, the two local switches and the
       mutes: ` + "`me.prefs.set`" + ` reads a family's channels as the intended
       END STATE, so a control that posted on its own would blank every
       cell it did not carry. The push control is outside it, because it
       is not a preference quarry stores — it is a browser's own
       subscription (RFC 0011 §6). Both ` + "`.`" + ` and ` + "`$`" + ` are the whole
       prefsView here — a {{template}} call rebinds both to its
       argument, so the matrix's email-unset check reads $.Email
       exactly the way the old inline copy read $pv.Email. */}}
{{define "notify-matrix"}}
{{if .Saved}}<p class="pref-banner" data-saved="1"
  data-applied-local="{{if .Applied.Local}}1{{else}}0{{end}}"
  data-applied-notify="{{if .Applied.Notify}}1{{else}}0{{end}}">{{.Banner}}</p>{{end}}
<form method="post" action="/me/notifications">
<table class="pref-matrix">
<tr><th>when</th>{{range .Columns}}<th class="c">{{.Label}}</th>{{end}}<th class="c">timing</th></tr>
{{range .Rows}}<tr data-family="{{.Family}}"><td class="when">{{.Label}}</td>
{{range .Cells}}<td class="c" data-cell="{{.Name}}"{{if .Unknown}} data-unknown="1"{{end}}>
{{- /* Both states, always: the hidden off is what makes an unticked
       box a decision rather than a field the form forgot. An email
       cell with no confirmed address is a THIRD case: not unknown,
       not off — a switch that cannot work, so it is not drawn as one. */ -}}
{{- if and (eq .Column "email") $.Email.Available (not $.Email.Confirmed)}}<span class="email-unset"
  data-hook="email-unset">no address — <a href="/me#email">set one</a></span>
{{- else if .Unknown}}<span class="pref-unknown" title="quarry could not read this setting">?</span>
{{- else}}<input type="hidden" name="{{.Name}}" value="off"><label><input type="checkbox"
  name="{{.Name}}" value="on"{{if .On}} checked{{end}}><span class="sr-only">{{.Label}}</span></label>
{{end}}</td>
{{end}}<td class="c pref-when">{{if .Digest}}<label class="pref-cadence">
<span class="sr-only">how often {{.Label}} is batched</span>
<select name="digest.{{.Family}}">{{range .Options}}
<option value="{{.Value}}"{{if .On}} selected{{end}}>{{.Label}}</option>{{end}}
</select></label>{{else}}{{.Timing}}{{end}}</td></tr>
{{end}}</table>
{{if .Unknown}}<p class="pref-note" data-unknown-note="1">The cells marked ? could not be read:
notify is unreachable. They are not shown and not changed — saving this form leaves the
channel matrix exactly as notify holds it.</p>{{end}}
<div class="pref-switches">
<label><input type="hidden" name="{{.Toasts.Name}}" value="off"><input type="checkbox"
  id="attn-toasts" name="{{.Toasts.Name}}" value="on"{{if .Toasts.On}} checked{{end}}>
  In-page banners</label>
<label><input type="hidden" name="{{.Desktop.Name}}" value="off"><input type="checkbox"
  id="notify-desktop" name="{{.Desktop.Name}}" value="on"{{if .Desktop.On}} checked{{end}}>
  Desktop notifications</label>
</div>
<h3>Muted repositories</h3>
<p class="pref-note">A muted repository still reaches this inbox; it just stops being sent.</p>
{{if .Muted}}<ul class="pref-mutes">{{range .Muted}}<li data-muted="{{.}}">{{.}}
<button type="submit" class="pref-unmute" name="unmute" value="{{.}}">unmute</button></li>
{{end}}</ul>{{else}}<p class="pref-note">Nothing is muted.</p>{{end}}
{{if .Mutable}}<label>Mute a repository
<select name="mute"><option value="" selected>choose…</option>
{{range .Mutable}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>{{end}}
<p><button type="submit" class="btn">Save</button></p>
</form>
{{template "push-control" .Push}}
{{end}}
{{define "push-control"}}<section id="push-control" class="pushrow" data-push="{{.State}}">
{{- /* The label and the state are one line, and the controls are a
       row of their own beneath it. They used to be siblings in a bare
       block, so on a phone the state ran straight into the label and
       the buttons wrapped between the prose that explains them. */ -}}
<div class="push-head"><b>Push on this device</b><span class="push-state">{{.Words}}</span></div>
{{if eq .State "unsupported"}}
<small class="push-nojs">This forge has no notification companion, so push cannot be turned on here.</small>
{{else}}
{{- /* RFC 0011 §11: the ONE exception to Principle 3. Everything the
       control does is in this expression — register the worker, ask
       permission from the click that asked for push, subscribe with
       notify's key, post the subscription, redraw. There is no script
       file and nothing else on the page needs one. */ -}}
<div class="push-acts">
<button type="button" class="btn" id="push-enable"
  data-on:click="` + pushEnableExpr + `">Enable</button>
{{if .Devices}}<button type="button" class="btn" id="push-disable"
  data-on:click="` + pushDisableExpr + `">Turn off on this device</button>{{end}}
{{if .Devices}}<button type="button" class="btn" id="push-test"
  data-on:click="` + pushTestExpr + `">Send a test</button>{{end}}
</div>
{{if .Err}}<span class="push-msg" data-push-err="1">{{.Err}}</span>{{end}}
{{if .Sent}}<span class="push-msg" data-push-sent="1">` + pushTestWords + `</span>{{end}}
<small class="push-nojs">Needs JavaScript and a browser that supports push. On iPhone,
add quarry to the Home Screen first.</small>
{{end}}
{{- /* The same name the sessions table gives a device, for the same
       reason: a raw user-agent string is four lines of version numbers
       nobody reads, and the reader is trying to recognise a device
       they own. The full string stays in the title for whoever does
       want it. */ -}}
{{if .Devices}}<ul class="push-devices">{{range .Devices}}<li data-device="{{.Hash}}">
<span class="pd-name"{{if .UA}} title="{{.UA}}"{{end}}>{{if .UA}}{{device .UA}}{{else}}an unnamed browser{{end}}</span>
{{if .At}}<span class="pd-when">last used {{ago .At}}</span>{{end}}
{{- if .Pending}}<span class="pd-pending">removal pending</span>{{end}}</li>{{end}}</ul>
<small class="push-nojs">quarry keeps only a hash of each subscription (RFC 0011 §6), so a
browser other than this one is turned off from that browser.</small>{{end}}
</section>{{end}}`))

// meView is /me's whole state: the account's own identity, email,
// password and sessions, plus the notifications matrix and push
// control it absorbed from the pages that used to hold them alone.
type meView struct {
	Name    string
	Admin   bool
	Created string
	Email   accountEmailView
	// PasswordAvailable draws the password section only when the
	// account store actually supports self-service change — the push
	// control's own rule: a control that cannot work is not drawn as
	// one, rather than drawn and refusing.
	PasswordAvailable bool
	Sessions          []sessionRow
	ThisSession       string
	Notify            *prefsView
	// Access is what this account may reach and what it may do there.
	// /me listed no repositories, roles or teams, so the page a person
	// opens to ask about themselves could not answer the question they
	// came with (ticket f44e0c66).
	Access []meAccessRow
	Teams  []string
}

// meAccessRow is one repository and the role held on it.
type meAccessRow struct {
	Repo string
	Role string
}

// meAccountPageOf reads every op /me's sections are built from. Each
// read degrades independently — a companion or a store half that
// cannot answer leaves its OWN section honest about that (Notify's
// Unknown cells, Email's Available=false) rather than failing the
// whole page over one section's absence.
func meAccountPageOf(r *ops.Registry, req *http.Request, user string) (*meView, error) {
	ctx := ops.WithUser(req.Context(), user)
	v := &meView{Name: user, ThisSession: sessionAuditID(cookieToken(req))}
	if out, err := invoke(ctx, r, "me.account", url.Values{}); err == nil {
		var a struct {
			Admin        bool   `json:"admin"`
			Created      string `json:"created"`
			PasswordAuth bool   `json:"passwordAuth"`
			Repos        []struct {
				Repo string `json:"repo"`
				Role string `json:"role"`
			} `json:"repos"`
			Teams []string `json:"teams"`
		}
		remarshal(out, &a)
		v.Admin, v.Created, v.PasswordAvailable = a.Admin, a.Created, a.PasswordAuth
		for _, g := range a.Repos {
			v.Access = append(v.Access, meAccessRow{Repo: g.Repo, Role: g.Role})
		}
		v.Teams = a.Teams
	}
	v.Email = accountEmailViewOf(ctx, r)
	if out, err := invoke(ctx, r, "session.list", nil); err == nil {
		var sl struct {
			Sessions []sessionRow `json:"sessions"`
		}
		remarshal(out, &sl)
		v.Sessions = sl.Sessions
	}
	pv, err := prefsPageOf(r, req, user)
	if err != nil {
		return nil, err
	}
	v.Notify = pv
	return v, nil
}

// mePageTmpl is /me itself: identity, email, password, sessions and
// notifications, one page with a rail like the settings page's, so it
// works at 400px the same way.
var mePageTmpl = template.Must(template.Must(notifyBase.Clone()).Parse(`{{define "content"}}
{{template "account-styles"}}

{{with .Me}}
{{if $.Error}}<p class="status err" role="alert"><strong>{{$.Error}}</strong></p>{{end}}
<div class="acct-rail">
<nav class="set-rail" aria-label="account sections"><ul>
<li><a href="#identity">Identity</a></li>
<li><a href="#email">Email</a></li>
{{if .PasswordAvailable}}<li><a href="#password">Password</a></li>{{end}}
<li><a href="#access">Access</a></li>
<li><a href="#sessions">Sessions</a></li>
<li><a href="#notifications">Notifications</a></li>
</ul></nav>
<div class="set-body">
<section id="identity" data-hook="account-identity">
<h2>Identity</h2>
<p>{{userIcon .Name}} <strong>{{.Name}}</strong>{{if .Admin}} <span class="chip">site admin</span>{{end}}</p>
{{if .Created}}<p class="pref-note">Account created {{ago .Created}}.</p>{{end}}
{{- /* A person's own picture. Nobody else may set it, not even a site
       admin: it is how somebody is DRAWN wherever they appear, and an
       admin who could change it could make one person look like
       another. Removing an abusive one is account moderation, which
       belongs with the account rather than here. */ -}}
<div class="av-edit" data-hook="avatar-edit">
<span class="avatar lg" aria-hidden="true" data-hook="avatar-current">{{userIcon .Name}}</span>
<div class="av-acts">
{{if $.MarksOff}}<p class="pref-note" data-hook="marks-off">This forge supplies no storage yet, so a picture cannot be
uploaded; every account keeps the mark drawn from its name.</p>
{{else}}<form method="post" action="/me/avatar" enctype="multipart/form-data" class="av-form">
<label for="me-av" class="btn">Choose an image</label>
<input id="me-av" type="file" name="image" accept="image/png,image/jpeg,image/gif,image/webp" required>
<button type="submit" class="btn">Upload</button>
<button type="button" class="btn" data-hook="avatar-undo" hidden>Undo</button>
</form>
<div class="av-paste" data-hook="avatar-paste" hidden role="textbox" aria-label="paste an image here" tabindex="0"></div>
<p class="pref-note av-hint"><span class="av-limits">PNG, JPEG, GIF or WebP &middot; up to 512&nbsp;KiB &middot; square reads best</span><span class="av-unsaved">Not saved yet &mdash; press Upload.</span></p>
{{if $.HasMark}}<form method="post" action="/me/avatar" class="row-act">
<button type="submit" name="clear" value="1" class="btn"
  data-confirm="You go back to the mark drawn from your name.">Remove picture</button>
</form>{{end}}{{end}}
</div></div>
</section>
{{if and (eq $.SavedPanel "email") (not $.PanelError)}}<p class="sr-only" role="status">Saved.</p>{{end}}
{{if and $.PanelError (eq $.PanelField "email")}}<p class="status err" role="alert"><strong>{{$.PanelError}}</strong></p>{{end}}
{{template "account-email-section" .Email}}
{{if .PasswordAvailable}}<section id="password" data-hook="account-password">
<h2>Password</h2>
{{if eq $.SavedPanel "password"}}<p class="status" role="status">Password changed. Other sessions were signed out.</p>{{end}}
{{if and $.PanelError (eq $.PanelField "password")}}<p class="status err" role="alert"><strong>{{$.PanelError}}</strong></p>{{end}}
<form method="post" action="/me/passwd">
<p><label>Current password<br><input name="current" type="password" autocomplete="current-password" required></label></p>
<p><label>New password<br><input name="new" type="password" autocomplete="new-password" minlength="8" required></label></p>
<p><label>Again<br><input name="again" type="password" autocomplete="new-password" minlength="8" required></label></p>
<p><button type="submit" class="btn">Change password</button></p>
</form>
</section>{{end}}
{{- /* What you may reach. The question a person opens this page with,
       and the one it could not answer: the dashboard drew a Reader
       repository and an Admin one identically, account.explain refused
       you your own letters, and /me listed nothing at all. */ -}}
<section id="access" data-hook="account-access">
<h2>Access</h2>
{{if .Access}}<table class="caps-table">
<thead><tr><th>Repository</th><th>You are</th></tr></thead>
<tbody>{{range .Access}}<tr>
<td><span class="who">{{repoIcon .Repo}} <a href="{{repoURL .Repo}}">{{.Repo}}</a></span></td>
<td>{{if .Role}}{{.Role}}{{else}}<span class="d-sub">no role</span>{{end}}</td>
</tr>{{end}}</tbody></table>
{{else}}<p class="pref-note">No repository has given you a role yet. You can still read and clone
anything public on this forge.</p>{{end}}
{{if .Teams}}<h3>Teams</h3>
<ul class="pref-mutes">{{range .Teams}}<li><a href="/t/{{pathesc .}}">{{.}}</a></li>{{end}}</ul>{{end}}
{{if .Admin}}<p class="pref-note">You are a <strong>site admin</strong>, so you reach every repository
here whatever the table says.</p>{{end}}
</section>
<section id="sessions" data-hook="account-sessions">
<h2>Sessions</h2>
<p class="d-sub">Every device signed in as you. Sign one out if you do not recognise it; sign in
again anywhere with your password.</p>
{{template "session-table" (dict "Sessions" .Sessions "This" .ThisSession "Action" "/me/sessions/end" "ShowAccount" false)}}
{{if gt (len .Sessions) 1}}<form method="post" action="/me/sessions/end"><input type="hidden" name="all" value="1"><p><button type="submit" class="btn-warn">Sign out every other device</button> <span class="tl-meta-inline">this one stays signed in</span></p></form>{{end}}
</section>
<section id="notifications" data-hook="account-notifications">
<h2>Notifications</h2>
{{template "notify-matrix" .Notify}}
</section>
</div>
</div>
{{end}}
{{end}}`))