oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "context"
5 "errors"
6 "io"
7 "mime"
8 "net/http"
9 "net/url"
10
11 "amadan.net/oficina/home/handoff"
12 "amadan.net/oficina/home/internal/home/directorydata"
13 "amadan.net/rastrillo/rastrillo/csrf"
14 "amadan.net/rastrillo/rastrillo/sessions"
15)
16
17const docsEntryPath = "/apps/docs"
18const docsChoicePath = "/apps/docs/team"
19
20type docsChoices interface {
21 Choices(context.Context, string, string) ([]directorydata.Choice, error)
22}
23
24type docsChooser struct {
25 app *App
26 store docsChoices
27 appID, appName, entryPath, choicePath string
28 selectHandler http.Handler
29}
30
31// newDocsChooser prepares handlers without mounting routes or applying schema.
32// The parent directory router must supply its existing sessions middleware and
33// exact-host guard. Selection carries its own same-origin CSRF protection.
34func (a *App) newDocsChooser(store docsChoices) (*docsChooser, error) {
35 return a.newAppChooser(store, "docs")
36}
37
38func (a *App) newAppChooser(store docsChoices, appID string) (*docsChooser, error) {
39 name := ""
40 for _, app := range directoryApps() {
41 if app.slug == appID && app.integrated {
42 name = app.name
43 }
44 }
45 if name == "" {
46 return nil, errExchangeConfig
47 }
48
49 if a == nil || a.runtime == nil || a.runtime.role != "directory" || a.renderer == nil || a.origins.validate() != nil || store == nil {
50 return nil, errors.New("invalid Docs chooser configuration")
51 }
52 c := &docsChooser{app: a, store: store, appID: appID, appName: name, entryPath: "/apps/" + appID, choicePath: "/apps/" + appID + "/team"}
53 c.selectHandler = csrf.Protect(a.origins.Directory)(http.HandlerFunc(c.choose))
54 return c, nil
55}
56func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) {
57 w.Header().Set("Cache-Control", "no-store")
58 if r.Method != http.MethodGet {
59 w.Header().Set("Allow", http.MethodGet)
60 w.WriteHeader(http.StatusMethodNotAllowed)
61 return
62 }
63 if r.URL.Path != c.entryPath || r.URL.RawQuery != "" || r.URL.ForceQuery {
64 http.NotFound(w, r)
65 return
66 }
67 // A preview deployment replaces the team routes for this app, as it
68 // does on the catalog page, so the entry follows it too (spec
69 // shared-chrome §6.2(b)): otherwise a link here from an app's switcher
70 // ends on "No teams available". A preview is an operator choice, not a
71 // team, so it needs no directory session; it goes to the preview's
72 // root, like the single-team redirect below.
73 if preview := c.previewOrigin(); preview != "" {
74 http.Redirect(w, r, preview+"/", http.StatusSeeOther)
75 return
76 }
77 subject, ok := c.subject(w, r)
78 if !ok {
79 return
80 }
81 choices, err := c.store.Choices(r.Context(), subject, c.appID)
82 if err != nil {
83 c.unavailable(w, r)
84 return
85 }
86 switch len(choices) {
87 case 0:
88 c.app.render(w, r, http.StatusOK, "docs_empty.html", map[string]any{"AppName": c.appName})
89 case 1:
90 c.redirect(w, r, choices[0])
91 default:
92 c.app.render(w, r, http.StatusOK, "docs_chooser.html", map[string]any{"Choices": choices, "AppName": c.appName, "ChoicePath": c.choicePath})
93 }
94}
95func (c *docsChooser) Select(w http.ResponseWriter, r *http.Request) {
96 w.Header().Set("Cache-Control", "no-store")
97 if r.Method != http.MethodPost {
98 w.Header().Set("Allow", http.MethodPost)
99 w.WriteHeader(http.StatusMethodNotAllowed)
100 return
101 }
102 if r.URL.Path != c.choicePath || r.URL.RawQuery != "" || r.URL.ForceQuery {
103 http.NotFound(w, r)
104 return
105 }
106 c.selectHandler.ServeHTTP(w, r)
107}
108func (c *docsChooser) choose(w http.ResponseWriter, r *http.Request) {
109 subject, ok := c.subject(w, r)
110 if !ok {
111 return
112 }
113 media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
114 if err != nil || media != "application/x-www-form-urlencoded" {
115 http.NotFound(w, r)
116 return
117 }
118 body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1024))
119 if err != nil {
120 http.NotFound(w, r)
121 return
122 }
123 form, err := url.ParseQuery(string(body))
124 if err != nil || len(form) != 1 || len(form["team"]) != 1 || form.Get("team") == "" {
125 http.NotFound(w, r)
126 return
127 }
128 choices, err := c.store.Choices(r.Context(), subject, c.appID)
129 if err != nil {
130 c.unavailable(w, r)
131 return
132 }
133 for _, choice := range choices {
134 if choice.SQID == form.Get("team") {
135 c.continueToDocs(w, r, choice)
136 return
137 }
138 }
139 http.NotFound(w, r)
140}
141func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, bool) {
142 session, ok := sessions.Current(r)
143 if !ok {
144 target := handoff.StartPath + "?" + url.Values{"return_to": {c.entryPath}}.Encode()
145 if r.Method == http.MethodPost {
146 // Start a new GET navigation after the form response. The submitted
147 // selection is discarded; sign-in returns to current team choices.
148 c.renderContinuation(w, r, c.app.origins.Directory+target)
149 } else {
150 http.Redirect(w, r, target, http.StatusSeeOther)
151 }
152 return "", false
153 }
154 if !ValidPersonRef(session.Subject) {
155 http.NotFound(w, r)
156 return "", false
157 }
158 return session.Subject, true
159}
160func (c *docsChooser) unavailable(w http.ResponseWriter, r *http.Request) {
161 c.app.render(w, r, http.StatusServiceUnavailable, "docs_unavailable.html", nil)
162}
163
164// previewOrigin is the operator's preview deployment for this app, or "".
165// Both settings were validated at construction (validAppPreviews).
166func (c *docsChooser) previewOrigin() string {
167 if c.appID == "docs" && c.app.runtime.docsPreviewOrigin != "" {
168 return c.app.runtime.docsPreviewOrigin
169 }
170 return c.app.runtime.appPreviewOrigins[c.appID]
171}
172
173// redirect and continueToDocs send the browser to the chosen team's app
174// root, not to its /auth/start. The app's own guard starts an assertion
175// exchange only when it has no session of its own, so a switch into an
176// app you are already signed in to costs no exchange and no interstitial
177// (spec shared-chrome §6.2(a)).
178func (c *docsChooser) redirect(w http.ResponseWriter, r *http.Request, choice directorydata.Choice) {
179 // The directorydata store has already applied the configured destination
180 // policy. Generic entry carries only the app root, never a document path.
181 http.Redirect(w, r, choice.Origin+"/", http.StatusSeeOther)
182}
183
184func (c *docsChooser) continueToDocs(w http.ResponseWriter, r *http.Request, choice directorydata.Choice) {
185 if !directorydata.ValidOrigin(choice.Origin) {
186 c.unavailable(w, r)
187 return
188 }
189 c.renderContinuation(w, r, choice.Origin+"/")
190}
191
192// Callers supply either a validated stored app origin plus the app root,
193// or the validated directory origin plus the fixed sign-in continuation path.
194func (c *docsChooser) renderContinuation(w http.ResponseWriter, r *http.Request, destination string) {
195 c.app.renderContinuation(w, r, destination, "Continue to "+c.appName)
196}
197