| 1 | package main |
| 2 | |
| 3 | import ( |
| 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. |
| 20 | type 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. |
| 30 | func 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. |
| 52 | func 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 | |