rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "log/slog"
6 "net/http"
7
8 "github.com/carlosframework/rastrillo/csrf"
9 "github.com/carlosframework/rastrillo/db"
10 "github.com/carlosframework/rastrillo/migrate"
11 "github.com/carlosframework/rastrillo/password"
12 "github.com/carlosframework/rastrillo/sessions"
13 "github.com/go-chi/chi/v5"
14 "gorm.io/gorm"
15
16 "amadan.net/rastrillo/idear"
17)
18
19// app is the wired instance: the database, the roster, and the router.
20type app struct {
21 db *gorm.DB
22 roster *idear.Roster
23 mux *http.ServeMux
24 site string
25 logger *slog.Logger
26}
27
28// App builds the whole thing and hands back the mux
29// rastrillo.Options.Mux wants. site is the instance's display name.
30func App(d *db.DB, origin, site string, logger *slog.Logger) (*http.ServeMux, error) {
31 a, err := newApp(d, origin, site, logger)
32 if err != nil {
33 return nil, err
34 }
35 return a.mux, nil
36}
37
38// newApp is App with the *app kept, for main's seed and for the tests.
39//
40// The order below is the order the wiring has to happen in, and every
41// step of it is load-bearing:
42//
43// 1. BootSchema — sessions, idear, then this app (models.go).
44// 2. sessions, over the writer handle.
45// 3. the ROSTER, before the identity plugin, because the identity
46// plugin is configured with two of its methods.
47// 4. password, with Create wrapped in rs.Admitting.
48// 5. idear's handlers, with this app's renderers.
49// 6. the router: CSRF and session resolution app-wide, ONE 404
50// renderer shared with idear, the signup POST wrapped in
51// rs.CarryToken, and every app route inside rs.Require.
52func newApp(d *db.DB, origin, site string, logger *slog.Logger) (*app, error) {
53 if logger == nil {
54 logger = slog.Default()
55 }
56 if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
57 return nil, err
58 }
59 writer, err := d.G.DB()
60 if err != nil {
61 return nil, err
62 }
63 sess, err := sessions.New(sessions.Config{DB: writer, Origin: origin, Logger: logger})
64 if err != nil {
65 return nil, err
66 }
67
68 a := &app{db: d.G, site: site, logger: logger}
69
70 // notFound is bound ONCE and handed to two places: idear's
71 // Config.NotFound below, and chi's own NotFound further down. Two
72 // different 404 renderers in one mount is a membership oracle — a
73 // non-member could tell "this route exists but not for me" from
74 // "no such route" by the shape of the page — and it is the one
75 // misconfiguration idear cannot detect at runtime, because both
76 // hooks are valid functions and neither can see the other. Sharing
77 // the function VALUE, rather than writing two renderers that
78 // happen to agree today, is what makes it stay true.
79 notFound := a.renderNotFound
80
81 rs, err := idear.New(idear.Config{
82 DB: d.G,
83 // OpenSignUp stays false: this instance is invitation-only.
84 // Turn it on and any address may sign up, arriving at Member.
85 NotFound: notFound,
86 Forbidden: a.renderForbidden,
87 Logger: logger,
88 // Subject is left at its default, sessions.Current(r).Subject,
89 // which is correct for the password plugin: password mints the
90 // decimal user id as the Subject (models.go's subjectFor).
91 //
92 // EmailForSubject is the other half of that join, read the
93 // other way: id → address. Reconciliation
94 // (POST /invitations/{token}) uses it to require that the
95 // invitation was issued to the SIGNED-IN VIEWER's address —
96 // the rule admission already applies. Leave it out and, under
97 // password, a signed-in orphan may spend any live token they
98 // get hold of, at whatever role it carries; idear has no way
99 // to resolve a decimal id on its own. See models.go.
100 EmailForSubject: emailForSubject(d.G),
101 })
102 if err != nil {
103 return nil, err
104 }
105 a.roster = rs
106
107 ph, err := password.New(password.Config{
108 Sessions: sess,
109 Lookup: lookupUser(d.G),
110 // THE ADMISSION SEAM. Not createUser directly: Admitting
111 // decides the role first (claim / invitation / open sign-up /
112 // refusal), calls this app's create only if the answer is yes,
113 // and writes the Member row after it returns.
114 Create: rs.Admitting(createUser(d.G)),
115 RenderSignin: a.renderSignin,
116 RenderSignup: a.renderSignup,
117 Logger: logger,
118 })
119 if err != nil {
120 return nil, err
121 }
122
123 hs, err := idear.NewHandlers(idear.HandlerConfig{
124 Roster: rs,
125 RenderMembers: a.renderMembers,
126 RenderInvitation: a.renderInvitation,
127 // Site is set, not defaulted. The default is the request's
128 // Host header, which is client-supplied, and the invitation
129 // page is public and unauthenticated: an attacker who can
130 // steer Host gets to choose what that page calls this
131 // instance.
132 Site: site,
133 MembersPath: "/members",
134 InvitationPath: "/invitations/",
135 // Deliver is nil, so idear puts the invitation LINK in the
136 // flash notice shown to the admin who minted it. That is what
137 // makes this example runnable with no mail server, and it is
138 // not what a deployed app should do: the token crosses the
139 // wire in a cookie rastrillo/flash does not mark Secure. An
140 // app that can send mail sets Deliver and keeps the token out
141 // of the browser entirely. NewHandlers logs a warning here.
142 //
143 // ClientKey is nil too, which keys the public routes' rate
144 // limiter by the client's own network. That is right for a
145 // direct listener and WRONG behind a reverse proxy, where
146 // every request arrives from the proxy's address and the
147 // limiter collapses into one global bucket.
148 })
149 if err != nil {
150 return nil, err
151 }
152
153 r := chi.NewRouter()
154 // App-wide, above every group: CSRF first, then session
155 // resolution. Middleware, not Require — the sign-in and invitation
156 // pages need to know whether there is a session without being
157 // redirected for lacking one.
158 r.Use(csrf.Protect(origin))
159 r.Use(sess.Middleware)
160 // The same function value idear.Config.NotFound got, above.
161 r.NotFound(notFound)
162
163 // The identity plugin's own routes. Public: this is the front
164 // door.
165 r.Get("/signin", ph.SigninPage)
166 r.Post("/signin", ph.Signin)
167 r.Get("/signup", ph.SignupPage)
168 // CarryToken IS MANDATORY on the password path. password.Config.
169 // Create receives (ctx, email, hash) and no *http.Request, so
170 // admission cannot read the invitation token off the form itself;
171 // this middleware reads the "invite" field and stashes it in the
172 // context Create does receive. Without it every invited signup is
173 // refused, and the instance is closed to everyone but its first
174 // account.
175 r.Method(http.MethodPost, "/signup", rs.CarryToken(http.HandlerFunc(ph.Signup)))
176 r.Post("/signout", ph.Signout)
177
178 // idear's two PUBLIC routes, mounted outside every guard: the
179 // invitation lookup and the signed-in reconciliation POST. They
180 // carry their own rate limiter.
181 for _, rt := range hs.Routes() {
182 if rt.Public {
183 r.Method(rt.Method, rt.Pattern, rt.Handler)
184 }
185 }
186
187 r.Group(func(gr chi.Router) {
188 // The app's session guard. Everything below is signed-in, and
189 // a signed-out GET is redirected here rather than being 404ed
190 // by idear — idear's Require never redirects, and mounting it
191 // outside this group would 404 every request from everyone,
192 // the Owner included.
193 gr.Use(sess.Require)
194
195 // idear's guarded routes arrive ALREADY wrapped in
196 // Require + RequireRole, in the right order. Mount them; do
197 // not re-wrap them.
198 for _, rt := range hs.Routes() {
199 if !rt.Public {
200 gr.Method(rt.Method, rt.Pattern, rt.Handler)
201 }
202 }
203
204 // The app's own routes, behind the MEMBERSHIP gate.
205 gr.Group(func(mr chi.Router) {
206 mr.Use(rs.Require)
207 // "/" IS BEHIND Require, and that is not tidiness.
208 // password.Signin runs Lookup, Verify and mint with no
209 // idear involvement at all, so a member who was removed a
210 // moment ago can still MINT a session under password.
211 // Nothing at sign-in stops them. What stops them is
212 // Require, per request, on every route — so a landing page
213 // outside it is a page a removed member can still read.
214 // This app does not have one.
215 mr.Get("/", a.board)
216 mr.Post("/posts", a.createPost)
217 // RequireRole STACKS INSIDE Require: this group already
218 // has Require, so With() adds the rank floor on top of a
219 // viewer that Require has already resolved. Mounted bare
220 // it would answer a stranger 403 — telling them the route
221 // exists — and would run the handler with no membership
222 // check at all.
223 mr.With(rs.RequireRole(idear.RoleAdmin)).Post("/posts/{id}/delete", a.deletePost)
224 })
225 })
226
227 mux := http.NewServeMux()
228 mux.Handle("/", r)
229 a.mux = mux
230 return a, nil
231}
232