WIP: implementer stopped mid-task (session model limit); untested
Saved as-is by the coordinating session so the work isn't lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+877
−84
cmd/calls/main.go+3 −0internal/calls/app.go+17 −2internal/calls/auth.go+144 −21internal/calls/chrome.go+262 −0internal/calls/errors.go+214 −0internal/calls/render.go+33 −61internal/calls/signin_return.go+204 −0
CI failed — run details
CI log
=== vet === go vet ./... go: downloading amadan.net/oficina/home/connect v0.0.0-20260930200947-7a0f19c3a8a7 # amadan.net/oficina/calls/internal/calls internal/calls/auth.go:174:21: undefined: errHomeRemoved internal/calls/auth.go:179:6: a.endRemovedSession undefined (type *app has no field or method endRemovedSession) internal/calls/credits.go:32:33: not enough arguments in call to a.page have (*http.Request, map[string]any) want (*http.Request, frame, map[string]any) internal/calls/handlers_join.go:87:30: not enough arguments in call to a.page have (*http.Request, map[string]any) want (*http.Request, frame, map[string]any) internal/calls/handlers_meetings.go:95:31: not enough arguments in call to a.page have (*http.Request, map[string]any) want (*http.Request, frame, map[string]any) internal/calls/handlers_meetings.go:154:35: not enough arguments in call to a.page have (*http.Request, map[string]any) want (*http.Request, frame, map[string]any) internal/calls/handlers_meetings.go:183:31: not enough arguments in call to a.page have (*http.Request, map[string]any) want (*http.Request, frame, map[string]any) internal/calls/handlers_meetings.go:206:33: not enough arguments in call to a.page have (*http.Request, map[string]any) want (*http.Request, frame, map[string]any) internal/calls/handlers_meetings.go:246:33: not enough arguments in call to a.page have (*http.Request, map[string]any) want (*http.Request, frame, map[string]any) internal/calls/handlers_room.go:71:30: not enough arguments in call to a.page have (*http.Request, map[string]any) want (*http.Request, frame, map[string]any) internal/calls/handlers_room.go:71:30: too many errors # amadan.net/oficina/calls/internal/calls # [amadan.net/oficina/calls/internal/calls] vet: internal/calls/auth.go:174:21: undefined: errHomeRemoved make: *** [Makefile:66: vet] Error 1 amadan: step vet failed: exit status 2 amadan: job failed at step vet — its output is above, not at the tail of this log
diff --git a/cmd/calls/main.go b/cmd/calls/main.go| index 864e85c..7988d12 100644 |
| --- a/cmd/calls/main.go |
| +++ b/cmd/calls/main.go |
| @@ -88,6 +88,9 @@ func main() { |
| // without this the platform has no way to know it should wake the |
| // app to clear one (review C2-2). |
| opts.NextDue = rt.NextDue |
| + // A panic outside the router gets the app's own error page, not |
| + // rastrillo's plain "Something went wrong." (shared chrome §3.3). |
| + opts.ErrorPage = rt.ErrorPage |
| if err := rastrillo.Serve(opts); err != nil { |
| logger.Error("serve failed", "err", err) |
| os.Exit(1) |
diff --git a/internal/calls/app.go b/internal/calls/app.go| index ed1cefe..816ce16 100644 |
| --- a/internal/calls/app.go |
| +++ b/internal/calls/app.go |
| @@ -1,6 +1,7 @@ |
| package calls |
| import ( |
| + "amadan.net/oficina/estilo.git/chrome" |
| "amadan.net/oficina/estilo.git/sky" |
| "amadan.net/oficina/estilo.git/theme" |
| "context" |
| @@ -436,6 +437,12 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| cancel() |
| r := chi.NewRouter() |
| + // Every panic inside the router is the 500 page with a reference and |
| + // the viewer's own chrome (errors.go); an unknown route is the 404 |
| + // page, not chi's plain text (shared chrome spec §3.3). |
| + r.Use(a.recoverToErrorPage) |
| + r.NotFound(a.notFound) |
| + r.MethodNotAllowed(a.methodNotAllowed(r)) |
| // The drive surface (spec §8.2) is mounted at the router root, |
| // outside csrf.Protect and outside the session middlewares: an |
| @@ -457,12 +464,17 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Use(a.nativeResume) |
| r.Get("/native/start", a.nativeStart) |
| r.Get("/help/open-source", a.credits) |
| + // Appearance is inside the CSRF check and outside the session |
| + // group: a session that expired mid-page must not turn the click |
| + // into a sign-in loop (spec §8.3). |
| + r.Post("/appearance", chrome.HandleAppearance) |
| r.Get(signinPath, a.signin) |
| if a.home == nil { |
| r.Post(signinPath, a.gateBegin(authPlugin.Begin)) |
| r.Get("/auth/callback", authPlugin.Callback) |
| r.Get("/auth/verify", authPlugin.Verify) |
| - r.Post("/signout", authPlugin.Signout) |
| + r.Post("/signout", signedOutAfter(authPlugin.Signout)) |
| + r.Get("/signed-out", a.signedOut) |
| } else { |
| r.Post(signinPath, a.home.routes.LocalGuard(a.gateBegin(authPlugin.Begin))) |
| r.Get("/auth/callback", a.home.routes.LocalGuard(authPlugin.Callback)) |
| @@ -546,6 +558,9 @@ type Runtime struct { |
| // NextDue answers rastrillo.Options.NextDue: the next moment this |
| // app has work, or the zero time when nothing is scheduled. |
| NextDue func() time.Time |
| + // ErrorPage answers rastrillo.Options.ErrorPage: the app's own error |
| + // page for a panic outside the router. |
| + ErrorPage func(w http.ResponseWriter, r *http.Request, status int, ref string) |
| } |
| // nextDue is Runtime.NextDue's implementation: the earliest of the next |
| @@ -598,7 +613,7 @@ func App(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *Runtime, e |
| } |
| go a.sweepForever(ctx) |
| go a.tickForever(ctx) |
| - return mux, &Runtime{NextDue: a.nextDue}, nil |
| + return mux, &Runtime{NextDue: a.nextDue, ErrorPage: a.ErrorPage}, nil |
| } |
| // NewForTest builds the app and returns the handler and its test |
diff --git a/internal/calls/auth.go b/internal/calls/auth.go| index 60804e6..dc36afc 100644 |
| --- a/internal/calls/auth.go |
| +++ b/internal/calls/auth.go |
| @@ -12,6 +12,7 @@ import ( |
| "amadan.net/oficina/estilo.git/sky" |
| rauth "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/sessions" |
| + "github.com/keymaildev/signin" |
| ) |
| const signinPath = "/signin" |
| @@ -61,6 +62,16 @@ func (a *app) gateBegin(next http.HandlerFunc) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| _ = r.ParseForm() |
| address := r.FormValue("address") |
| + // Syntax first. The gate below answers every refusal as "sent", so |
| + // a malformed address used to go straight to "Check your email" |
| + // and the field's own error could never show. Whether a string is |
| + // an email address says nothing about who is on the team, so this |
| + // answer is no oracle. It is keymail's own check, the one Begin |
| + // would make. |
| + if _, _, err := signin.SplitAddress(strings.TrimSpace(address)); err != nil { |
| + http.Redirect(w, r, a.signinAgain(r, "err=address"), http.StatusSeeOther) |
| + return |
| + } |
| if !a.mayRequestLink(w, r, address) { |
| http.Redirect(w, r, sentLocation, http.StatusSeeOther) |
| return |
| @@ -160,8 +171,16 @@ func (a *app) requirePerson(next http.Handler) http.Handler { |
| return |
| } |
| p, err := a.personForSession(r.Context(), s) |
| + if errors.Is(err, errHomeRemoved) { |
| + // A Home identity that is definitely no longer admitted: end |
| + // the session and say so, rather than a 404 whose "Sign in" |
| + // would pass straight back through the same live session |
| + // (the loop Astra found in Docs #65 and Sheets #39). |
| + a.endRemovedSession(w, r) |
| + return |
| + } |
| if errors.Is(err, errHomeIdentity) { |
| - a.ErrorPage(w, r, http.StatusNotFound, "") |
| + a.notFound(w, r) |
| return |
| } |
| if err != nil { |
| @@ -183,46 +202,119 @@ func (a *app) requireMember(next http.Handler) http.Handler { |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| p, ok := currentPerson(r) |
| if !ok || !a.isMember(p) { |
| - a.ErrorPage(w, r, http.StatusNotFound, "") |
| + a.notFound(w, r) |
| return |
| } |
| next.ServeHTTP(w, r) |
| }) |
| } |
| +// localSignin serves the sign-in form (GET /signin standalone, |
| +// /signin/local with Home) and the landing spot for every outcome |
| +// rastrillo redirects back to (?sent=1, ?err=rate|address|expired| |
| +// removed|1), in the suite's words (spec §9.2). |
| +// |
| +// Already signed in, it skips the form: 303 to return_to or the meeting |
| +// list. A guest is not signed in for this purpose — signing in is exactly |
| +// what a guest here is asking to do — and a session that no longer |
| +// resolves to a person has been ended by signin before this runs. |
| +// |
| +// return_to is remembered in a sealed cookie (signin_return.go), and a |
| +// plain /signin forgets one left by an abandoned attempt. |
| func (a *app) localSignin(w http.ResponseWriter, r *http.Request) { |
| + q := r.URL.Query() |
| + ret, hasReturn := safeReturn(q) |
| + if v := a.viewerOf(r); v.signedIn() && q.Get("sent") == "" && q.Get("err") == "" && r.URL.Path == signinPath { |
| + dest := "/" |
| + if hasReturn { |
| + dest = ret |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, dest, http.StatusSeeOther) |
| + return |
| + } |
| + switch { |
| + case hasReturn: |
| + a.rememberReturn(w, ret) |
| + case len(q) == 0: |
| + a.forgetReturn(w) |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + if q.Get("sent") == "1" { |
| + render(w, "signin", a.page(r, frame{parts: []string{"Check your email"}, signinPage: true}, map[string]any{ |
| + "Sent": true, |
| + "Notice": chrome.SignInNotice{ |
| + Icon: assets.Path("static/brand/icon.svg"), |
| + App: appName, |
| + Sky: sky.For(r.Host), |
| + Heading: "Check your email", |
| + // rastrillo's Begin redirects to ?sent=1 with no address, so |
| + // this cannot echo it, as Docs, Sheets and Slides cannot. |
| + Body: "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.", |
| + // Back to the form, keeping the return (spec §9.2): the |
| + // remembered page, not a bare /signin, which would forget it |
| + // (Astra on Docs #65, finding 2). |
| + Link: &chrome.Link{Href: a.signinAgain(r, ""), Label: "Use a different address"}, |
| + }, |
| + })) |
| + return |
| + } |
| door := chrome.SignIn{ |
| Icon: assets.Path("static/brand/icon.svg"), |
| - App: "Calls", |
| + App: appName, |
| Sky: sky.For(r.Host), |
| Heading: "Sign in", |
| Action: signinPath, |
| AddressLabel: "Email address", |
| + Help: "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| Submit: "Email me a link", |
| + OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"}, |
| + } |
| + if hasReturn { |
| + door.Return = ret |
| } |
| - switch r.URL.Query().Get("err") { |
| + switch q.Get("err") { |
| case "address": |
| - door.AddressError = "That does not look like an email address." |
| + // An address error belongs to the field, not a banner. |
| + door.AddressError = "That doesn’t look like an email address." |
| case "rate": |
| - door.Error = "Too many sign-in attempts just now. Wait a few minutes and try again." |
| + door.Error = "Too many attempts. Wait a few minutes, then try again." |
| case "expired": |
| - door.Error = "That sign-in link has expired. Ask for a new one." |
| + door.Error = "That link has expired or was already used. Enter your email to get a new one." |
| + case "removed": |
| + door.Error = "Your access has been removed, so you’ve been signed out. Ask whoever runs this team if that’s wrong." |
| case "": |
| default: |
| - door.Error = "Sign-in did not work. Try again." |
| + door.Error = "We couldn’t sign you in. Enter your email to get a new link." |
| } |
| - render(w, "signin", map[string]any{ |
| - "Sent": r.URL.Query().Get("sent") == "1", |
| - "Door": door, |
| - "Notice": chrome.SignInNotice{ |
| - Icon: door.Icon, |
| - App: door.App, |
| - Sky: door.Sky, |
| - Heading: "Check your email", |
| - Body: "A sign in link should be on its way with a link to click.", |
| - Link: &chrome.Link{Href: signinPath, Label: "Use a different email address"}, |
| - }, |
| - }) |
| + render(w, "signin", a.page(r, frame{parts: []string{"Sign in"}, signinPage: true}, map[string]any{"Door": door})) |
| +} |
| + |
| +// signinAgain is the form's own address, keeping whatever page the |
| +// person was on their way to: /signin/local with Home (a local sign-in |
| +// the person chose on purpose, which /signin would turn into the Home |
| +// exchange), /signin otherwise. extra is one more query parameter. |
| +func (a *app) signinAgain(r *http.Request, extra string) string { |
| + path := signinPath |
| + if a.home != nil { |
| + path = "/signin/local" |
| + } |
| + q := url.Values{} |
| + if ret, ok := a.rememberedReturn(r); ok { |
| + q.Set("return_to", ret) |
| + } |
| + out := path |
| + if enc := q.Encode(); enc != "" { |
| + out += "?" + enc |
| + } |
| + if extra != "" { |
| + if strings.Contains(out, "?") { |
| + out += "&" + extra |
| + } else { |
| + out += "?" + extra |
| + } |
| + } |
| + return out |
| } |
| // continueSignedIn is SignedInPath. Task 6 adds the pending-join branch |
| @@ -238,6 +330,14 @@ func (a *app) continueSignedIn(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, dest, http.StatusSeeOther) |
| return |
| } |
| + // Where the person was going when they were asked to sign in |
| + // (signin_return.go), spent either way. |
| + ret, remembered := a.rememberedReturn(r) |
| + a.forgetReturn(w) |
| + if remembered { |
| + http.Redirect(w, r, ret, http.StatusSeeOther) |
| + return |
| + } |
| if a.isMember(p) { |
| http.Redirect(w, r, "/", http.StatusSeeOther) |
| return |
| @@ -268,5 +368,28 @@ func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person) |
| func (a *app) welcome(w http.ResponseWriter, r *http.Request) { |
| p, _ := currentPerson(r) |
| - render(w, "welcome", a.page(r, map[string]any{"Email": p.Email})) |
| + render(w, "welcome", a.page(r, titled("You’re signed in"), map[string]any{"Email": p.Email})) |
| +} |
| + |
| +// signedOut serves GET /signed-out standalone: where Sign out lands. |
| +// With Home configured connect serves it, through renderHome. |
| +func (a *app) signedOut(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + a.renderSignedOut(w, r, "You’re signed out", "You’ve signed out of Calls on this device.", signinPath, "Sign in", http.StatusOK) |
| +} |
| + |
| +// renderSignedOut is the signed-out page, the Oficina door's notice with |
| +// a primary Sign in: standalone in the suite's words (spec §9.2), with |
| +// Home in connect's. |
| +func (a *app) renderSignedOut(w http.ResponseWriter, r *http.Request, heading, body, href, label string, status int) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(status) |
| + render(w, "signedout", a.page(r, frame{parts: []string{"Signed out"}, signinPage: true}, map[string]any{"Notice": chrome.SignInNotice{ |
| + Icon: assets.Path("static/brand/icon.svg"), |
| + App: appName, |
| + Sky: sky.For(r.Host), |
| + Heading: heading, |
| + Body: body, |
| + Link: &chrome.Link{Href: href, Label: label}, |
| + }})) |
| } |
diff --git a/internal/calls/chrome.go b/internal/calls/chrome.go| new file mode 100644 |
| index 0000000..781d314 |
| --- /dev/null |
| +++ b/internal/calls/chrome.go |
| @@ -0,0 +1,262 @@ |
| +package calls |
| + |
| +// The shared chrome (estilo chrome, round 2): every page's bar, account |
| +// menu and <title> are built here, once, so no page can drift from |
| +// another. Before this the shell was rastrillo's topbar at 81px: a raw |
| +// address, a blue underlined "Settings" and a Sign out button that |
| +// wrapped under it, a separate one-item Help dropdown, no nav and no |
| +// current-page marker, and a <title> that named the app on one page out |
| +// of fifteen. |
| +// |
| +// Spec: estilo spec/shared-chrome, docs/superpowers/specs/ |
| +// 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account), |
| +// §10 (titles). Calls adopts after Docs, Sheets and Slides. |
| +// |
| +// The call room keeps no bar: since 2026-09-28 a call is one window in |
| +// the viewport (room.html). Its title menu carries the same items, in |
| +// the same order and words, as the account menu built here (roomMenu). |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + |
| + "amadan.net/oficina/estilo.git/chrome" |
| + estilomenu "amadan.net/oficina/estilo.git/menu" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +// appName is the brand and the last part of every <title>. |
| +const appName = "Calls" |
| + |
| +// Frame is what layout.html reads from every page: the bar, the parts of |
| +// the <title> before the app's name, and the Appearance preference for |
| +// <html data-theme>. |
| +type Frame struct { |
| + Chrome chrome.Bar |
| + TitleParts []string |
| + Theme string |
| +} |
| + |
| +// frame describes one page's chrome. The zero value is a page whose |
| +// <title> is just "Calls" (the meeting list, the app root). |
| +type frame struct { |
| + // parts precede "Calls" in <title>, most specific first. The first |
| + // is the page's h1 (spec §10). |
| + parts []string |
| + // context is the open meeting's title, shown in the bar. |
| + context string |
| + // signinPage is one of the sign-in, sent and signed-out pages: the |
| + // corner is empty and the brand is plain text, since everything it |
| + // could link to is behind the session gate. |
| + signinPage bool |
| + // public is a page reached through a capability (the invitation |
| + // link /j/{token}): "Sign in" carries no return path, because the |
| + // path is the capability and must not be copied into another URL. |
| + public bool |
| +} |
| + |
| +// titled is the frame for a page whose <title> is these parts. |
| +func titled(parts ...string) frame { return frame{parts: parts} } |
| + |
| +// onMeeting is the frame for one of a meeting's pages: the meeting's |
| +// title is the bar's context, and it follows page in the <title>. With |
| +// page empty the meeting is the page (the holding screen, the room). |
| +func onMeeting(page, meeting string) frame { |
| + if page == "" { |
| + return frame{parts: []string{meeting}, context: meeting} |
| + } |
| + return frame{parts: []string{page, meeting}, context: meeting} |
| +} |
| + |
| +// viewer is who the chrome is for: nobody, a guest, or a signed-in |
| +// person, and how they signed in. |
| +type viewer struct { |
| + person Person |
| + method string |
| + ok bool |
| +} |
| + |
| +// signedIn is a person who signed in, as opposed to a per-meeting guest. |
| +func (v viewer) signedIn() bool { return v.ok && !v.person.Anonymous } |
| + |
| +// viewerOf resolves whoever the request is for without requiring |
| +// anybody. Behind requireParticipant that is the person already in the |
| +// context. Anywhere else (an error page, Open source, the invitation |
| +// page) the session is read optionally and without side effects: an |
| +// error page must never create a persons row or end a session, so a |
| +// session that no longer resolves is simply signed out here, and the |
| +// next guarded page is where it is dealt with. |
| +func (a *app) viewerOf(r *http.Request) viewer { |
| + if p, ok := currentPerson(r); ok { |
| + s, _ := sessions.Current(r) |
| + return viewer{person: p, method: s.Method, ok: true} |
| + } |
| + id, ok := a.auth.SessionFrom(r) |
| + if !ok { |
| + return viewer{} |
| + } |
| + p, err := a.lookupPerson(r, sessions.Session{Subject: id.Address, Method: string(id.Method)}) |
| + if err != nil { |
| + return viewer{} |
| + } |
| + return viewer{person: p, method: string(id.Method), ok: true} |
| +} |
| + |
| +// lookupPerson is personForSession without its one write: a first |
| +// sign-in's persons row is created by the guarded pages, never by chrome. |
| +func (a *app) lookupPerson(r *http.Request, s sessions.Session) (Person, error) { |
| + switch s.Method { |
| + case "magiclink", "keymail": |
| + return a.store.existingHomePerson(r.Context(), s.Subject) |
| + } |
| + return a.personForSession(r.Context(), s) |
| +} |
| + |
| +// page builds render's data for a page: data plus "Frame", the chrome, |
| +// and "Viewer", which the room's own menu reads. |
| +func (a *app) page(r *http.Request, f frame, data map[string]any) map[string]any { |
| + if data == nil { |
| + data = map[string]any{} |
| + } |
| + var v viewer |
| + if !f.signinPage { |
| + v = a.viewerOf(r) |
| + } |
| + if v.signedIn() { |
| + data["Viewer"] = Viewer{Email: v.person.Email, Member: a.isMember(v.person)} |
| + } |
| + data["Frame"] = a.frameFor(r, v, f) |
| + return data |
| +} |
| + |
| +// Viewer is the signed-in person, for the room's title menu: who they |
| +// are, and whether the team's pages (Meetings, Settings) are theirs. |
| +type Viewer struct { |
| + Email string |
| + Member bool |
| +} |
| + |
| +// frameFor builds a page's Frame for viewer v. |
| +func (a *app) frameFor(r *http.Request, v viewer, f frame) Frame { |
| + bar := chrome.Bar{ |
| + App: chrome.App{ |
| + Name: appName, |
| + Slug: "calls", |
| + Home: "/", |
| + Icon: assets.Path("static/brand/icon.svg"), |
| + }, |
| + Context: f.context, |
| + Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"}, |
| + } |
| + switch { |
| + case f.signinPage: |
| + bar.App.Home = "" |
| + case v.signedIn(): |
| + bar.Account = a.account(r, v) |
| + bar.Switcher = a.switcher() |
| + if a.isMember(v.person) { |
| + bar.Nav = a.nav(r) |
| + bar.NavLabel = "Calls" |
| + } |
| + case f.public: |
| + // A guest, or nobody, on the invitation page. |
| + bar.App.Home = "" |
| + bar.SignIn = &chrome.Link{Href: signinPath, Label: "Sign in"} |
| + default: |
| + // Nobody, or a guest (spec §8.4: guests get the same corner). |
| + bar.SignIn = &chrome.Link{Href: signinPath + "?return_to=" + url.QueryEscape(returnTarget(r)), Label: "Sign in"} |
| + } |
| + return Frame{ |
| + Chrome: bar, |
| + TitleParts: f.parts, |
| + Theme: chrome.ThemeAttr(chrome.Appearance(r)), |
| + } |
| +} |
| + |
| +// nav is the team's two pages, with the one being shown marked. |
| +func (a *app) nav(r *http.Request) []chrome.Link { |
| + return []chrome.Link{ |
| + {Href: "/", Label: "Meetings", Current: r.URL.Path == "/"}, |
| + {Href: "/settings", Label: "Settings", Current: r.URL.Path == "/settings"}, |
| + } |
| +} |
| + |
| +// returnTarget is where a person should come back to: this page, if it |
| +// is a page (a GET), otherwise the meeting list. |
| +func returnTarget(r *http.Request) string { |
| + if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| + return "/" |
| + } |
| + return r.URL.RequestURI() |
| +} |
| + |
| +// account is the account menu (spec §8.2): who is signed in, "Connect |
| +// your Oficina account" when that means something, Appearance, Help |
| +// (Open source, last), and the one Sign out in the app. Settings is in |
| +// the bar's nav rather than here: it is the team's meeting defaults and |
| +// agents, not this person's account, and a second copy of the link would |
| +// be a second label to keep in step. |
| +func (a *app) account(r *http.Request, v viewer) *chrome.Account { |
| + acc := &chrome.Account{ |
| + TriggerLabel: "Account: " + v.person.Email, |
| + Initials: chrome.Initials("", v.person.Email), |
| + Address: v.person.Email, |
| + Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"}, |
| + Appearance: &chrome.AppearanceMenu{ |
| + Action: "/appearance", |
| + Return: returnTarget(r), |
| + Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"), |
| + }, |
| + Help: []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}}, |
| + SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"}, |
| + } |
| + if a.offersHomeLink(v) { |
| + acc.Items = []estilomenu.Item{{Href: "/account/home", Label: "Connect your Oficina account"}} |
| + } |
| + return acc |
| +} |
| + |
| +// offersHomeLink is whether the account menu offers to connect the |
| +// person's Oficina account: only with Home configured, only to a member |
| +// (the page is behind requireMember), and never to somebody who signed |
| +// in through Home, whose account already is the connection. |
| +func (a *app) offersHomeLink(v viewer) bool { |
| + return a.home != nil && v.signedIn() && a.isMember(v.person) && (v.method == "magiclink" || v.method == "keymail") |
| +} |
| + |
| +// switcher is the app switcher, or nil when no hub directory is |
| +// configured. It shows "All apps" alone and no tiles (Paul's §13 answer |
| +// 2), as Docs, Sheets and Slides do. |
| +func (a *app) switcher() *chrome.Switcher { |
| + if a.cfg.home == nil || a.cfg.home.Directory == "" { |
| + return nil |
| + } |
| + return &chrome.Switcher{ |
| + Label: "Oficina apps", |
| + CurrentLabel: "(this app)", |
| + AllApps: estilomenu.Item{Href: a.cfg.home.Directory + "/", Label: "All apps"}, |
| + } |
| +} |
| + |
| +// roomMenu is the call window's title menu: the account menu's items in |
| +// its order and its words, for a page that has no bar. Standing is the |
| +// one line only the room has. |
| +type roomMenu struct { |
| + Identity string |
| + Nav []chrome.Link |
| + Help []estilomenu.Item |
| + SignOut *chrome.SignOut |
| +} |
| + |
| +func (a *app) roomMenuFor(fr Frame) roomMenu { |
| + m := roomMenu{Nav: fr.Chrome.Nav} |
| + if acc := fr.Chrome.Account; acc != nil { |
| + m.Identity = acc.Address |
| + m.Help = acc.Help |
| + m.SignOut = &acc.SignOut |
| + } else { |
| + m.Help = []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}} |
| + } |
| + return m |
| +} |
| + |
diff --git a/internal/calls/errors.go b/internal/calls/errors.go| new file mode 100644 |
| index 0000000..a3c778f |
| --- /dev/null |
| +++ b/internal/calls/errors.go |
| @@ -0,0 +1,214 @@ |
| +package calls |
| + |
| +// HTML error pages (shared chrome spec §3). Before this an unknown URL |
| +// answered chi's plain-text "404 page not found", with or without |
| +// Accept: text/html, and the handled error pages were titled "Something |
| +// went wrong" whatever had happened, a 404 included. |
| +// |
| +// Which answers are pages is decided by the request, not the route: a |
| +// page is rendered only when Accept names text/html, as every browser |
| +// navigation and form post does. The room's own fetches (the stream, |
| +// signal, ICE and info), the native app's JSON and the share form's JSON |
| +// answer never ask for HTML, so they keep plain answers. room.js reads |
| +// those by status (readRefusal), and the statuses are unchanged. |
| +// |
| +// A meeting that is missing, ended, or not yours is the same 404 |
| +// (AGENTS.md: a row that isn't yours 404s, never 403s). |
| + |
| +import ( |
| + "fmt" |
| + "net/http" |
| + "net/url" |
| + "runtime/debug" |
| + "strings" |
| + |
| + "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/rastrillo/rastrillo" |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// errorKind says which row of spec §3.2 a failure is. |
| +type errorKind int |
| + |
| +const ( |
| + // kindRoute: nothing here for you — an unknown address, or a page |
| + // this session may not open (a guest outside /m/, a non-member on |
| + // Settings). Both are "we can't find that page": which of the two it |
| + // is must not be readable from outside. |
| + kindRoute errorKind = iota |
| + // kindMeeting: the meeting (or its invitation, or a peer in it) is |
| + // missing, ended, or not shared with you. |
| + kindMeeting |
| + // kindStatus: the page for the status itself (5xx). |
| + kindStatus |
| +) |
| + |
| +// errorWords is one row of §3.2: the title part, the heading, the body. |
| +type errorWords struct { |
| + title, heading, body string |
| +} |
| + |
| +func errorCopy(status int, kind errorKind) errorWords { |
| + switch { |
| + case status == http.StatusNotFound && kind == kindMeeting: |
| + return errorWords{"Meeting not available", "This meeting isn’t available", "It may have ended, or it hasn’t been shared with you."} |
| + case status == http.StatusNotFound: |
| + return errorWords{"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."} |
| + case status == http.StatusForbidden: |
| + return errorWords{"No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should."} |
| + case status == http.StatusServiceUnavailable: |
| + return errorWords{"Calls is unavailable", "Calls is unavailable right now", "Try again in a few minutes."} |
| + } |
| + return errorWords{"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."} |
| +} |
| + |
| +// wantsHTML reports whether the request asked for a page. |
| +func wantsHTML(r *http.Request) bool { |
| + return strings.Contains(r.Header.Get("Accept"), "text/html") |
| +} |
| + |
| +// plainError is the answer to a request that did not ask for a page. |
| +func plainError(w http.ResponseWriter, status int) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Error(w, http.StatusText(status), status) |
| +} |
| + |
| +// ErrorPage answers status for a handler: a meeting-shaped 404 (almost |
| +// every handled 404 in Calls is about a meeting or its invitation), the |
| +// 5xx page with the reference rastrillo minted for the log line, or |
| +// plain text for a request that did not ask for a page. It is also |
| +// rastrillo.Options.ErrorPage, for panics outside the router. |
| +func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| + a.errorPage(w, r, status, kindMeeting, ref) |
| +} |
| + |
| +// notFound is the route-shaped 404: the router's own, and the guards'. |
| +func (a *app) notFound(w http.ResponseWriter, r *http.Request) { |
| + a.errorPage(w, r, http.StatusNotFound, kindRoute, "") |
| +} |
| + |
| +// errorPage renders the page inside the normal layout, with the |
| +// viewer's own chrome (read optionally: an error page never signs |
| +// anybody out or creates anything). |
| +func (a *app) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) { |
| + if !wantsHTML(r) { |
| + plainError(w, status) |
| + return |
| + } |
| + c := errorCopy(status, kind) |
| + page := chrome.ErrorPage{ |
| + Status: status, |
| + Title: c.heading, |
| + Body: c.body, |
| + Home: chrome.Link{Href: "/", Label: "Go to Calls"}, |
| + } |
| + // Try again is the same URL, and only for a GET: re-sending a POST |
| + // from an error page is the one thing it must never do. |
| + if status >= 500 && r.Method == http.MethodGet { |
| + page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"} |
| + } |
| + if ref != "" { |
| + page.Ref, page.RefLabel = ref, "Reference:" |
| + } |
| + if back := sameOriginReferer(r, a.cfg.Origin); back != "" && status < 500 { |
| + page.Back = &chrome.Link{Href: back, Label: "Back"} |
| + } |
| + var buf strings.Builder |
| + data := a.page(r, titled(c.title), map[string]any{"Error": page}) |
| + if err := pages["errors"].ExecuteTemplate(&buf, "layout", data); err != nil { |
| + a.logger.Error("calls: render error page", "err", err) |
| + plainError(w, status) |
| + return |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(status) |
| + _, _ = w.Write([]byte(buf.String())) |
| +} |
| + |
| +// sameOriginReferer is the page a person came from, when it is this app |
| +// and not this page: rastrillo's rule for the error page's Back link. An |
| +// unchecked Referer is an open redirect with better manners. |
| +func sameOriginReferer(r *http.Request, origin string) string { |
| + raw := r.Header.Get("Referer") |
| + if raw == "" || origin == "" { |
| + return "" |
| + } |
| + u, err := url.Parse(raw) |
| + if err != nil || u.User != nil || u.Scheme+"://"+u.Host != origin { |
| + return "" |
| + } |
| + back := u.EscapedPath() |
| + if u.RawQuery != "" { |
| + back += "?" + u.RawQuery |
| + } |
| + // Never an invitation path: the Back link would copy the capability |
| + // into this page. |
| + if strings.HasPrefix(back, "/j/") { |
| + return "" |
| + } |
| + if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() { |
| + return "" |
| + } |
| + return back |
| +} |
| + |
| +// methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a |
| +// path that only takes a POST (somebody opening /signout from history) |
| +// is the same 404 page as an unknown route: to a person there is no |
| +// page there. Any other method gets 405 in plain text, with the Allow |
| +// header set here — chi's default sets it, a custom handler does not. |
| +func (a *app) methodNotAllowed(routes chi.Routes) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + var allowed []string |
| + for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { |
| + if routes.Match(chi.NewRouteContext(), m, r.URL.Path) { |
| + allowed = append(allowed, m) |
| + } |
| + } |
| + get := false |
| + for _, m := range allowed { |
| + get = get || m == http.MethodGet |
| + } |
| + if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get { |
| + a.notFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Allow", strings.Join(allowed, ", ")) |
| + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) |
| + } |
| +} |
| + |
| +// routePattern is the matched route, "/m/{sqid}" rather than the path it |
| +// matched: safe to log whatever the path held (/j/ is a capability). |
| +func routePattern(r *http.Request) string { |
| + if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" { |
| + return rc.RoutePattern() |
| + } |
| + return "(unrouted)" |
| +} |
| + |
| +// recoverToErrorPage turns a panic in a handler into the 500 page with a |
| +// reference, logged with the stack under the same reference. The page |
| +// reads the session itself, so it shows the viewer's own chrome |
| +// wherever in the router the panic happened. Panics outside the router |
| +// reach rastrillo.Serve's own recoverer through Options.ErrorPage. |
| +func (a *app) recoverToErrorPage(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + defer func() { |
| + v := recover() |
| + if v == nil { |
| + return |
| + } |
| + if v == http.ErrAbortHandler { |
| + panic(v) |
| + } |
| + ref := rastrillo.NewRef() |
| + // The value is logged as its type only: a panic raised with a |
| + // meeting title in it would be a leak of its own. |
| + a.logger.Error("calls: panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack())) |
| + a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref) |
| + }() |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
diff --git a/internal/calls/render.go b/internal/calls/render.go| index 5a7a145..6bb220c 100644 |
| --- a/internal/calls/render.go |
| +++ b/internal/calls/render.go |
| @@ -7,8 +7,10 @@ import ( |
| "io/fs" |
| "testing/fstest" |
| + "amadan.net/oficina/estilo.git/base" |
| "amadan.net/oficina/estilo.git/callui" |
| "amadan.net/oficina/estilo.git/chrome" |
| + estilomenu "amadan.net/oficina/estilo.git/menu" |
| "amadan.net/oficina/estilo.git/credits" |
| "amadan.net/oficina/estilo.git/sky" |
| "html/template" |
| @@ -26,13 +28,18 @@ var appFS embed.FS |
| var assets = rastrillo.NewAssets(callAssets()) |
| func callAssets() fs.FS { |
| - // callui.css, chrome.css (the sign-in door) and sky.css (the sky |
| - // behind it) come straight from the estilo this module pins, so they |
| - // cannot drift from it. |
| + // estilo's stylesheets and chrome.js come straight from the estilo |
| + // this module pins, so they cannot drift from it: base.css (links), |
| + // menu.css (every floating panel), chrome.css (the bar, the account |
| + // menu and the sign-in door), sky.css (the sky behind the door), |
| + // callui.css (the call's own pieces). |
| files := fstest.MapFS{ |
| - "static/callui.css": &fstest.MapFile{Data: callui.CSS()}, |
| - "static/chrome.css": &fstest.MapFile{Data: chrome.CSS()}, |
| - "static/sky.css": &fstest.MapFile{Data: sky.CSS()}, |
| + "static/estilo/base.css": &fstest.MapFile{Data: base.CSS()}, |
| + "static/estilo/menu.css": &fstest.MapFile{Data: estilomenu.CSS()}, |
| + "static/estilo/chrome.css": &fstest.MapFile{Data: chrome.CSS()}, |
| + "static/estilo/chrome.js": &fstest.MapFile{Data: chrome.JS()}, |
| + "static/estilo/sky.css": &fstest.MapFile{Data: sky.CSS()}, |
| + "static/callui.css": &fstest.MapFile{Data: callui.CSS()}, |
| } |
| if err := fs.WalkDir(appFS, "static", func(path string, entry fs.DirEntry, err error) error { |
| if err != nil { |
| @@ -57,83 +64,48 @@ var pageNames = []string{"index", "signin", "signedout", "errors", "welcome", "l |
| var pages = map[string]*template.Template{} |
| -// doorPages are the pages drawn as the Oficina door: estilo's chrome |
| -// sign-in templates, parsed alongside rastrillo's field and callout |
| -// partials that they call. |
| -var doorPages = map[string]bool{"signin": true, "signedout": true} |
| - |
| func init() { |
| for _, name := range pageNames { |
| pages[name] = template.Must(buildPage(name).ParseFS(credits.Templates(), "templates/credits.html")) |
| - if doorPages[name] { |
| - pages[name] = template.Must(pages[name].ParseFS(chrome.Templates(), "templates/signin.html")) |
| - } |
| if name == "home" { |
| pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html")) |
| } |
| } |
| } |
| -// buildPage parses rastrillo's ui partials first, then this app's own |
| -// partials, then the layout and the page, so a page can call |
| -// confirm-form, field, empty-state and the rest by name with a dict — |
| -// and chat-panel, the one partial Calls owns, the same way. |
| +// buildPage parses rastrillo's ui partials first, then estilo's chrome |
| +// and menu templates (the bar, the account menu, the error page body and |
| +// the sign-in door, which call rastrillo's field and callout partials), |
| +// then this app's own partials, then the layout and the page, so a page |
| +// can call confirm-form, field, empty-state and the rest by name with a |
| +// dict — and chat-panel, the one partial Calls owns, the same way. |
| // |
| // templates/partials/ is parsed for every page rather than per page |
| // that uses it: a partial is defined-but-unused for free, and the |
| // alternative is a second list beside pageNames that has to be kept in |
| // step with the first. |
| func buildPage(name string) *template.Template { |
| - return template.Must( |
| - template.Must( |
| - template.Must(template.New("layout"). |
| - Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| - Funcs(template.FuncMap{"asset": assets.Path}). |
| - ParseFS(ui.Templates(), "*.html")). |
| - ParseFS(appFS, "templates/partials/*.html")). |
| - ParseFS(appFS, "templates/layout.html", "templates/"+name+".html")) |
| + t := template.New("layout"). |
| + Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| + Funcs(template.FuncMap{"asset": assets.Path}) |
| + t = template.Must(t.ParseFS(ui.Templates(), "*.html")) |
| + t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html")) |
| + t = template.Must(t.ParseFS(estilomenu.Templates(), "templates/*.html")) |
| + t = template.Must(t.ParseFS(appFS, "templates/partials/*.html")) |
| + return template.Must(t.ParseFS(appFS, "templates/layout.html", "templates/"+name+".html")) |
| } |
| +// render writes a page. A template failure is logged by nobody and |
| +// answered in the plainest way left: nothing has been written yet, so |
| +// the status can still say so. |
| func render(w http.ResponseWriter, name string, data any) { |
| var buf bytes.Buffer |
| if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil { |
| http.Error(w, "something went wrong", http.StatusInternalServerError) |
| return |
| } |
| - buf.WriteTo(w) |
| -} |
| - |
| -// Viewer is the signed-in person the shell's account corner names, plus |
| -// whether they currently hold member standing — the layout's only use |
| -// for it is deciding whether the Settings link belongs beside sign-out |
| -// (spec's team-only surface: a guest has no agents to manage). |
| -type Viewer struct { |
| - Email string |
| - Member bool |
| -} |
| - |
| -// page builds render's data map for a request that may be signed in: |
| -// it adds "Viewer" when the request carries a signed-in person |
| -// (currentPerson, auth.go), so layout.html's account corner and |
| -// sign-out form appear; a request with no person in context (signin, |
| -// followLink before a session exists) gets data back unchanged, and |
| -// the layout's {{if .Viewer}} renders nothing. A page reached only |
| -// while signed out (templates/signin.html) has no need to call this |
| -// and renders with the plain render(w, name, data) instead. |
| -// |
| -// A method on *app, not a free function, because Viewer.Member needs |
| -// a.isMember — the team as it stands right now (standing.go) — which |
| -// a bare *http.Request cannot answer on its own. |
| -func (a *app) page(r *http.Request, data map[string]any) map[string]any { |
| - if p, ok := currentPerson(r); ok && !p.Anonymous { |
| - data["Viewer"] = Viewer{Email: p.Email, Member: a.isMember(p)} |
| + if w.Header().Get("Content-Type") == "" { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| } |
| - return data |
| -} |
| - |
| -// ErrorPage renders the one error template with the status and the |
| -// reference rastrillo minted for the log line. |
| -func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| - w.WriteHeader(status) |
| - render(w, "errors", a.page(r, map[string]any{"Status": status, "Ref": ref})) |
| + buf.WriteTo(w) |
| } |
diff --git a/internal/calls/signin_return.go b/internal/calls/signin_return.go| new file mode 100644 |
| index 0000000..e22b03c |
| --- /dev/null |
| +++ b/internal/calls/signin_return.go |
| @@ -0,0 +1,204 @@ |
| +package calls |
| + |
| +// Back to where you were (shared chrome spec §9.4). Before this a |
| +// signed-out visit to /settings, a meeting's holding screen or any other |
| +// guarded page went to a bare /signin, and after the emailed link the |
| +// person landed on the meeting list: rastrillo/auth's RequireSession |
| +// drops the path, and its Verify always lands on a fixed SignedInPath. |
| +// |
| +// So the path rides a cookie instead of the link. GET /signin?return_to= |
| +// remembers it, sealed and short-lived, and /continue (SignedInPath) |
| +// goes there once a pending meeting join — which takes precedence, being |
| +// the reason the person signed in at all — has had its turn. With Home |
| +// configured, connect's own Signin remembers return_to for the Home |
| +// exchange; this cookie serves the local link there (/signin/local). |
| +// |
| +// This is a port of Slides' cookie (b19fbda), which Astra reviewed on |
| +// oficina/slides #14: sealed with a key of its own, bound to this origin |
| +// by the purpose, with an issued and an expiry time checked on read, one |
| +// cookie only, strict base64, and handoff.SafePath on the way in and the |
| +// way out, so it can never name another site or loop into /signin. |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "math" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/oficina/home/handoff/seal" |
| +) |
| + |
| +// returnCookieAge is how long a remembered page lasts: a link opened |
| +// after ten minutes, or in another browser, lands on the meeting list. |
| +const returnCookieAge int64 = 600 |
| +const returnCookieLimit = 4096 |
| + |
| +type returnIntent struct { |
| + Path string `json:"path"` |
| + Issued int64 `json:"issued"` |
| + Expires int64 `json:"expires"` |
| +} |
| + |
| +// returnCookieName is host-only, and __Host- prefixed wherever the origin |
| +// is https. |
| +func (a *app) returnCookieName() string { |
| + if a.secure() { |
| + return "__Host-calls_return" |
| + } |
| + return "calls_return" |
| +} |
| + |
| +func (a *app) secure() bool { return strings.HasPrefix(a.cfg.Origin, "https://") } |
| + |
| +// returnSealer keeps the cookie independent of every other sealed value: |
| +// its key is a domain-bound hash of the instance key. A changed key |
| +// invalidates old return cookies, which is safe. |
| +func (a *app) returnSealer() *seal.Sealer { |
| + key := sha256.Sum256([]byte("oficina/calls return v1\x00" + a.cfg.instanceKey)) |
| + s, err := seal.New(key[:], "oficina/calls") |
| + if err != nil { |
| + return nil |
| + } |
| + return s |
| +} |
| + |
| +func (a *app) returnPurpose() string { return "calls.return.v1:" + a.cfg.Origin } |
| + |
| +// safeReturn is the request's return_to, when there is exactly one and |
| +// it is a same-origin path (handoff.SafePath: no scheme, no host, no |
| +// "//", no control characters, not /signin or /auth/*) and not an |
| +// invitation, whose path is the capability itself. |
| +func safeReturn(q url.Values) (string, bool) { |
| + values, ok := q["return_to"] |
| + if !ok || len(values) != 1 || !safeReturnPath(values[0]) { |
| + return "", false |
| + } |
| + return values[0], true |
| +} |
| + |
| +func safeReturnPath(p string) bool { |
| + return handoff.SafePath(p) && !strings.HasPrefix(p, "/j/") |
| +} |
| + |
| +func (a *app) rememberReturn(w http.ResponseWriter, path string) { |
| + s := a.returnSealer() |
| + if !safeReturnPath(path) || s == nil { |
| + return |
| + } |
| + now := a.store.now().Unix() |
| + if now <= 0 || now > math.MaxInt64-returnCookieAge { |
| + return |
| + } |
| + raw, err := json.Marshal(returnIntent{Path: path, Issued: now, Expires: now + returnCookieAge}) |
| + if err != nil { |
| + return |
| + } |
| + sealed, err := s.Seal(a.returnPurpose(), raw) |
| + if err != nil { |
| + return |
| + } |
| + value := base64.RawURLEncoding.EncodeToString(sealed) |
| + if len(value) > returnCookieLimit { |
| + return |
| + } |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: a.returnCookieName(), Value: value, Path: "/", |
| + MaxAge: int(returnCookieAge), HttpOnly: true, Secure: a.secure(), |
| + SameSite: http.SameSiteLaxMode, |
| + }) |
| +} |
| + |
| +func (a *app) forgetReturn(w http.ResponseWriter) { |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: a.returnCookieName(), Value: "", Path: "/", |
| + MaxAge: -1, HttpOnly: true, Secure: a.secure(), |
| + SameSite: http.SameSiteLaxMode, |
| + }) |
| +} |
| + |
| +// rememberedReturn reads the remembered page back, validated again: the |
| +// cookie is a request header like any other. |
| +func (a *app) rememberedReturn(r *http.Request) (string, bool) { |
| + s := a.returnSealer() |
| + if s == nil { |
| + return "", false |
| + } |
| + count := 0 |
| + for _, c := range r.Cookies() { |
| + if c.Name == a.returnCookieName() { |
| + count++ |
| + } |
| + } |
| + if count != 1 { |
| + return "", false |
| + } |
| + c, err := r.Cookie(a.returnCookieName()) |
| + if err != nil || c.Value == "" || len(c.Value) > returnCookieLimit { |
| + return "", false |
| + } |
| + sealed, err := base64.RawURLEncoding.Strict().DecodeString(c.Value) |
| + if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != c.Value { |
| + return "", false |
| + } |
| + raw, err := s.Open(a.returnPurpose(), sealed) |
| + if err != nil || len(raw) > returnCookieLimit { |
| + return "", false |
| + } |
| + var intent returnIntent |
| + if json.Unmarshal(raw, &intent) != nil { |
| + return "", false |
| + } |
| + canonical, _ := json.Marshal(intent) |
| + now := a.store.now().Unix() |
| + if !bytes.Equal(raw, canonical) || intent.Issued <= 0 || intent.Issued > now || intent.Expires <= now || |
| + intent.Expires <= intent.Issued || intent.Expires-intent.Issued > returnCookieAge || !safeReturnPath(intent.Path) { |
| + return "", false |
| + } |
| + return intent.Path, true |
| +} |
| + |
| +// requireSignedInPage is rastrillo/auth's RequireSession with return_to |
| +// for a page: a signed-out GET or HEAD that asked for HTML is answered |
| +// 303 /signin?return_to=<the page>. Everything else — a POST, and the |
| +// room's stream, signal, ICE and info fetches, which never ask for HTML — |
| +// goes to the guard and gets exactly today's answer, which is what |
| +// room.js's readRefusal reads a sign-out from (spec §9.4). |
| +func (a *app) requireSignedInPage(next http.Handler) http.Handler { |
| + guarded := a.auth.RequireSession(next) |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if (r.Method == http.MethodGet || r.Method == http.MethodHead) && wantsHTML(r) { |
| + if _, ok := a.auth.SessionFrom(r); !ok { |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) |
| + return |
| + } |
| + } |
| + guarded.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| +// signedOutAfter wraps rastrillo/auth's Signout, which ends the session |
| +// and redirects to the sign-in form: a page that, with no word about |
| +// what had just happened, read like an error. It goes to /signed-out, |
| +// which says so. |
| +func signedOutAfter(signout http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + signout(&toSignedOut{ResponseWriter: w}, r) |
| + } |
| +} |
| + |
| +// toSignedOut rewrites Signout's Location, at the last moment a header is |
| +// still settable (sameAnswer's technique). |
| +type toSignedOut struct{ http.ResponseWriter } |
| + |
| +func (t *toSignedOut) WriteHeader(status int) { |
| + if t.Header().Get("Location") == signinPath { |
| + t.Header().Set("Location", "/signed-out") |
| + } |
| + t.ResponseWriter.WriteHeader(status) |
| +} |