oficina / docs Public

Wire Home entry and explicit Docs account connection routes

Paul Campbell pushed by paul@keymail.dev 3741f6fc665949ec0d3a93a8a4b7cf6bf8ff2835 parent e8f7bbc
9 files changed, +394 −27
  • cmd/docs/main.go +4 −0
  • internal/docs/app.go +36 −7
  • internal/docs/auth.go +11 −6
  • internal/docs/handlers.go +20 −12
  • internal/docs/home_return.go +79 −0
  • internal/docs/home_return_test.go +82 −0
  • internal/docs/home_routes.go +160 −0
  • internal/docs/views/list.html +1 −1
  • internal/docs/views/show.html +1 −1

CI passed — run details

diff --git a/cmd/docs/main.go b/cmd/docs/main.go
index 69cf7d6..521980f 100644
--- a/cmd/docs/main.go
+++ b/cmd/docs/main.go
@@ -280,6 +280,10 @@ func main() {
SampleDocuments: sampleDocuments,
Logger: logger,
}
+ home := docs.HomeConfig{Issuer: os.Getenv("DOCS_HOME_ISSUER"), ConsumerID: os.Getenv("DOCS_HOME_CONSUMER_ID"), KeysBase64: os.Getenv("DOCS_HOME_KEYS_BASE64"), DEKBase64: os.Getenv("DOCS_HOME_DEK_BASE64")}
+ if home.Issuer != "" || home.ConsumerID != "" || home.KeysBase64 != "" || home.DEKBase64 != "" {
+ cfg.Home = &home
+ }
app, err := docs.New(cfg)
if err != nil {
logger.Error("docs: new", "err", err)
diff --git a/internal/docs/app.go b/internal/docs/app.go
index a5ddb45..35ba45c 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -20,7 +20,8 @@ import (
"strings"
"time"
- "github.com/carlosframework/rastrillo/csrf"
+ "amadan.net/oficina/home/handoff"
+ "github.com/carlosframework/docs/internal/docs/linkstore"
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/mail"
"github.com/carlosframework/rastrillo/migrate"
@@ -32,6 +33,8 @@ import (
// Config configures New.
type Config struct {
+ Home *HomeConfig
+
// DBPath is the SQLite file New opens via rastrillo's db package.
DBPath string
ContentKey string
@@ -284,7 +287,7 @@ type App struct {
// §2): sessions.Schema before auth.Schema — auth's own backfill
// migration reads the sessions table — before this app's own Schema
// (models.go).
-var BootSchema = migrate.Merge(sessions.Schema, authSchema, Schema)
+var BootSchema = migrate.Merge(sessions.Schema, authSchema, Schema, linkstore.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"))
// New opens the database, applies migrations and wires the router. It
// is rastrillo SKILL.md's app.go shape: db.Open for the SQLite handle,
@@ -344,6 +347,10 @@ func New(cfg Config) (*App, error) {
// pending link, or cannot send one, refuses to start rather than
// booting into a broken or (worse) silently-logging-credentials
// sign-in. See auth.go.
+ homeSettings, err := validatedHomeConfig(cfg.Home, cfg.Origin)
+ if err != nil {
+ return nil, err
+ }
instanceKey, err := resolveInstanceKey(cfg)
if err != nil {
return nil, err
@@ -448,17 +455,31 @@ func New(cfg Config) (*App, error) {
h := &handlers{render: render, logger: logger, store: store, hub: newHub(), previews: newPreviews(), origin: cfg.Origin, isMember: list.configured, oneShots: newOneShots()}
+ var home *homeRoutes
+ if homeSettings != nil {
+ home, err = newHomeRoutes(homeSettings, cfg.Origin, d.Writer(), authPlugin, h)
+ if err != nil {
+ rt.Close(context.Background())
+ d.Close()
+ return nil, err
+ }
+ h.homeEnabled = true
+ }
r := chi.NewRouter()
// Mounted above every route, per rastrillo SKILL.md §5: refuses a
// cross-origin POST/PUT/PATCH/DELETE by Sec-Fetch-Site/Origin/
// Referer evidence; GET/HEAD/OPTIONS pass untouched, so this does
// nothing to "/" or "/d/{sqid}" and everything to "/documents",
// "/signin" and "/signout".
- r.Use(csrf.Protect(cfg.Origin))
+ r.Use(homeCSRF(cfg.Origin, home))
// Unauthenticated: the sign-in page and its four handlers, and the
// static assets that page (and the shell around it) needs to
// render at all.
- r.Get("/signin", h.signin)
+ if home == nil {
+ r.Get("/signin", h.signin)
+ } else {
+ home.mountPublic(r)
+ }
// gateBegin, not authPlugin.Begin directly: see AllowedAddresses'
// own doc comment on Config, and gateBegin's own doc comment
// (auth.go), for why the allowlist has to sit in front of Begin
@@ -470,8 +491,8 @@ func New(cfg Config) (*App, error) {
// coincidence needing its own sync mechanism, only something a
// future SigninPath override would have to touch in both places.
r.Post("/signin", gateBegin(authPlugin.Begin, list, "/signin"))
- r.Get("/auth/callback", authPlugin.Callback)
- r.Get("/auth/verify", authPlugin.Verify)
+ r.Get("/auth/callback", localAuthGuard(home, authPlugin.Callback))
+ r.Get("/auth/verify", localAuthGuard(home, authPlugin.Verify))
// The published document, and the only route here no session guards.
// It is authorized by the token in its own path (links.go) rather
// than by who is asking — which is what a capability is, and why it
@@ -491,11 +512,19 @@ func New(cfg Config) (*App, error) {
// sess.Require", here auth's own RequireSession. A signed-out
// GET/HEAD redirects to /signin; anything else 403s.
r.Group(func(r chi.Router) {
- r.Use(authPlugin.RequireSession)
+ if home == nil {
+ r.Use(authPlugin.RequireSession)
+ } else {
+ r.Use(home.bridge.sessions.Require)
+ r.Use(home.bridge.resolver.middleware("/signin/local"))
+ }
// Admission is re-asked per request, not only at sign-in, so
// revoking a guest's last grant ends their access now rather
// than whenever their 30-day session happens to expire.
r.Use(requireAdmission(list, "/signin"))
+ if home != nil {
+ home.mountAccount(r)
+ }
r.Get("/", h.list)
r.Post("/documents", h.create)
// Title search for the jump-to palette (search.go). Inside
diff --git a/internal/docs/auth.go b/internal/docs/auth.go
index c783109..376dc04 100644
--- a/internal/docs/auth.go
+++ b/internal/docs/auth.go
@@ -489,13 +489,18 @@ func requireAdmissionOf(list allowlist, signinPath string, identity func(*http.R
// is left nil on purpose: no passkey step-up, ever, for the reason
// this file's own package comment gives.
func newAuthPlugin(cfg Config, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger) (*rauth.Auth, error) {
+ signedInPath := "/"
+ if cfg.Home != nil {
+ signedInPath = "/auth/local/complete"
+ }
return rauth.New(rauth.Config{
- DB: writer,
- Origin: cfg.Origin,
- InstanceKey: instanceKey,
- Mailer: mailer,
- Authorize: list.admits,
- Logger: logger,
+ SignedInPath: signedInPath,
+ DB: writer,
+ Origin: cfg.Origin,
+ InstanceKey: instanceKey,
+ Mailer: mailer,
+ Authorize: list.admits,
+ Logger: logger,
})
}
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index 6ea164d..4894145 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -33,6 +33,8 @@ const maxUpdateBytes = 1 << 20
// instead: this app reads and writes documents through Store, never
// through a *gorm.DB of its own.
type handlers struct {
+ homeEnabled bool
+
render *renderer
logger *slog.Logger
store *Store
@@ -109,6 +111,8 @@ type handlers struct {
// (rastrillo/auth.DefaultSessionTTL); a page with no way to end one
// on purpose is a real gap on something public, not a cosmetic one.
type listPage struct {
+ HomeLinkLabel string
+
// CanCreate gates the create form in the list header. A guest is
// answered 403 by POST /documents, and a form whose only outcome is a
// bare error page is a control that exists to fail — the same defect
@@ -250,6 +254,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
data := listPage{
+ HomeLinkLabel: h.homeLinkLabel(r),
CanCreate: h.member(r),
Title: rastrillo.T(r, "docs.list.title"),
EmptyTitle: rastrillo.T(r, "docs.list.empty.title"),
@@ -355,6 +360,8 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
// string, so the template can emit it unescaped — Store.HTML's own
// doc comment is where that "trusted markup" decision is made.
type showPage struct {
+ HomeLinkLabel string
+
Title string
HTML template.HTML
SignOutLabel string // see listPage.SignOutLabel's own doc comment
@@ -557,18 +564,19 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
}
data := showPage{
- Title: doc.Title,
- HTML: html,
- SignOutLabel: rastrillo.T(r, "docs.signout.label"),
- BackLabel: rastrillo.T(r, "docs.list.title"),
- Sqid: doc.Sqid,
- CanShare: doc.OwnerID == actor.PersonID,
- CanWrite: h.store.CanWrite(r.Context(), actor, doc.ID),
- ShareLabel: rastrillo.T(r, "docs.share.title"),
- EditorScript: editorScriptPath(),
- SaveStatus: savestatus.Data{Placement: "floating", Retrying: rastrillo.T(r, "docs.editor.status.retrying"), Failed: rastrillo.T(r, "docs.editor.status.failed")},
- JumpTo: newJumpToView(r),
- JumpToScript: jumpToScriptPath(),
+ HomeLinkLabel: h.homeLinkLabel(r),
+ Title: doc.Title,
+ HTML: html,
+ SignOutLabel: rastrillo.T(r, "docs.signout.label"),
+ BackLabel: rastrillo.T(r, "docs.list.title"),
+ Sqid: doc.Sqid,
+ CanShare: doc.OwnerID == actor.PersonID,
+ CanWrite: h.store.CanWrite(r.Context(), actor, doc.ID),
+ ShareLabel: rastrillo.T(r, "docs.share.title"),
+ EditorScript: editorScriptPath(),
+ SaveStatus: savestatus.Data{Placement: "floating", Retrying: rastrillo.T(r, "docs.editor.status.retrying"), Failed: rastrillo.T(r, "docs.editor.status.failed")},
+ JumpTo: newJumpToView(r),
+ JumpToScript: jumpToScriptPath(),
Toolbar: newToolbarView(r),
Menu: newMenuView(r, doc.Sqid, h.member(r)),
diff --git a/internal/docs/home_return.go b/internal/docs/home_return.go
new file mode 100644
index 0000000..71962e5
--- /dev/null
+++ b/internal/docs/home_return.go
@@ -0,0 +1,79 @@
+package docs
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "math"
+ "net/http"
+
+ "amadan.net/oficina/home/handoff"
+)
+
+const homeReturnCookie = "__Host-oficina-docs-home-return"
+const homeReturnLimit = 4096
+
+type homeReturnIntent struct {
+ Path string `json:"path"`
+ Issued int64 `json:"issued"`
+ Expires int64 `json:"expires"`
+}
+
+// Resource navigation is separate from the fixed-action local reauth marker.
+// Neither cookie authorizes linking or access to the requested document.
+func (n *localLinkNavigation) rememberReturn(w http.ResponseWriter, path string) bool {
+ if !handoff.SafePath(path) {
+ path = "/"
+ }
+ now := n.now().Unix()
+ if now <= 0 || now > math.MaxInt64-localLinkNavigationLifetime {
+ return false
+ }
+ raw, err := json.Marshal(homeReturnIntent{path, now, now + localLinkNavigationLifetime})
+ if err != nil {
+ return false
+ }
+ sealed, err := n.sealer.Seal("docs.home-return.v1:"+n.origin, raw)
+ if err != nil {
+ return false
+ }
+ value := base64.RawURLEncoding.EncodeToString(sealed)
+ if len(value) > homeReturnLimit {
+ return false
+ }
+ localNavigationCookie(w, homeReturnCookie, value, int(localLinkNavigationLifetime))
+ return true
+}
+
+func (n *localLinkNavigation) returnPath(r *http.Request) string {
+ value := ""
+ if cookieCount(r, homeReturnCookie) != 1 {
+ return "/"
+ }
+ cookie, err := r.Cookie(homeReturnCookie)
+ if err != nil {
+ return "/"
+ }
+ value = cookie.Value
+ if value == "" || len(value) > homeReturnLimit {
+ return "/"
+ }
+ sealed, err := base64.RawURLEncoding.Strict().DecodeString(value)
+ if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != value {
+ return "/"
+ }
+ raw, err := n.sealer.Open("docs.home-return.v1:"+n.origin, sealed)
+ if err != nil || len(raw) > homeReturnLimit {
+ return "/"
+ }
+ var intent homeReturnIntent
+ if json.Unmarshal(raw, &intent) != nil {
+ return "/"
+ }
+ canonical, _ := json.Marshal(intent)
+ now := n.now().Unix()
+ if !bytes.Equal(raw, canonical) || intent.Issued <= 0 || intent.Issued > now || intent.Expires <= now || intent.Expires <= intent.Issued || intent.Expires-intent.Issued > localLinkNavigationLifetime || !handoff.SafePath(intent.Path) {
+ return "/"
+ }
+ return intent.Path
+}
diff --git a/internal/docs/home_return_test.go b/internal/docs/home_return_test.go
new file mode 100644
index 0000000..2b753d5
--- /dev/null
+++ b/internal/docs/home_return_test.go
@@ -0,0 +1,82 @@
+package docs
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestHomeResourceReturnSurvivesSetupAndRefusesTamper(t *testing.T) {
+ _, n := navigationFixture(t)
+ now := time.Unix(1900000000, 0)
+ n.now = func() time.Time { return now }
+ const target = "/d/private-resource?view=compact"
+ w := httptest.NewRecorder()
+ if !n.rememberReturn(w, target) {
+ t.Fatal("return intent refused")
+ }
+ cookies := w.Result().Cookies()
+ if len(cookies) != 1 || !cookies[0].Secure || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteLaxMode || cookies[0].MaxAge != 600 {
+ t.Fatal("unsafe navigation cookie")
+ }
+ c := cookies[0]
+ r := httptest.NewRequest("GET", navigationOrigin+"/account/home", nil)
+ r.AddCookie(c)
+ if got := n.returnPath(r); got != target {
+ t.Fatal("resource lost across setup", got)
+ }
+ raw, _ := base64.RawURLEncoding.DecodeString(c.Value)
+ if bytes.Contains(raw, []byte("private-resource")) {
+ t.Fatal("resource stored as readable cookie bytes")
+ }
+ raw[len(raw)-1] ^= 1
+ bad := *c
+ bad.Value = base64.RawURLEncoding.EncodeToString(raw)
+ tampered := httptest.NewRequest("GET", navigationOrigin+"/account/home", nil)
+ tampered.AddCookie(&bad)
+ if n.returnPath(tampered) != "/" {
+ t.Fatal("tampered return accepted")
+ }
+ other := *n
+ other.origin = "https://another.docs.example.com"
+ if other.returnPath(r) != "/" {
+ t.Fatal("other team return accepted")
+ }
+ duplicate := r.Clone(r.Context())
+ duplicate.AddCookie(c)
+ if n.returnPath(duplicate) != "/" {
+ t.Fatal("ambiguous return accepted")
+ }
+ now = now.Add(600 * time.Second)
+ if n.returnPath(r) != "/" {
+ t.Fatal("expired return accepted")
+ }
+}
+
+func TestHomeResourceReturnValidatesSealedPathAndLifetime(t *testing.T) {
+ _, n := navigationFixture(t)
+ now := int64(1900000000)
+ n.now = func() time.Time { return time.Unix(now, 0) }
+ for _, v := range []homeReturnIntent{
+ {"https://other.example", now, now + 600},
+ {"//other.example", now, now + 600},
+ {"/auth/start", now, now + 600},
+ {"/d/ok", now + 1, now + 600},
+ {"/d/ok", now, now + 601},
+ } {
+ payload, _ := json.Marshal(v)
+ sealed, err := n.sealer.Seal("docs.home-return.v1:"+n.origin, payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := httptest.NewRequest("GET", navigationOrigin+"/account/home", nil)
+ r.AddCookie(&http.Cookie{Name: homeReturnCookie, Value: base64.RawURLEncoding.EncodeToString(sealed)})
+ if n.returnPath(r) != "/" {
+ t.Fatal("unsafe sealed navigation accepted")
+ }
+ }
+}
diff --git a/internal/docs/home_routes.go b/internal/docs/home_routes.go
new file mode 100644
index 0000000..bae044d
--- /dev/null
+++ b/internal/docs/home_routes.go
@@ -0,0 +1,160 @@
+package docs
+
+import (
+ "database/sql"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/oficina/home/handoff/seal"
+ "github.com/carlosframework/rastrillo"
+ rauth "github.com/carlosframework/rastrillo/auth"
+ "github.com/carlosframework/rastrillo/csrf"
+ "github.com/carlosframework/rastrillo/sessions"
+ "github.com/go-chi/chi/v5"
+)
+
+type homeRoutes struct {
+ bridge *homeBridge
+ navigation *localLinkNavigation
+ handlers *handlers
+}
+
+func newHomeRoutes(settings *homeSettings, origin string, db *sql.DB, auth *rauth.Auth, h *handlers) (*homeRoutes, error) {
+ b, err := newHomeBridge(settings, origin, db, auth, h.render, h.store)
+ if err != nil {
+ return nil, err
+ }
+ sealer, err := seal.New(settings.dek, "oficina/docs")
+ if err != nil {
+ return nil, errHomeConfig
+ }
+ navigation := newLocalLinkNavigation(sealer, b.sessions, origin)
+ if navigation == nil {
+ return nil, errHomeConfig
+ }
+ b.returnPath = navigation.returnPath
+ return &homeRoutes{bridge: b, navigation: navigation, handlers: h}, nil
+}
+
+func cookieCount(r *http.Request, name string) int {
+ n := 0
+ for _, c := range r.Cookies() {
+ if c.Name == name {
+ n++
+ }
+ }
+ return n
+}
+
+// Only the assertion callback crosses origins. Its shared consumer validates
+// the exact issuer without consulting the browser's local session.
+func homeCSRF(origin string, home *homeRoutes) func(http.Handler) http.Handler {
+ protect := csrf.Protect(origin)
+ if home == nil {
+ return protect
+ }
+ return func(next http.Handler) http.Handler {
+ checked := protect(next)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Host != strings.TrimPrefix(origin, "https://") {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Method == http.MethodPost && r.URL.Path == handoff.CallbackPath {
+ next.ServeHTTP(w, r)
+ return
+ }
+ if cookieCount(r, home.bridge.sessionCookie) > 1 {
+ home.bridge.fail(w, r, http.StatusBadRequest)
+ return
+ }
+ checked.ServeHTTP(w, r)
+ })
+ }
+}
+
+// The local plugin resolves the first pending cookie, so reject ambiguity
+// before it can verify a gesture or rotate the existing session.
+func localAuthGuard(home *homeRoutes, next http.HandlerFunc) http.HandlerFunc {
+ if home == nil {
+ return next
+ }
+ return func(w http.ResponseWriter, r *http.Request) {
+ if cookieCount(r, home.bridge.sessionCookie) > 1 || cookieCount(r, "__Host-rastrillo_pending") > 1 {
+ home.bridge.fail(w, r, http.StatusBadRequest)
+ return
+ }
+ next(w, r)
+ }
+}
+
+func (h *homeRoutes) mountPublic(r chi.Router) {
+ r.Get("/signin", h.signin)
+ r.Get("/signin/local", h.handlers.signin)
+ r.Get(localLinkCompletePath, h.navigation.Complete)
+ r.Get(handoff.StartPath, h.bridge.Start)
+ r.Post(handoff.CallbackPath, h.bridge.Callback)
+ r.Get(handoff.FinishPath, h.bridge.Finish)
+}
+
+func (h *homeRoutes) signin(w http.ResponseWriter, r *http.Request) {
+ q, err := url.ParseQuery(r.URL.RawQuery)
+ if err != nil {
+ h.bridge.fail(w, r, http.StatusBadRequest)
+ return
+ }
+ // These are outcomes from the existing local email flow. Keep its form
+ // available even when the browser already has a Home session.
+ if q.Has("sent") || q.Has("err") || q.Has("force") {
+ h.handlers.signin(w, r)
+ return
+ }
+ path := "/"
+ if values, ok := q["return_to"]; ok && len(values) == 1 && handoff.SafePath(values[0]) {
+ path = values[0]
+ }
+ if !h.navigation.rememberReturn(w, path) {
+ h.bridge.fail(w, r, http.StatusServiceUnavailable)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ http.Redirect(w, r, handoff.StartPath+"?"+url.Values{"return_to": {path}}.Encode(), http.StatusSeeOther)
+}
+
+func (h *homeRoutes) mountAccount(r chi.Router) {
+ r.Get("/account/home", func(w http.ResponseWriter, r *http.Request) {
+ s, ok := sessions.Current(r)
+ if _, fresh := freshLocalAuthentication(s, time.Now()); !ok || !fresh {
+ h.navigation.Begin(w, r)
+ return
+ }
+ h.bridge.LinkPage(w, r)
+ })
+ r.Post("/account/home", func(w http.ResponseWriter, r *http.Request) {
+ s, ok := sessions.Current(r)
+ if _, fresh := freshLocalAuthentication(s, time.Now()); !ok || !fresh {
+ w.Header().Set("Cache-Control", "no-store")
+ http.Redirect(w, r, "/account/home", http.StatusSeeOther)
+ return
+ }
+ h.bridge.LinkStart(w, r)
+ })
+ r.Get("/account/home/confirm", h.bridge.ConfirmPage)
+ r.Post("/account/home/confirm", func(w http.ResponseWriter, r *http.Request) {
+ localNavigationCookie(w, homeReturnCookie, "", -1)
+ h.bridge.ConfirmPost(w, r)
+ })
+}
+
+func (h *handlers) homeLinkLabel(r *http.Request) string {
+ if !h.homeEnabled {
+ return ""
+ }
+ if id, ok := currentIdentity(r); ok && id.Session.Method == homeAssertionMethod {
+ return ""
+ }
+ return rastrillo.T(r, "docs.home.menu_action")
+}
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html
index 7caaae0..406bb63 100644
--- a/internal/docs/views/list.html
+++ b/internal/docs/views/list.html
@@ -58,7 +58,7 @@
identical on purpose, and are two definitions rather than one
shared partial because a page template is where a shell block
override has to live for the clone that page is parsed into. */}}
-{{define "account"}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}}
+{{define "account"}}{{if .HomeLinkLabel}}<a href="/account/home">{{.HomeLinkLabel}}</a>{{end}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}}
{{/* thumb — one document's page one, drawn from the line boxes
preview.go reports. aria-hidden because it is decorative: it is
wholly redundant with the document's own title beside it, and
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html
index bebda31..6b3d88e 100644
--- a/internal/docs/views/show.html
+++ b/internal/docs/views/show.html
@@ -79,7 +79,7 @@
share glyph — see showPage.CanShare's comment for why inventing
one here would have shipped an invisible control. */}}
{{define "nav"}}{{if .CanShare}}<a href="/d/{{.Sqid}}/share">{{.ShareLabel}}</a>{{end}}{{end}}
-{{define "account"}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}}
+{{define "account"}}{{if .HomeLinkLabel}}<a href="/account/home">{{.HomeLinkLabel}}</a>{{end}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}}
{{define "content"}}<link rel="stylesheet" href="{{asset "static/estilo-save-status.css"}}"><style>
/* This screen is the one that is not a column of content. It is a
document on a desk, and in both of the applications this layout