Skip to content

Commit a124314

Browse files
authored
feat(sponsor-panel): add Thoth token issuance card (#1196)
* feat(sponsor-panel): add thoth client Signed-off-by: Xe Iaso <me@xeiaso.net> * feat(sponsor-panel): add Thoth token issuance card Add a self-service dashboard card that lets $1+/month sponsors generate Thoth API tokens. Uses lazy user creation: on first token generation, creates a Thoth user via AdminUsers.Create and persists the ID on the PanelUser model. Subsequent requests skip creation and go straight to MakeJWT. Includes empty email guard, HTMX double-click prevention, copy to clipboard button, and instructional text for Anubis/Botstopper deployment. --------- Signed-off-by: Xe Iaso <me@xeiaso.net>
1 parent 18e4ee4 commit a124314

13 files changed

Lines changed: 555 additions & 58 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ cw.tar
1111
node_modules
1212
/xesite
1313
/xesitectl
14+
/sponsor-panel

cmd/sponsor-panel/handlers.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
"github.com/google/go-github/v82/github"
1717
"github.com/google/uuid"
1818

19+
adminv1 "xeiaso.net/v4/gen/techaro/thoth/auth/admin/v1"
20+
1921
"xeiaso.net/v4/cmd/sponsor-panel/templates"
2022
)
2123

