| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "log/slog" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "time" |
| 9 | |
| 10 | "amadan.net/oficina/estilo.git/chrome" |
| 11 | "amadan.net/oficina/estilo.git/theme" |
| 12 | "amadan.net/oficina/home/handoff" |
| 13 | "amadan.net/rastrillo/rastrillo/assertion" |
| 14 | "amadan.net/rastrillo/rastrillo/csrf" |
| 15 | "amadan.net/rastrillo/rastrillo/db" |
| 16 | "amadan.net/rastrillo/rastrillo/sessions" |
| 17 | "github.com/go-chi/chi/v5" |
| 18 | ) |
| 19 | |
| 20 | func newDirectory(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) { |
| 21 | if !cfg.validAppPreviews() || cfg.role != "directory" || cfg.origins.validate() != nil || cfg.secrets.Sealer == nil || d == nil || cfg.signer != nil || !cfg.roster.valid(cfg.role, cfg.keys) { |
| 22 | return nil, errExchangeConfig |
| 23 | } |
| 24 | if logger == nil { |
| 25 | logger = slog.Default() |
| 26 | } |
| 27 | renderer, err := newRenderer() |
| 28 | if err != nil { |
| 29 | return nil, err |
| 30 | } |
| 31 | sess, err := sessions.New(sessions.Config{DB: d.Writer(), Origin: cfg.origins.Directory, TTL: 30 * 24 * time.Hour, SigninPath: handoff.StartPath, Logger: logger}) |
| 32 | if err != nil { |
| 33 | return nil, err |
| 34 | } |
| 35 | store, err := handoff.NewStore(d.Writer(), cfg.secrets.Sealer, cfg.origins.Credential, cfg.origins.Directory, cfg.keys) |
| 36 | if err != nil { |
| 37 | return nil, err |
| 38 | } |
| 39 | if err = store.Sweep(context.Background(), time.Now()); err != nil { |
| 40 | return nil, err |
| 41 | } |
| 42 | a := &App{runtime: &cfg, origins: cfg.origins, credentialHost: strings.TrimPrefix(cfg.origins.Credential, "https://"), directoryHost: strings.TrimPrefix(cfg.origins.Directory, "https://"), secrets: cfg.secrets, db: d, logger: logger, renderer: renderer, sessions: sess} |
| 43 | a.consumer, err = handoff.NewConsumer(handoff.ConsumerConfig{Store: store, ConsumerID: "directory", CurrentSubject: func(r *http.Request) string { |
| 44 | s, ok := sessions.Current(r) |
| 45 | if !ok { |
| 46 | return "" |
| 47 | } |
| 48 | return s.Subject |
| 49 | }, Complete: func(w http.ResponseWriter, r *http.Request, c assertion.Claims) error { |
| 50 | return a.sessions.SignIn(w, r, sessions.Session{Subject: c.Subject, Method: "assertion", AuthTime: time.Unix(c.AuthTime, 0)}) |
| 51 | }, Fail: func(w http.ResponseWriter, r *http.Request, status int) { |
| 52 | a.failDirectoryHandoff(w, r, status) |
| 53 | }}) |
| 54 | if err != nil { |
| 55 | return nil, err |
| 56 | } |
| 57 | a.directory, err = a.newDirectoryFeatures() |
| 58 | if err != nil { |
| 59 | return nil, err |
| 60 | } |
| 61 | return a, nil |
| 62 | } |
| 63 | func (a *App) directoryRouter() http.Handler { |
| 64 | r := chi.NewRouter() |
| 65 | r.Use(chooserNoStore) |
| 66 | r.Use(a.serveKnownHost) |
| 67 | r.Use(a.chooserCookies) |
| 68 | r.Use(a.sessions.Middleware) |
| 69 | // After the session middleware, so a signed-in person's 404 shows their |
| 70 | // account menu (spec §3.3). |
| 71 | r.NotFound(a.notFound) |
| 72 | r.MethodNotAllowed(a.methodNotAllowed) |
| 73 | a.mountCredits(r) |
| 74 | // This callback alone accepts a cross-origin POST, with its own exact issuer |
| 75 | // check and browser-bound finish. Every other mutation retains CSRF protection. |
| 76 | r.Post(handoff.CallbackPath, a.consumer.Callback) |
| 77 | r.Group(func(r chi.Router) { |
| 78 | r.Use(csrf.Protect(a.origins.Directory)) |
| 79 | for _, chooser := range a.directory.choosers { |
| 80 | r.Get(chooser.entryPath, chooser.Entry) |
| 81 | r.Post(chooser.choicePath, chooser.Select) |
| 82 | } |
| 83 | r.Get(handoff.StartPath, a.consumer.Start) |
| 84 | r.Get(handoff.FinishPath, a.consumer.Finish) |
| 85 | r.Get("/signin", func(w http.ResponseWriter, r *http.Request) { |
| 86 | http.Redirect(w, r, handoff.StartPath, http.StatusSeeOther) |
| 87 | }) |
| 88 | // One URL, two audiences. A visitor with no session gets the front |
| 89 | // page — what Oficina is, the apps, and the way in. A signed-in |
| 90 | // person gets their directory. Gating this behind sessions.Require |
| 91 | // instead, as it was, sends every stranger who types oficina.works |
| 92 | // to a sign-in form: a door with no building attached to it. |
| 93 | // |
| 94 | // The session is READ here, never required, so an expired cookie |
| 95 | // degrades to the front page rather than to a redirect loop. |
| 96 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { |
| 97 | // no-store covers BOTH branches, and it is set before either |
| 98 | // runs. One URL now answers two different bodies keyed on a |
| 99 | // cookie, so a shared cache that stored the anonymous 200 |
| 100 | // would hand the front page to signed-in people. Vary: Cookie |
| 101 | // says the same thing to a cache that honours it; no-store is |
| 102 | // the half that does not depend on it honouring anything. |
| 103 | w.Header().Set("Cache-Control", "no-store") |
| 104 | w.Header().Add("Vary", "Cookie") |
| 105 | // Current reads what Middleware already resolved for this |
| 106 | // request, rather than asking the database a second time. |
| 107 | if _, ok := sessions.Current(r); !ok { |
| 108 | a.handleIndex(w, r) |
| 109 | return |
| 110 | } |
| 111 | a.render(w, r, 200, "directory.html", map[string]any{"Apps": directoryCatalog(*a.runtime)}) |
| 112 | }) |
| 113 | // The account menu's Appearance (spec §8.3): inside Protect, outside |
| 114 | // any session requirement, so a session that expired mid-page does |
| 115 | // not turn the click into a sign-in loop. A host-only cookie, so the |
| 116 | // choice is the directory's own. |
| 117 | r.Post("/appearance", chrome.HandleAppearance) |
| 118 | // Sign out ends both sessions: this one here, then the id.* |
| 119 | // session by handing the POST on. See signoutPath. |
| 120 | r.Post(signoutPath, func(w http.ResponseWriter, r *http.Request) { |
| 121 | w.Header().Set("Cache-Control", "no-store") |
| 122 | a.sessions.SignOut(w, r) |
| 123 | http.Redirect(w, r, a.origins.Credential+signoutPath, http.StatusTemporaryRedirect) |
| 124 | }) |
| 125 | r.Get("/signed-out", func(w http.ResponseWriter, r *http.Request) { |
| 126 | w.Header().Set("Cache-Control", "no-store") |
| 127 | a.render(w, r, 200, "directory_signed_out.html", map[string]any{}) |
| 128 | }) |
| 129 | r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) |
| 130 | mountCSS(r, "/static/tokens.css", staticAsset("tokens.css")) |
| 131 | mountCSS(r, "/static/theme.css", theme.CSS()) |
| 132 | mountBrand(r) |
| 133 | mountCalma(r) |
| 134 | mountSite(r) |
| 135 | }) |
| 136 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| 137 | if req.URL.Path == rosterPath { |
| 138 | a.directory.roster.ServeHTTP(w, req) |
| 139 | return |
| 140 | } |
| 141 | r.ServeHTTP(w, req) |
| 142 | }) |
| 143 | } |
| 144 | |