Wire Home entry and explicit Docs account connection routes
9 files changed,
+394
−27
cmd/docs/main.go+4 −0internal/docs/app.go+36 −7internal/docs/auth.go+11 −6internal/docs/handlers.go+20 −12internal/docs/home_return.go+79 −0internal/docs/home_return_test.go+82 −0internal/docs/home_routes.go+160 −0internal/docs/views/list.html+1 −1internal/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 |