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 "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
20func 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}
63func (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