diff --git a/.gitignore b/.gitignore index 8a74e5c88..c972fe993 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ cw.tar node_modules /xesite /xesitectl +/sponsor-panel diff --git a/cmd/sponsor-panel/handlers.go b/cmd/sponsor-panel/handlers.go index 7125f12f2..b5020ad68 100644 --- a/cmd/sponsor-panel/handlers.go +++ b/cmd/sponsor-panel/handlers.go @@ -16,6 +16,8 @@ import ( "github.com/google/go-github/v82/github" "github.com/google/uuid" + adminv1 "xeiaso.net/v4/gen/techaro/thoth/auth/admin/v1" + "xeiaso.net/v4/cmd/sponsor-panel/templates" ) @@ -318,3 +320,82 @@ func renderInviteSuccess(w http.ResponseWriter, username, state string) { func renderLogoSuccess(w http.ResponseWriter, company, issueURL string, issueNumber int) { templates.LogoSuccess(company, issueURL, issueNumber).Render(context.Background(), w) } + +// thothTokenHandler handles POST /thoth-token - issues a Thoth JWT for the user. +func (s *Server) thothTokenHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + slog.Debug("thothTokenHandler: processing token request") + + // Get user from session + user, err := s.getSessionUser(r) + if err != nil { + slog.Error("thothTokenHandler: failed to get session user", "err", err) + renderError(w, "Authentication required", http.StatusUnauthorized) + return + } + + slog.Debug("thothTokenHandler: authenticated user", "user_id", user.ID, "login", user.Login) + + // Check sponsorship tier (any active sponsorship) + if !user.IsSponsorAtTier(100) { + slog.Error("thothTokenHandler: user not a sponsor", "user", user.Login, "user_id", user.ID) + renderError(w, "Requires active sponsorship", http.StatusForbidden) + return + } + + // Create Thoth user if not already provisioned + if user.ThothUserID == nil { + if user.Email == "" { + slog.Error("thothTokenHandler: user has no email address", "user_id", user.ID, "login", user.Login) + renderError(w, "Email address required. Please update your profile.", http.StatusBadRequest) + return + } + + slog.Debug("thothTokenHandler: creating Thoth user", "user_id", user.ID, "login", user.Login) + + resp, err := s.thothClient.AdminUsers.Create(r.Context(), &adminv1.UsersServiceCreateRequest{ + EmailAddress: user.Email, + Name: user.Login, + CustomerId: user.Provider + ":" + user.Login, + }) + if err != nil { + slog.Error("thothTokenHandler: failed to create Thoth user", "err", err, "user_id", user.ID) + renderError(w, "Failed to create Thoth user: "+err.Error(), http.StatusInternalServerError) + return + } + + thothID := resp.GetUser().GetId() + user.ThothUserID = &thothID + + if err := s.db.Save(user).Error; err != nil { + slog.Error("thothTokenHandler: failed to save Thoth user ID", "err", err, "user_id", user.ID) + renderError(w, "Failed to save Thoth user: "+err.Error(), http.StatusInternalServerError) + return + } + + slog.Info("thothTokenHandler: Thoth user created", "user_id", user.ID, "login", user.Login, "thoth_user_id", thothID) + } + + // Issue JWT + slog.Debug("thothTokenHandler: issuing JWT", "user_id", user.ID, "thoth_user_id", *user.ThothUserID) + + jwtResp, err := s.thothClient.AdminUsers.MakeJWT(r.Context(), &adminv1.UsersServiceMakeJWTRequest{ + UserId: *user.ThothUserID, + Comment: "sponsor-panel token for " + user.Login, + }) + if err != nil { + slog.Error("thothTokenHandler: failed to issue JWT", "err", err, "user_id", user.ID) + renderError(w, "Failed to issue token: "+err.Error(), http.StatusInternalServerError) + return + } + + slog.Info("thothTokenHandler: token issued", "user_id", user.ID, "login", user.Login) + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + templates.ThothTokenSuccess(jwtResp.GetTokenInfo().GetJwt()).Render(context.Background(), w) +} diff --git a/cmd/sponsor-panel/internal/thoth/thoth.go b/cmd/sponsor-panel/internal/thoth/thoth.go new file mode 100644 index 000000000..1fee896e7 --- /dev/null +++ b/cmd/sponsor-panel/internal/thoth/thoth.go @@ -0,0 +1,109 @@ +package thoth + +import ( + "context" + "crypto/tls" + "fmt" + "time" + + grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus" + "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/timeout" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + healthv1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/metadata" + adminv1 "xeiaso.net/v4/gen/techaro/thoth/auth/admin/v1" + authv1 "xeiaso.net/v4/gen/techaro/thoth/auth/v1" +) + +type Client struct { + conn *grpc.ClientConn + + Health healthv1.HealthClient + AuthJWT authv1.JWTServiceClient + AdminUsers adminv1.UsersServiceClient +} + +func New(ctx context.Context, thothURL, apiToken string) (*Client, error) { + clMetrics := grpcprom.NewClientMetrics( + grpcprom.WithClientHandlingTimeHistogram( + grpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120}), + ), + ) + prometheus.DefaultRegisterer.Register(clMetrics) + + conn, err := grpc.NewClient( + thothURL, + grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})), + //grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithChainUnaryInterceptor( + timeout.UnaryClientInterceptor(5*time.Minute), + clMetrics.UnaryClientInterceptor(), + authUnaryClientInterceptor(apiToken), + ), + grpc.WithChainStreamInterceptor( + clMetrics.StreamClientInterceptor(), + authStreamClientInterceptor(apiToken), + ), + ) + if err != nil { + return nil, fmt.Errorf("can't dial thoth at %s: %w", thothURL, err) + } + + hc := healthv1.NewHealthClient(conn) + + resp, err := hc.Check(ctx, &healthv1.HealthCheckRequest{}) + if err != nil { + return nil, fmt.Errorf("can't verify thoth health at %s: %w", thothURL, err) + } + + if resp.Status != healthv1.HealthCheckResponse_SERVING { + return nil, fmt.Errorf("thoth is not healthy, wanted %s but got %s", healthv1.HealthCheckResponse_SERVING, resp.Status) + } + + return &Client{ + conn: conn, + Health: hc, + AuthJWT: authv1.NewJWTServiceClient(conn), + AdminUsers: adminv1.NewUsersServiceClient(conn), + }, nil +} + +func (c *Client) Close() error { + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +func authUnaryClientInterceptor(token string) grpc.UnaryClientInterceptor { + return func( + ctx context.Context, + method string, + req interface{}, + reply interface{}, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + md := metadata.Pairs("authorization", "Bearer "+token) + ctx = metadata.NewOutgoingContext(ctx, md) + return invoker(ctx, method, req, reply, cc, opts...) + } +} + +func authStreamClientInterceptor(token string) grpc.StreamClientInterceptor { + return func( + ctx context.Context, + desc *grpc.StreamDesc, + cc *grpc.ClientConn, + method string, + streamer grpc.Streamer, + opts ...grpc.CallOption, + ) (grpc.ClientStream, error) { + md := metadata.Pairs("authorization", "Bearer "+token) + ctx = metadata.NewOutgoingContext(ctx, md) + return streamer(ctx, desc, cc, method, opts...) + } +} diff --git a/cmd/sponsor-panel/main.go b/cmd/sponsor-panel/main.go index bab435cee..086d92ff0 100644 --- a/cmd/sponsor-panel/main.go +++ b/cmd/sponsor-panel/main.go @@ -19,15 +19,16 @@ import ( "github.com/facebookgo/flagenv" gh "github.com/google/go-github/v82/github" "github.com/gorilla/sessions" - slogGorm "github.com/orandin/slog-gorm" - "gorm.io/driver/postgres" - "gorm.io/gorm" - gormPrometheus "gorm.io/plugin/prometheus" _ "github.com/joho/godotenv/autoload" - patreon "gopkg.in/mxpv/patreon-go.v1" + slogGorm "github.com/orandin/slog-gorm" "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/oauth2" "golang.org/x/oauth2/github" + patreon "gopkg.in/mxpv/patreon-go.v1" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormPrometheus "gorm.io/plugin/prometheus" + "xeiaso.net/v4/cmd/sponsor-panel/internal/thoth" "xeiaso.net/v4/internal" "xeiaso.net/v4/web/htmx" ) @@ -57,24 +58,29 @@ var ( patreonCampaignID = flag.String("patreon-campaign-id", "", "Patreon campaign ID to check pledges against") patreonFiftyPlus = flag.String("patreon-fifty-plus", "", "Comma-separated list of Patreon usernames always treated as $50+ sponsors") + // Thoth settings + thothToken = flag.String("thoth-token", "", "Thoth API token (use a god token)") + thothURL = flag.String("thoth-url", "passthrough:///thoth.techaro.lol:443", "URL for the Thoth API server") + //go:embed static staticFS embed.FS ) // Server holds the application dependencies. type Server struct { - db *gorm.DB - ghClient *gh.Client - oauth *oauth2.Config + db *gorm.DB + ghClient *gh.Client + oauth *oauth2.Config patreonOAuth *oauth2.Config // nil if Patreon not configured patreonCampaignID string patreonFiftyPlusSpons map[string]bool // Patreon usernames always treated as $50+ - discordInvite string - fiftyPlusSponsors map[string]bool // Always treated as $50+ sponsors - sessionStore *sessions.CookieStore - cookieSecure bool - bucketName string - s3Client *s3.Client + discordInvite string + fiftyPlusSponsors map[string]bool // Always treated as $50+ sponsors + sessionStore *sessions.CookieStore + cookieSecure bool + bucketName string + s3Client *s3.Client + thothClient *thoth.Client } func main() { @@ -260,19 +266,27 @@ func main() { slog.Info("main: S3 client created", "bucket", *bucketName) } + thothClient, err := thoth.New(context.Background(), *thothURL, *thothToken) + if err != nil { + slog.Error("can't create thoth client", "err", err) + os.Exit(2) + } + slog.Info("thoth client created") + server := &Server{ - db: db, - ghClient: ghClient, - oauth: oauthConfig, + db: db, + ghClient: ghClient, + oauth: oauthConfig, patreonOAuth: patreonConfig, patreonCampaignID: *patreonCampaignID, patreonFiftyPlusSpons: patreonFiftyPlusMap, - discordInvite: *discordInvite, - fiftyPlusSponsors: fiftyPlusMap, - sessionStore: sessionStore, - cookieSecure: *cookieSecure, - bucketName: *bucketName, - s3Client: s3Client, + discordInvite: *discordInvite, + fiftyPlusSponsors: fiftyPlusMap, + sessionStore: sessionStore, + cookieSecure: *cookieSecure, + bucketName: *bucketName, + s3Client: s3Client, + thothClient: thothClient, } mux := http.NewServeMux() @@ -308,6 +322,7 @@ func main() { // Feature handlers mux.HandleFunc("/invite", server.inviteHandler) mux.HandleFunc("/logo", server.logoHandler) + mux.HandleFunc("/thoth-token", server.thothTokenHandler) // Expose Prometheus metrics at /metrics for observability mux.Handle("/metrics", promhttp.Handler()) @@ -322,6 +337,7 @@ func main() { "/", "/invite", "/logo", + "/thoth-token", "/metrics", }) diff --git a/cmd/sponsor-panel/models.go b/cmd/sponsor-panel/models.go index 22cafe205..adaa5a194 100644 --- a/cmd/sponsor-panel/models.go +++ b/cmd/sponsor-panel/models.go @@ -18,6 +18,7 @@ type PanelUser struct { AvatarURL string `json:"avatar_url"` Name string `json:"name"` Email string `json:"email"` + ThothUserID *string `json:"thoth_user_id" gorm:"column:thoth_user_id"` SponsorshipData string `json:"-" gorm:"type:jsonb"` LastSponsorshipCheck time.Time `json:"last_sponsorship_check"` CreatedAt time.Time `json:"created_at"` diff --git a/cmd/sponsor-panel/templates/dashboard.templ b/cmd/sponsor-panel/templates/dashboard.templ index 852eb0255..d7050f82c 100644 --- a/cmd/sponsor-panel/templates/dashboard.templ +++ b/cmd/sponsor-panel/templates/dashboard.templ @@ -38,6 +38,11 @@ templ Dashboard(props DashboardProps) { @LogoSubmitCard() } +
+ Generate an API token for Thoth services. +
+ + +Connect with other sponsors and get early access to updates.
Connect with other sponsors and get early access to updates.
Join Discord") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "/month
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tier) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 90, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
Thank you for your support!
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Thank you for your support!
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "You're not currently an active sponsor.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "You're not currently an active sponsor.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if provider == "patreon" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Become a Patron") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Become a Patron") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Become a Sponsor") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Become a Sponsor") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "If you're part of an organization that sponsors Anubis and see this message, please contact me@xeiaso.net for help.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "If you're part of an organization that sponsors Anubis and see this message, please contact me@xeiaso.net for help.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Invite team members to TecharoHQ as part of your sponsorship.
Invite team members to TecharoHQ as part of your sponsorship.
Submit your company logo for the Anubis README.
Submit your company logo for the Anubis README.
Generate an API token for Thoth services.
Token generated!
++ Set these environment variables in your Anubis/Botstopper deployment. +
+{ "THOTH_URL=passthrough:///thoth.techaro.lol:443\nTHOTH_TOKEN=" + token }
+
+ Token generated!
Set these environment variables in your Anubis/Botstopper deployment.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("THOTH_URL=passthrough:///thoth.techaro.lol:443\nTHOTH_TOKEN=" + token)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/formsuccess.templ`, Line: 58, Col: 206}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "` block with: + ``` + THOTH_URL=passthrough:///thoth.techaro.lol:443 + THOTH_TOKEN=+ ``` +- Monospace styling so users can copy-paste + +### 4. Dashboard Layout (`templates/dashboard.templ`) + +Add `ThothTokenCard()` to the second row grid, shown when `props.IsSponsor` is +true: + +```templ + + if props.IsFiftyPlus { + @TeamInviteCard() + } + if props.IsSponsor { + @LogoSubmitCard() + } + if props.IsSponsor { + @ThothTokenCard() + } ++``` + +If a $50+ sponsor sees all three cards (TeamInvite, LogoSubmit, ThothToken), +add a third row for ThothTokenCard to keep the 2-column grid clean. + +### 5. Routing (`main.go`) + +Add route: + +```go +mux.HandleFunc("/thoth-token", server.thothTokenHandler) +``` + +Add to the debug routes list. + +## Files Modified + +- `cmd/sponsor-panel/models.go` -- add ThothUserID field to PanelUser +- `cmd/sponsor-panel/handlers.go` -- add thothTokenHandler + renderThothSuccess +- `cmd/sponsor-panel/templates/dashboard.templ` -- add ThothTokenCard, wire into layout +- `cmd/sponsor-panel/templates/formresult.templ` -- add ThothTokenSuccess component +- `cmd/sponsor-panel/main.go` -- add /thoth-token route + +## Existing Code to Reuse + +- `server.getSessionUser(r)` -- session auth (`oauth.go:634`) +- `user.IsSponsorAtTier(100)` -- tier check (`models.go:38`) +- `renderError(w, msg, code)` -- error rendering (`handlers.go:302`) +- `templates.FormResult(msg, bool)` -- generic result component (`formresult.templ`) +- `server.thothClient.AdminUsers` -- gRPC client (`internal/thoth/thoth.go`) +- Generated proto types from `xeiaso.net/v4/gen/techaro/thoth/auth/admin/v1` + +## Verification + +1. `go build ./cmd/sponsor-panel` -- compiles without errors +2. `npm test` -- all tests pass +3. `npm run dev:sponsor-panel` -- start dev server +4. Log in as a sponsor, verify the Thoth card appears +5. Click "Generate Token", verify credentials are displayed +6. Click again, verify it works without creating a duplicate Thoth user +7. Check database: `ThothUserID` column populated after first generation diff --git a/go.mod b/go.mod index 2b7fc15e7..46b2cf0d5 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,8 @@ require ( github.com/google/subcommands v1.2.0 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 github.com/jackc/pgx/v5 v5.9.1 github.com/joho/godotenv v1.5.1 github.com/orandin/slog-gorm v1.4.0 diff --git a/go.sum b/go.sum index 0f07729ff..b51504c86 100644 --- a/go.sum +++ b/go.sum @@ -377,6 +377,10 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= diff --git a/web/htmx/htmx_templ.go b/web/htmx/htmx_templ.go index 1fb5e0b42..609450111 100644 --- a/web/htmx/htmx_templ.go +++ b/web/htmx/htmx_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.2.731 package htmx //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -21,9 +21,6 @@ import templruntime "github.com/a-h/templ/runtime" func Use(exts ...string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { defer func() { @@ -39,44 +36,42 @@ func Use(exts ...string) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, ext := range exts { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - return nil + return templ_7745c5c3_Err }) } - -var _ = templruntime.GeneratedTemplate