@@ -318,3 +320,82 @@ func renderInviteSuccess(w http.ResponseWriter, username, state string) {
318320
func renderLogoSuccess(w http.ResponseWriter, company, issueURL string, issueNumber int) {
319321
templates.LogoSuccess(company, issueURL, issueNumber).Render(context.Background(), w)
320322
}
323+
324+
// thothTokenHandler handles POST /thoth-token - issues a Thoth JWT for the user.
325+
func (s *Server) thothTokenHandler(w http.ResponseWriter, r *http.Request) {
326+
if r.Method != http.MethodPost {
327+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
328+
return
329+
}
330+
331+
slog.Debug("thothTokenHandler: processing token request")
332+
333+
// Get user from session
334+
user, err := s.getSessionUser(r)
335+
if err != nil {
336+
slog.Error("thothTokenHandler: failed to get session user", "err", err)
337+
renderError(w, "Authentication required", http.StatusUnauthorized)
338+
return
339+
}
340+
341+
slog.Debug("thothTokenHandler: authenticated user", "user_id", user.ID, "login", user.Login)
342+
343+
// Check sponsorship tier (any active sponsorship)
344+
if !user.IsSponsorAtTier(100) {
345+
slog.Error("thothTokenHandler: user not a sponsor", "user", user.Login, "user_id", user.ID)
346+
renderError(w, "Requires active sponsorship", http.StatusForbidden)
347+
return
348+
}
349+
350+
// Create Thoth user if not already provisioned
351+
if user.ThothUserID == nil {
352+
if user.Email == "" {
353+
slog.Error("thothTokenHandler: user has no email address", "user_id", user.ID, "login", user.Login)
354+
renderError(w, "Email address required. Please update your profile.", http.StatusBadRequest)
355+
return
356+
}
357+
358+
slog.Debug("thothTokenHandler: creating Thoth user", "user_id", user.ID, "login", user.Login)
359+
360+
resp, err := s.thothClient.AdminUsers.Create(r.Context(), &adminv1.UsersServiceCreateRequest{
361+
EmailAddress: user.Email,
362+
Name: user.Login,
363+
CustomerId: user.Provider + ":" + user.Login,
364+
})
365+
if err != nil {
366+
slog.Error("thothTokenHandler: failed to create Thoth user", "err", err, "user_id", user.ID)
367+
renderError(w, "Failed to create Thoth user: "+err.Error(), http.StatusInternalServerError)
368+
return
369+
}
370+
371+
thothID := resp.GetUser().GetId()
372+
user.ThothUserID = &thothID
373+
374+
if err := s.db.Save(user).Error; err != nil {
375+
slog.Error("thothTokenHandler: failed to save Thoth user ID", "err", err, "user_id", user.ID)
376+
renderError(w, "Failed to save Thoth user: "+err.Error(), http.StatusInternalServerError)
377+
return
378+
}
379+
380+
slog.Info("thothTokenHandler: Thoth user created", "user_id", user.ID, "login", user.Login, "thoth_user_id", thothID)
381+
}
382+
383+
// Issue JWT
384+
slog.Debug("thothTokenHandler: issuing JWT", "user_id", user.ID, "thoth_user_id", *user.ThothUserID)
385+
386+
jwtResp, err := s.thothClient.AdminUsers.MakeJWT(r.Context(), &adminv1.UsersServiceMakeJWTRequest{
387+
UserId: *user.ThothUserID,
388+
Comment: "sponsor-panel token for " + user.Login,
389+
})
390+
if err != nil {
391+
slog.Error("thothTokenHandler: failed to issue JWT", "err", err, "user_id", user.ID)
392+
renderError(w, "Failed to issue token: "+err.Error(), http.StatusInternalServerError)
393+
return
394+
}
395+
396+
slog.Info("thothTokenHandler: token issued", "user_id", user.ID, "login", user.Login)
397+
398+
w.Header().Set("Content-Type", "text/html")
399+
w.WriteHeader(http.StatusOK)
400+
templates.ThothTokenSuccess(jwtResp.GetTokenInfo().GetJwt()).Render(context.Background(), w)
401+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package thoth
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"fmt"
7+
"time"
8+
9+
grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus"
10+
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/timeout"
11+
"github.com/prometheus/client_golang/prometheus"
12+
"google.golang.org/grpc"
13+
"google.golang.org/grpc/credentials"
14+
healthv1 "google.golang.org/grpc/health/grpc_health_v1"
15+
"google.golang.org/grpc/metadata"
16+
adminv1 "xeiaso.net/v4/gen/techaro/thoth/auth/admin/v1"
17+
authv1 "xeiaso.net/v4/gen/techaro/thoth/auth/v1"
18+
)
19+
20+
type Client struct {
21+
conn *grpc.ClientConn
22+
23+
Health healthv1.HealthClient
24+
AuthJWT authv1.JWTServiceClient
25+
AdminUsers adminv1.UsersServiceClient
26+
}
27+
28+
func New(ctx context.Context, thothURL, apiToken string) (*Client, error) {
29+
clMetrics := grpcprom.NewClientMetrics(
30+
grpcprom.WithClientHandlingTimeHistogram(
31+
grpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120}),
32+
),
33+
)
34+
prometheus.DefaultRegisterer.Register(clMetrics)
35+
36+
conn, err := grpc.NewClient(
37+
thothURL,
38+
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})),
39+
//grpc.WithTransportCredentials(insecure.NewCredentials()),
40+
grpc.WithChainUnaryInterceptor(
41+
timeout.UnaryClientInterceptor(5*time.Minute),
42+
clMetrics.UnaryClientInterceptor(),
43+
authUnaryClientInterceptor(apiToken),
44+
),
45+
grpc.WithChainStreamInterceptor(
46+
clMetrics.StreamClientInterceptor(),
47+
authStreamClientInterceptor(apiToken),
48+
),
49+
)
50+
if err != nil {
51+
return nil, fmt.Errorf("can't dial thoth at %s: %w", thothURL, err)
52+
}
53+
54+
hc := healthv1.NewHealthClient(conn)
55+
56+
resp, err := hc.Check(ctx, &healthv1.HealthCheckRequest{})
57+
if err != nil {
58+
return nil, fmt.Errorf("can't verify thoth health at %s: %w", thothURL, err)
59+
}
60+
61+
if resp.Status != healthv1.HealthCheckResponse_SERVING {
62+
return nil, fmt.Errorf("thoth is not healthy, wanted %s but got %s", healthv1.HealthCheckResponse_SERVING, resp.Status)
63+
}
64+
65+
return &Client{
66+
conn: conn,
67+
Health: hc,
68+
AuthJWT: authv1.NewJWTServiceClient(conn),
69+
AdminUsers: adminv1.NewUsersServiceClient(conn),
70+
}, nil
71+
}
72+
73+
func (c *Client) Close() error {
74+
if c.conn != nil {
75+
return c.conn.Close()
76+
}
77+
return nil
78+
}
79+
80+
func authUnaryClientInterceptor(token string) grpc.UnaryClientInterceptor {
81+
return func(
82+
ctx context.Context,
83+
method string,
84+
req interface{},
85+
reply interface{},
86+
cc *grpc.ClientConn,
87+
invoker grpc.UnaryInvoker,
88+
opts ...grpc.CallOption,
89+
) error {
90+
md := metadata.Pairs("authorization", "Bearer "+token)
91+
ctx = metadata.NewOutgoingContext(ctx, md)
92+
return invoker(ctx, method, req, reply, cc, opts...)
93+
}
94+
}
95+
96+
func authStreamClientInterceptor(token string) grpc.StreamClientInterceptor {
97+
return func(
98+
ctx context.Context,
99+
desc *grpc.StreamDesc,
100+
cc *grpc.ClientConn,
101+
method string,
102+
streamer grpc.Streamer,
103+
opts ...grpc.CallOption,
104+
) (grpc.ClientStream, error) {
105+
md := metadata.Pairs("authorization", "Bearer "+token)
106+
ctx = metadata.NewOutgoingContext(ctx, md)
107+
return streamer(ctx, desc, cc, method, opts...)
108+
}
109+
}

cmd/sponsor-panel/main.go

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,16 @@ import (
1919
"github.com/facebookgo/flagenv"
2020
gh "github.com/google/go-github/v82/github"
2121
"github.com/gorilla/sessions"
22-
slogGorm "github.com/orandin/slog-gorm"
23-
"gorm.io/driver/postgres"
24-
"gorm.io/gorm"
25-
gormPrometheus "gorm.io/plugin/prometheus"
2622
_ "github.com/joho/godotenv/autoload"
27-
patreon "gopkg.in/mxpv/patreon-go.v1"
23+
slogGorm "github.com/orandin/slog-gorm"
2824
"github.com/prometheus/client_golang/prometheus/promhttp"
2925
"golang.org/x/oauth2"
3026
"golang.org/x/oauth2/github"
27+
patreon "gopkg.in/mxpv/patreon-go.v1"
28+
"gorm.io/driver/postgres"
29+
"gorm.io/gorm"
30+
gormPrometheus "gorm.io/plugin/prometheus"
31+
"xeiaso.net/v4/cmd/sponsor-panel/internal/thoth"
3132
"xeiaso.net/v4/internal"
3233
"xeiaso.net/v4/web/htmx"
3334
)
@@ -57,24 +58,29 @@ var (
5758
patreonCampaignID = flag.String("patreon-campaign-id", "", "Patreon campaign ID to check pledges against")
5859
patreonFiftyPlus = flag.String("patreon-fifty-plus", "", "Comma-separated list of Patreon usernames always treated as $50+ sponsors")
5960

61+
// Thoth settings
62+
thothToken = flag.String("thoth-token", "", "Thoth API token (use a god token)")
63+
thothURL = flag.String("thoth-url", "passthrough:///thoth.techaro.lol:443", "URL for the Thoth API server")
64+
6065
//go:embed static
6166
staticFS embed.FS
6267
)
6368

6469
// Server holds the application dependencies.
6570
type Server struct {
66-
db *gorm.DB
67-
ghClient *gh.Client
68-
oauth *oauth2.Config
71+
db *gorm.DB
72+
ghClient *gh.Client
73+
oauth *oauth2.Config
6974
patreonOAuth *oauth2.Config // nil if Patreon not configured
7075
patreonCampaignID string
7176
patreonFiftyPlusSpons map[string]bool // Patreon usernames always treated as $50+
72-
discordInvite string
73-
fiftyPlusSponsors map[string]bool // Always treated as $50+ sponsors
74-
sessionStore *sessions.CookieStore
75-
cookieSecure bool
76-
bucketName string
77-
s3Client *s3.Client
77+
discordInvite string
78+
fiftyPlusSponsors map[string]bool // Always treated as $50+ sponsors
79+
sessionStore *sessions.CookieStore
80+
cookieSecure bool
81+
bucketName string
82+
s3Client *s3.Client
83+
thothClient *thoth.Client
7884
}
7985

8086
func main() {
@@ -260,19 +266,27 @@ func main() {
260266
slog.Info("main: S3 client created", "bucket", *bucketName)
261267
}
262268

269+
thothClient, err := thoth.New(context.Background(), *thothURL, *thothToken)
270+
if err != nil {
271+
slog.Error("can't create thoth client", "err", err)
272+
os.Exit(2)
273+
}
274+
slog.Info("thoth client created")
275+
263276
server := &Server{
264-
db: db,
265-
ghClient: ghClient,
266-
oauth: oauthConfig,
277+
db: db,
278+
ghClient: ghClient,
279+
oauth: oauthConfig,
267280
patreonOAuth: patreonConfig,
268281
patreonCampaignID: *patreonCampaignID,
269282
patreonFiftyPlusSpons: patreonFiftyPlusMap,
270-
discordInvite: *discordInvite,
271-
fiftyPlusSponsors: fiftyPlusMap,
272-
sessionStore: sessionStore,
273-
cookieSecure: *cookieSecure,
274-
bucketName: *bucketName,
275-
s3Client: s3Client,
283+
discordInvite: *discordInvite,
284+
fiftyPlusSponsors: fiftyPlusMap,
285+
sessionStore: sessionStore,
286+
cookieSecure: *cookieSecure,
287+
bucketName: *bucketName,
288+
s3Client: s3Client,
289+
thothClient: thothClient,
276290
}
277291

278292
mux := http.NewServeMux()
@@ -308,6 +322,7 @@ func main() {
308322
// Feature handlers
309323
mux.HandleFunc("/invite", server.inviteHandler)
310324
mux.HandleFunc("/logo", server.logoHandler)
325+
mux.HandleFunc("/thoth-token", server.thothTokenHandler)
311326

312327
// Expose Prometheus metrics at /metrics for observability
313328
mux.Handle("/metrics", promhttp.Handler())
@@ -322,6 +337,7 @@ func main() {
322337
"/",
323338
"/invite",
324339
"/logo",
340+
"/thoth-token",
325341
"/metrics",
326342
})
327343

cmd/sponsor-panel/models.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type PanelUser struct {
1818
AvatarURL string `json:"avatar_url"`
1919
Name string `json:"name"`
2020
Email string `json:"email"`
21+
ThothUserID *string `json:"thoth_user_id" gorm:"column:thoth_user_id"`
2122
SponsorshipData string `json:"-" gorm:"type:jsonb"`
2223
LastSponsorshipCheck time.Time `json:"last_sponsorship_check"`
2324
CreatedAt time.Time `json:"created_at"`

cmd/sponsor-panel/templates/dashboard.templ

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ templ Dashboard(props DashboardProps) {
3838
@LogoSubmitCard()
3939
}
4040
</div>
41+
<div class="grid md:grid-cols-2 gap-8 mt-8">
42+
if props.IsSponsor {
43+
@ThothTokenCard()
44+
}
45+
</div>
4146
</main>
4247
}
4348

@@ -174,3 +179,23 @@ templ LogoSubmitCard() {
174179
<div id="logo-result"></div>
175180
</div>
176181
}
182+
183+
templ ThothTokenCard() {
184+
<div class="card p-4">
185+
<h2 class="card-title flex items-center gap-2">
186+
<svg class="w-5 h-5 text-yellow-light dark:text-yellowDark-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
187+
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
188+
</svg>
189+
Thoth API Token
190+
</h2>
191+
<p class="card-description">
192+
Generate an API token for Thoth services.
193+
</p>
194+
<form hx-post="/thoth-token" hx-target="#thoth-result">
195+
<button type="submit" class="btn btn-dark w-full" hx-disabled-elt="this">
196+
Generate Token
197+
</button>
198+
</form>
199+
<div id="thoth-result"></div>
200+
</div>
201+
}

0 commit comments

Comments
 (0)