package main import ( "context" "log/slog" "net/http" "github.com/carlosframework/rastrillo/csrf" "github.com/carlosframework/rastrillo/db" "github.com/carlosframework/rastrillo/migrate" "github.com/carlosframework/rastrillo/password" "github.com/carlosframework/rastrillo/sessions" "github.com/go-chi/chi/v5" "gorm.io/gorm" "amadan.net/rastrillo/idear" ) // app is the wired instance: the database, the roster, and the router. type app struct { db *gorm.DB roster *idear.Roster mux *http.ServeMux site string logger *slog.Logger } // App builds the whole thing and hands back the mux // rastrillo.Options.Mux wants. site is the instance's display name. func App(d *db.DB, origin, site string, logger *slog.Logger) (*http.ServeMux, error) { a, err := newApp(d, origin, site, logger) if err != nil { return nil, err } return a.mux, nil } // newApp is App with the *app kept, for main's seed and for the tests. // // The order below is the order the wiring has to happen in, and every // step of it is load-bearing: // // 1. BootSchema — sessions, idear, then this app (models.go). // 2. sessions, over the writer handle. // 3. the ROSTER, before the identity plugin, because the identity // plugin is configured with two of its methods. // 4. password, with Create wrapped in rs.Admitting. // 5. idear's handlers, with this app's renderers. // 6. the router: CSRF and session resolution app-wide, ONE 404 // renderer shared with idear, the signup POST wrapped in // rs.CarryToken, and every app route inside rs.Require. func newApp(d *db.DB, origin, site string, logger *slog.Logger) (*app, error) { if logger == nil { logger = slog.Default() } if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { return nil, err } writer, err := d.G.DB() if err != nil { return nil, err } sess, err := sessions.New(sessions.Config{DB: writer, Origin: origin, Logger: logger}) if err != nil { return nil, err } a := &app{db: d.G, site: site, logger: logger} // notFound is bound ONCE and handed to two places: idear's // Config.NotFound below, and chi's own NotFound further down. Two // different 404 renderers in one mount is a membership oracle — a // non-member could tell "this route exists but not for me" from // "no such route" by the shape of the page — and it is the one // misconfiguration idear cannot detect at runtime, because both // hooks are valid functions and neither can see the other. Sharing // the function VALUE, rather than writing two renderers that // happen to agree today, is what makes it stay true. notFound := a.renderNotFound rs, err := idear.New(idear.Config{ DB: d.G, // OpenSignUp stays false: this instance is invitation-only. // Turn it on and any address may sign up, arriving at Member. NotFound: notFound, Forbidden: a.renderForbidden, Logger: logger, // Subject is left at its default, sessions.Current(r).Subject, // which is correct for the password plugin: password mints the // decimal user id as the Subject (models.go's subjectFor). // // EmailForSubject is the other half of that join, read the // other way: id → address. Reconciliation // (POST /invitations/{token}) uses it to require that the // invitation was issued to the SIGNED-IN VIEWER's address — // the rule admission already applies. Leave it out and, under // password, a signed-in orphan may spend any live token they // get hold of, at whatever role it carries; idear has no way // to resolve a decimal id on its own. See models.go. EmailForSubject: emailForSubject(d.G), }) if err != nil { return nil, err } a.roster = rs ph, err := password.New(password.Config{ Sessions: sess, Lookup: lookupUser(d.G), // THE ADMISSION SEAM. Not createUser directly: Admitting // decides the role first (claim / invitation / open sign-up / // refusal), calls this app's create only if the answer is yes, // and writes the Member row after it returns. Create: rs.Admitting(createUser(d.G)), RenderSignin: a.renderSignin, RenderSignup: a.renderSignup, Logger: logger, }) if err != nil { return nil, err } hs, err := idear.NewHandlers(idear.HandlerConfig{ Roster: rs, RenderMembers: a.renderMembers, RenderInvitation: a.renderInvitation, // Site is set, not defaulted. The default is the request's // Host header, which is client-supplied, and the invitation // page is public and unauthenticated: an attacker who can // steer Host gets to choose what that page calls this // instance. Site: site, MembersPath: "/members", InvitationPath: "/invitations/", // Deliver is nil, so idear puts the invitation LINK in the // flash notice shown to the admin who minted it. That is what // makes this example runnable with no mail server, and it is // not what a deployed app should do: the token crosses the // wire in a cookie rastrillo/flash does not mark Secure. An // app that can send mail sets Deliver and keeps the token out // of the browser entirely. NewHandlers logs a warning here. // // ClientKey is nil too, which keys the public routes' rate // limiter by the client's own network. That is right for a // direct listener and WRONG behind a reverse proxy, where // every request arrives from the proxy's address and the // limiter collapses into one global bucket. }) if err != nil { return nil, err } r := chi.NewRouter() // App-wide, above every group: CSRF first, then session // resolution. Middleware, not Require — the sign-in and invitation // pages need to know whether there is a session without being // redirected for lacking one. r.Use(csrf.Protect(origin)) r.Use(sess.Middleware) // The same function value idear.Config.NotFound got, above. r.NotFound(notFound) // The identity plugin's own routes. Public: this is the front // door. r.Get("/signin", ph.SigninPage) r.Post("/signin", ph.Signin) r.Get("/signup", ph.SignupPage) // CarryToken IS MANDATORY on the password path. password.Config. // Create receives (ctx, email, hash) and no *http.Request, so // admission cannot read the invitation token off the form itself; // this middleware reads the "invite" field and stashes it in the // context Create does receive. Without it every invited signup is // refused, and the instance is closed to everyone but its first // account. r.Method(http.MethodPost, "/signup", rs.CarryToken(http.HandlerFunc(ph.Signup))) r.Post("/signout", ph.Signout) // idear's two PUBLIC routes, mounted outside every guard: the // invitation lookup and the signed-in reconciliation POST. They // carry their own rate limiter. for _, rt := range hs.Routes() { if rt.Public { r.Method(rt.Method, rt.Pattern, rt.Handler) } } r.Group(func(gr chi.Router) { // The app's session guard. Everything below is signed-in, and // a signed-out GET is redirected here rather than being 404ed // by idear — idear's Require never redirects, and mounting it // outside this group would 404 every request from everyone, // the Owner included. gr.Use(sess.Require) // idear's guarded routes arrive ALREADY wrapped in // Require + RequireRole, in the right order. Mount them; do // not re-wrap them. for _, rt := range hs.Routes() { if !rt.Public { gr.Method(rt.Method, rt.Pattern, rt.Handler) } } // The app's own routes, behind the MEMBERSHIP gate. gr.Group(func(mr chi.Router) { mr.Use(rs.Require) // "/" IS BEHIND Require, and that is not tidiness. // password.Signin runs Lookup, Verify and mint with no // idear involvement at all, so a member who was removed a // moment ago can still MINT a session under password. // Nothing at sign-in stops them. What stops them is // Require, per request, on every route — so a landing page // outside it is a page a removed member can still read. // This app does not have one. mr.Get("/", a.board) mr.Post("/posts", a.createPost) // RequireRole STACKS INSIDE Require: this group already // has Require, so With() adds the rank floor on top of a // viewer that Require has already resolved. Mounted bare // it would answer a stranger 403 — telling them the route // exists — and would run the handler with no membership // check at all. mr.With(rs.RequireRole(idear.RoleAdmin)).Post("/posts/{id}/delete", a.deletePost) }) }) mux := http.NewServeMux() mux.Handle("/", r) a.mux = mux return a, nil }