// abono_pages.go is abono's starter pages, written into this app by // `abono pages`. They are yours now: change the routes, the pages and // the words as you like. Running the command again never overwrites // them. // // Wire them in once, inside the router that runs your session // middleware and csrf.Protect: // // pages := &AbonoPages{ // Service: svc, // Render: render, // Subject: func(r *http.Request) (string, bool) { ... }, // IsOwner: func(r *http.Request) bool { ... }, // SubjectForEmail: func(ctx context.Context, email string) (string, bool) { ... }, // } // pages.Mount(r) // // Render executes one of templates/abono/*.html by its name ("abono/pricing") // with data, which is always a map[string]any: add the request's // localizer as "L" and whatever your layout reads, as a scaffolded app's // render does. Each page defines "content"; its data keys are listed at // the top of its file, and every page's data has "Title", the message // key of the page's title, for your layout's . A page that // answers with something other than 200 has had its status set already: // Render writes the body as usual. // // templates/abono/account.html is not a page but a section, // "abono-account", for your own account page; Account(r) is its data. // Parse it with your layout, beside abono.Templates()'s partials, which // every page here uses. // // The words are message keys (abono.pages.*, abono.owner.*, // abono.error.*, abono.button.*): merge abono.Catalog() into your base // catalog and override or translate any of them in your own locales. package main import ( "context" "errors" "log/slog" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "amadan.net/rastrillo/abono" ) // The pages' addresses. abonoAccountPath is your account page, where the // "abono-account" section is shown and the billing portal comes back to. const ( abonoPricingPath = "/subscribe" abonoDonePath = "/subscribe/done" abonoAccountPath = "/account" abonoBillingPath = "/account/billing" abonoMembersPath = "/admin/members" abonoOffersPath = "/admin/offers" abonoStripePath = "/admin/stripe" ) // AbonoPages is the reader's pages (pricing, a plan, Checkout's return, // the billing button) and the owner's (members, comps, plans and prices, // Stripe). type AbonoPages struct { Service *abono.Service // Render executes the page called name with data, a map[string]any. Render func(w http.ResponseWriter, r *http.Request, name string, data any) // Subject is the signed-in reader's subject, the one abono keys // holdings to; ok is false when nobody is signed in. Subject func(*http.Request) (string, bool) // IsOwner says whether the request may use the owner's pages. Nil // lets nobody in. IsOwner func(*http.Request) bool // SubjectForEmail finds the subject of the person with this address, // for the owner's free-membership form. Nil, or not found, refuses // the form. SubjectForEmail func(ctx context.Context, email string) (subject string, ok bool) // Label is what the owner's pages call the person with this // subject: their address or name. Nil, or "", shows the subject. Label func(ctx context.Context, subject string) string } // Mount adds the pages to r. The reader's pages are open to anyone (the // billing button needs a signed-in reader); the owner's answer 403 to // anyone IsOwner refuses. // // A plan named "done" cannot have a page: GET /subscribe/done is // Checkout's return. func (p *AbonoPages) Mount(r chi.Router) { r.Get(abonoPricingPath, p.pricing) r.Get(abonoDonePath, p.done) r.Get(abonoPricingPath+"/{plan}", p.plan) r.Post(abonoPricingPath+"/{plan}", p.subscribe) r.Post(abonoBillingPath, p.billing) r.Group(func(r chi.Router) { r.Use(p.ownerOnly) r.Get(abonoMembersPath, p.members) r.Post(abonoMembersPath+"/sync", p.sync) r.Post(abonoMembersPath+"/comps", p.comp) r.Get(abonoMembersPath+"/comps/{id}/end", p.endCompPage) r.Post(abonoMembersPath+"/comps/{id}/end", p.endComp) r.Get(abonoOffersPath, p.offers) r.Post(abonoOffersPath+"/adopt", p.adopt) r.Post(abonoOffersPath+"/prices", p.addPrice) r.Get(abonoStripePath, p.stripe) }) } // ---------------------------------------------------------------- reader // abonoCard is one plan card: an offer at one interval ("" for the // plan's only price, or when Stripe has not said its interval yet), and // where its form posts. type abonoCard struct { Offer abono.Offer Interval string Action string } // abonoCards is a card per offer and sold interval, monthly before // yearly. An offer sold at one price, or whose prices' intervals are not // known yet, gets one card with no interval; an offer with nothing sold // gets none. A price billed other than every month or every year // (Unsupported) is not sold, so it makes no card. func abonoCards(offers []abono.Offer) []abonoCard { var cards []abonoCard for _, o := range offers { var intervals []string sold, unknown := 0, false for _, pr := range o.Prices { if !pr.Sold || pr.Unsupported() { continue } sold++ if pr.Interval == "" { unknown = true } else if !abonoContains(intervals, pr.Interval) { intervals = append(intervals, pr.Interval) } } if sold == 0 { continue } action := abonoPricingPath + "/" + url.PathEscape(o.Plan) if sold == 1 || unknown { cards = append(cards, abonoCard{Offer: o, Action: action}) continue } for _, iv := range []string{"day", "week", "month", "year"} { if abonoContains(intervals, iv) { cards = append(cards, abonoCard{Offer: o, Interval: iv, Action: action}) } } } return cards } func abonoContains(list []string, s string) bool { for _, v := range list { if v == s { return true } } return false } // forSale is the offers with a card: none while Stripe is off. func (p *AbonoPages) forSale(ctx context.Context) ([]abono.Offer, []abonoCard, error) { if !p.Service.On() { return nil, nil, nil } offers, err := p.Service.Offers(ctx) if err != nil { return nil, nil, err } return offers, abonoCards(offers), nil } // GET /subscribe: every plan on sale. func (p *AbonoPages) pricing(w http.ResponseWriter, r *http.Request) { _, cards, err := p.forSale(r.Context()) if err != nil { p.fail(w, r, "offers", err) return } p.render(w, r, http.StatusOK, "abono/pricing", map[string]any{ "Title": "abono.pages.pricing_title", "Open": len(cards) > 0, "Cards": cards, }) } // GET /subscribe/{plan}: one plan, with a promotion code field. func (p *AbonoPages) plan(w http.ResponseWriter, r *http.Request) { p.renderPlan(w, r, http.StatusOK, chi.URLParam(r, "plan"), "") } // renderPlan shows plan's page, with the message errKey above it when // there is one. A plan that is not on sale shows only the message, or // says nothing is for sale. func (p *AbonoPages) renderPlan(w http.ResponseWriter, r *http.Request, status int, plan, errKey string) { offers, all, err := p.forSale(r.Context()) if err != nil { p.fail(w, r, "offers", err) return } var cards []abonoCard for _, c := range all { if c.Offer.Plan == plan { cards = append(cards, c) } } if len(cards) == 0 && len(offers) > 0 && errKey == "" { status, errKey = http.StatusNotFound, "abono.error.unknown_plan" } _, signedIn := p.subject(r) p.render(w, r, status, "abono/plan", map[string]any{ "Title": "abono.pages.pricing_title", "Open": len(all) > 0, "Cards": cards, "SignedIn": signedIn, "PlansURL": abonoPricingPath, "Error": errKey, }) } // POST /subscribe/{plan}: start Checkout for the plan at the posted // interval, as the signed-in reader or as nobody yet. func (p *AbonoPages) subscribe(w http.ResponseWriter, r *http.Request) { plan := chi.URLParam(r, "plan") if !p.Service.On() { p.renderPlan(w, r, http.StatusServiceUnavailable, plan, "abono.error.not_available") return } subject, _ := p.subject(r) u, err := p.Service.Checkout(r.Context(), abono.CheckoutRequest{ Plan: plan, Interval: r.PostFormValue("interval"), Subject: subject, PromotionCode: r.PostFormValue("code"), SuccessPath: abonoDonePath, CancelPath: abonoPricingPath + "/" + url.PathEscape(plan), }) switch { case errors.Is(err, abono.ErrUnknownCode): p.renderPlan(w, r, http.StatusUnprocessableEntity, plan, "abono.error.unknown_code") case errors.Is(err, abono.ErrUnknownPlan): p.renderPlan(w, r, http.StatusNotFound, plan, "abono.error.unknown_plan") case err != nil: slog.ErrorContext(r.Context(), "abono pages: checkout", "plan", plan, "err", err) p.renderPlan(w, r, http.StatusBadGateway, plan, "abono.error.unreachable") default: http.Redirect(w, r, u, http.StatusSeeOther) } } // GET /subscribe/done: Checkout's return. Finish records the payment at // once, so the account page already shows it. func (p *AbonoPages) done(w http.ResponseWriter, r *http.Request) { _, signedIn := p.subject(r) data := map[string]any{ "Title": "abono.pages.done_unpaid_title", "Paid": false, "Processing": false, "Email": "", "SignedIn": signedIn, "AccountURL": abonoAccountPath, "PlansURL": abonoPricingPath, "Error": "", } id := r.URL.Query().Get("session_id") switch { case !p.Service.On(): data["Error"] = "abono.error.not_available" p.render(w, r, http.StatusServiceUnavailable, "abono/done", data) return case id == "": p.render(w, r, http.StatusOK, "abono/done", data) return } f, err := p.Service.Finish(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "abono pages: finish", "err", err) data["Error"] = "abono.error.finish_failed" p.render(w, r, http.StatusBadGateway, "abono/done", data) return } switch { case f.Paid: data["Title"], data["Paid"], data["Email"] = "abono.pages.done_title", true, f.Email case f.Processing: // Complete, the money still on its way: not "no payment was made", // and no second purchase. data["Title"], data["Processing"] = "abono.pages.done_title", true } p.render(w, r, http.StatusOK, "abono/done", data) } // POST /account/billing: the signed-in reader's billing portal for the // posted customer, one of theirs, coming back to the account page. A // failure goes back there too, saying why. func (p *AbonoPages) billing(w http.ResponseWriter, r *http.Request) { back := func(problem string) { http.Redirect(w, r, abonoAccountPath+"?billing="+problem, http.StatusSeeOther) } subject, ok := p.subject(r) switch { case !ok: back("signin") return case !p.Service.On(): back("off") return } u, err := p.Service.Portal(r.Context(), subject, r.PostFormValue("customer"), abonoAccountPath) switch { case errors.Is(err, abono.ErrNoCustomer): back("none") case err != nil: slog.ErrorContext(r.Context(), "abono pages: portal", "err", err) back("unreachable") default: http.Redirect(w, r, u, http.StatusSeeOther) } } // abonoBillingProblems are the ?billing= values billing sends the reader // back with, and what each says. Anything else says nothing. var abonoBillingProblems = map[string]string{ "signin": "abono.error.signin_needed", "off": "abono.error.not_available", "none": "abono.error.no_billing", "unreachable": "abono.error.unreachable", } // Account is the data for the "abono-account" section of your account // page: the reader's holdings, a billing button for each Stripe customer // they pay through (even one whose subscription no plan recognises: it is // still charged), and the problem the billing button came back with. Add // the request's localizer before rendering it: // // {{template "abono-account" (withL .Abono $L)}} func (p *AbonoPages) Account(r *http.Request) (map[string]any, error) { subject, _ := p.subject(r) holdings, err := p.Service.Holdings(r.Context(), subject) if err != nil { return nil, err } granted := false for _, h := range holdings { granted = granted || h.Grants } var customers []string if p.Service.On() { if customers, err = p.Service.Customers(r.Context(), subject); err != nil { return nil, err } } return map[string]any{ "Holdings": holdings, "Now": p.Service.Now(), "Manage": len(customers) > 0, "Customers": customers, "Granted": granted, "BillingAction": abonoBillingPath, "PlansURL": abonoPricingPath, "Error": abonoBillingProblems[r.URL.Query().Get("billing")], }, nil } // ---------------------------------------------------------------- owner func (p *AbonoPages) ownerOnly(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if p.IsOwner == nil || !p.IsOwner(r) { http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // abonoMember is one row of the members table. type abonoMember struct { abono.Member // Label is what to call the member (AbonoPages.Label). Label string // EndURL is the confirm page that ends a comp still granting; "" // for anything else. EndURL string } // abonoCompForm is the free-membership form as posted, and what was wrong // with it: message keys, by field. type abonoCompForm struct { Member, Plan, Ends, Note string Errors map[string]string } // abonoNotice is what a finished owner action says on the page it // returns to: Kind is sync, comp, end or adopt; N and Plan fill its // sentence. type abonoNotice struct { Kind string N int Plan string } // abonoReadNotice reads the ?done= an owner action redirected with. A plan that // is not one of offers is dropped, so a link cannot put words on the page. func abonoReadNotice(r *http.Request, offers []abono.Offer) *abonoNotice { q := r.URL.Query() n := &abonoNotice{Kind: q.Get("done")} switch n.Kind { case "sync": n.N, _ = strconv.Atoi(q.Get("n")) case "comp", "adopt": for _, o := range offers { if o.Plan == q.Get("plan") { n.Plan = o.Name } } if n.Plan == "" { return nil } case "end": default: return nil } return n } // GET /admin/members: every holding, sync now, and give a comp. func (p *AbonoPages) members(w http.ResponseWriter, r *http.Request) { p.renderMembers(w, r, http.StatusOK, abonoCompForm{}, "") } func (p *AbonoPages) renderMembers(w http.ResponseWriter, r *http.Request, status int, form abonoCompForm, errKey string) { ctx := r.Context() ms, err := p.Service.Members(ctx) if err != nil { p.fail(w, r, "members", err) return } offers, err := p.Service.Offers(ctx) if err != nil { p.fail(w, r, "offers", err) return } var unknown []abonoUnrecognised if p.Service.On() { if unknown, err = p.unrecognised(ctx); err != nil { p.fail(w, r, "unrecognised", err) return } } rows := make([]abonoMember, len(ms)) for i, m := range ms { rows[i] = abonoMember{Member: m, Label: p.label(ctx, m.Subject)} if m.Source == abono.SourceComp && m.Grants { rows[i].EndURL = abonoMembersPath + "/comps/" + strconv.FormatInt(m.CompID, 10) + "/end" } } p.render(w, r, status, "abono/owner-members", map[string]any{ "Title": "abono.owner.members_title", "On": p.Service.On(), "Members": rows, "Unrecognised": unknown, "Now": p.Service.Now(), "Offers": offers, "PlanOptions": abonoPlanOptions(offers, form.Plan), "AdoptOptions": abonoPlanOptions(offers, ""), "Form": form, "SyncAction": abonoMembersPath + "/sync", "CompAction": abonoMembersPath + "/comps", "AdoptAction": abonoOffersPath + "/adopt", "StripeURL": abonoStripePath, "Done": abonoReadNotice(r, offers), "Error": errKey, }) } // POST /admin/members/sync: Reconcile now. Reconcile keeps every // subscription it could sync when others fail, so the page says nothing // changed only when no subscription was synced. func (p *AbonoPages) sync(w http.ResponseWriter, r *http.Request) { rep, err := p.Service.Reconcile(r.Context()) if err != nil { slog.ErrorContext(r.Context(), "abono pages: sync", "err", err) key := "abono.error.sync_failed" if rep.Subscriptions > rep.Failed { key = "abono.error.sync_partial" } p.renderMembers(w, r, http.StatusBadGateway, abonoCompForm{}, key) return } http.Redirect(w, r, abonoMembersPath+"?done=sync&n="+strconv.Itoa(rep.Subscriptions), http.StatusSeeOther) } // POST /admin/members/comps: give the person with the posted address // a plan, until the posted day or for good. func (p *AbonoPages) comp(w http.ResponseWriter, r *http.Request) { ctx := r.Context() form := abonoCompForm{ Member: strings.TrimSpace(r.PostFormValue("member")), Plan: r.PostFormValue("plan"), Ends: strings.TrimSpace(r.PostFormValue("ends")), Note: strings.TrimSpace(r.PostFormValue("note")), Errors: map[string]string{}, } var ends *time.Time if form.Ends != "" { t, err := time.Parse("2006-01-02", form.Ends) if err != nil { // A date field always posts this format; anything else is not a browser. http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } ends = &t } offers, err := p.Service.Offers(ctx) if err != nil { p.fail(w, r, "offers", err) return } if !abonoIsPlan(offers, form.Plan) { form.Errors["plan"] = "abono.error.free_plan" } subject, found := "", false if p.SubjectForEmail != nil && form.Member != "" { subject, found = p.SubjectForEmail(ctx, form.Member) } if !found { form.Errors["member"] = "abono.owner.free_no_member" } if len(form.Errors) > 0 { p.renderMembers(w, r, http.StatusUnprocessableEntity, form, "") return } if _, err := p.Service.Comp(ctx, subject, form.Plan, ends, form.Note); err != nil { p.fail(w, r, "comp", err) return } http.Redirect(w, r, abonoMembersPath+"?done=comp&plan="+url.QueryEscape(form.Plan), http.StatusSeeOther) } // abonoOption is one choice of a field-select. type abonoOption struct { Value, Label string Selected bool } // abonoPlanOptions is every offer as a choice, selected marked. func abonoPlanOptions(offers []abono.Offer, selected string) []abonoOption { opts := make([]abonoOption, len(offers)) for i, o := range offers { opts[i] = abonoOption{Value: o.Plan, Label: o.Name, Selected: o.Plan == selected} } return opts } func abonoIsPlan(offers []abono.Offer, plan string) bool { for _, o := range offers { if o.Plan == plan { return true } } return false } // compFor is the comp {id} names, while it still grants. func (p *AbonoPages) compFor(r *http.Request) (abono.Member, bool, error) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { return abono.Member{}, false, nil } ms, err := p.Service.Members(r.Context()) if err != nil { return abono.Member{}, false, err } for _, m := range ms { if m.Source == abono.SourceComp && m.CompID == id && m.Grants { return m, true, nil } } return abono.Member{}, false, nil } // GET /admin/members/comps/{id}/end: are you sure? func (p *AbonoPages) endCompPage(w http.ResponseWriter, r *http.Request) { m, ok, err := p.compFor(r) switch { case err != nil: p.fail(w, r, "members", err) return case !ok: http.NotFound(w, r) return } offers, err := p.Service.Offers(r.Context()) if err != nil { p.fail(w, r, "offers", err) return } plan := m.Plan for _, o := range offers { if o.Plan == m.Plan { plan = o.Name } } p.render(w, r, http.StatusOK, "abono/owner-end", map[string]any{ "Title": "abono.owner.end_title", "Member": m, "Label": p.label(r.Context(), m.Subject), "Plan": plan, "Action": r.URL.Path, "MembersURL": abonoMembersPath, }) } // POST /admin/members/comps/{id}/end: end it now. func (p *AbonoPages) endComp(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { http.NotFound(w, r) return } switch err := p.Service.EndComp(r.Context(), id); { case errors.Is(err, abono.ErrNotFound): http.NotFound(w, r) case err != nil: p.fail(w, r, "end comp", err) default: http.Redirect(w, r, abonoMembersPath+"?done=end", http.StatusSeeOther) } } // abonoPriceForm is the add-a-price form as posted. type abonoPriceForm struct { Plan, Price string Sold bool } // GET /admin/offers: plans and their prices, a way to add an existing // Stripe price to a plan or put a legacy one back on sale, and paying // subscriptions no plan recognises, each with a way onto a plan. func (p *AbonoPages) offers(w http.ResponseWriter, r *http.Request) { p.renderOffers(w, r, http.StatusOK, abonoPriceForm{}, "") } func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status int, form abonoPriceForm, errKey string) { ctx := r.Context() offers, err := p.Service.Offers(ctx) if err != nil { p.fail(w, r, "offers", err) return } var unknown []abonoUnrecognised if p.Service.On() { if unknown, err = p.unrecognised(ctx); err != nil { p.fail(w, r, "unrecognised", err) return } } p.render(w, r, status, "abono/owner-offers", map[string]any{ "Title": "abono.owner.offers_title", "On": p.Service.On(), "Offers": offers, "Unrecognised": unknown, "PlanOptions": abonoPlanOptions(offers, ""), "AdoptAction": abonoOffersPath + "/adopt", "PriceOptions": abonoPlanOptions(offers, form.Plan), "PriceForm": form, "PriceAction": abonoOffersPath + "/prices", "StripeURL": abonoStripePath, "Done": abonoReadNotice(r, offers), "Error": errKey, }) } // POST /admin/offers/prices: add an existing Stripe price to the posted // plan, on sale (sold) or as a legacy price whose subscribers keep the // plan; posting a price the plan already has changes whether it is on // sale, which is how a legacy price goes back on sale. A price put on // sale replaces the plan's sold price that bills the same way. func (p *AbonoPages) addPrice(w http.ResponseWriter, r *http.Request) { ctx := r.Context() form := abonoPriceForm{ Plan: r.PostFormValue("plan"), Price: strings.TrimSpace(r.PostFormValue("price")), Sold: r.PostFormValue("sold") != "", } offers, err := p.Service.Offers(ctx) if err != nil { p.fail(w, r, "offers", err) return } switch { case !abonoIsPlan(offers, form.Plan): p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.unknown_plan") return case form.Price == "": p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.add_price_failed") return } if err := p.Service.AddPrice(ctx, form.Plan, form.Price, form.Sold); err != nil { slog.ErrorContext(ctx, "abono pages: add price", "plan", form.Plan, "price", form.Price, "err", err) p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.add_price_failed") return } http.Redirect(w, r, abonoOffersPath, http.StatusSeeOther) } // POST /admin/offers/adopt: put an unrecognised subscription, and every // other on its price, onto the posted plan. Its holder is found as a sync // finds one. The form is on the members page and the offers page; a // posted from=members goes back to the first, anything else to the // second. func (p *AbonoPages) adopt(w http.ResponseWriter, r *http.Request) { ctx := r.Context() sub, plan := r.PostFormValue("subscription"), r.PostFormValue("plan") back, again := abonoOffersPath, func(status int, errKey string) { p.renderOffers(w, r, status, abonoPriceForm{}, errKey) } if r.PostFormValue("from") == "members" { back, again = abonoMembersPath, func(status int, errKey string) { p.renderMembers(w, r, status, abonoCompForm{}, errKey) } } offers, err := p.Service.Offers(ctx) if err != nil { p.fail(w, r, "offers", err) return } if !abonoIsPlan(offers, plan) { again(http.StatusUnprocessableEntity, "abono.error.unknown_plan") return } if err := p.Service.Adopt(ctx, sub, "", plan); err != nil { slog.ErrorContext(ctx, "abono pages: adopt", "subscription", sub, "err", err) again(http.StatusUnprocessableEntity, "abono.error.adopt_failed") return } http.Redirect(w, r, back+"?done=adopt&plan="+url.QueryEscape(plan), http.StatusSeeOther) } // GET /admin/stripe: whether Stripe is connected, how, and the webhook's // address. func (p *AbonoPages) stripe(w http.ResponseWriter, r *http.Request) { mode := "off" switch { case p.Service.Sandbox(): mode = "sandbox" case p.Service.On() && p.Service.TestMode(): mode = "test" case p.Service.On(): mode = "live" } p.render(w, r, http.StatusOK, "abono/owner-stripe", map[string]any{ "Title": "abono.owner.stripe_title", "Mode": mode, "WebhookURL": strings.TrimRight(p.Service.Origin(), "/") + abono.WebhookPath, }) } // ---------------------------------------------------------------- plumbing // abonoUnrecognised is a paying subscription no plan recognises, with // what to call its holder when it has one. type abonoUnrecognised struct { abono.Unrecognised Label string } func (p *AbonoPages) unrecognised(ctx context.Context) ([]abonoUnrecognised, error) { us, err := p.Service.Unrecognised(ctx) if err != nil { return nil, err } out := make([]abonoUnrecognised, len(us)) for i, u := range us { out[i] = abonoUnrecognised{Unrecognised: u} if u.Subject != "" { out[i].Label = p.label(ctx, u.Subject) } } return out, nil } // label is what the owner's pages call subject. func (p *AbonoPages) label(ctx context.Context, subject string) string { if p.Label != nil { if l := p.Label(ctx, subject); l != "" { return l } } return subject } func (p *AbonoPages) subject(r *http.Request) (string, bool) { if p.Subject == nil { return "", false } s, ok := p.Subject(r) return s, ok && s != "" } // render hands the page to Render, with the response's status set when it // is not 200. func (p *AbonoPages) render(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) { if status != http.StatusOK { w = &abonoStatusWriter{ResponseWriter: w, status: status} } p.Render(w, r, name, data) } // fail is the answer to a failure no page explains: logged, and a 500. func (p *AbonoPages) fail(w http.ResponseWriter, r *http.Request, what string, err error) { slog.ErrorContext(r.Context(), "abono pages: "+what, "err", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } // abonoStatusWriter writes status instead of the 200 a page's body would // imply, and lets any other status (a Render that fails with a 500) pass. type abonoStatusWriter struct { http.ResponseWriter status int wrote bool } func (s *abonoStatusWriter) WriteHeader(code int) { if s.wrote { return } s.wrote = true if code == http.StatusOK { code = s.status } s.ResponseWriter.WriteHeader(code) } func (s *abonoStatusWriter) Write(b []byte) (int, error) { if !s.wrote { s.WriteHeader(http.StatusOK) } return s.ResponseWriter.Write(b) } func (s *abonoStatusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter }