Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ cw.tar
node_modules
/xesite
/xesitectl
/sponsor-panel
81 changes: 81 additions & 0 deletions cmd/sponsor-panel/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
109 changes: 109 additions & 0 deletions cmd/sponsor-panel/internal/thoth/thoth.go
Original file line number Diff line number Diff line change
@@ -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...)
}
}
62 changes: 39 additions & 23 deletions cmd/sponsor-panel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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())
Expand All @@ -322,6 +337,7 @@ func main() {
"/",
"/invite",
"/logo",
"/thoth-token",
"/metrics",
})

Expand Down
1 change: 1 addition & 0 deletions cmd/sponsor-panel/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
25 changes: 25 additions & 0 deletions cmd/sponsor-panel/templates/dashboard.templ
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ templ Dashboard(props DashboardProps) {
@LogoSubmitCard()
}
</div>
<div class="grid md:grid-cols-2 gap-8 mt-8">
if props.IsSponsor {
@ThothTokenCard()
}
</div>
</main>
}

Expand Down Expand Up @@ -174,3 +179,23 @@ templ LogoSubmitCard() {
<div id="logo-result"></div>
</div>
}

templ ThothTokenCard() {
<div class="card p-4">
<h2 class="card-title flex items-center gap-2">
<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">
<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>
</svg>
Thoth API Token
</h2>
<p class="card-description">
Generate an API token for Thoth services.
</p>
<form hx-post="/thoth-token" hx-target="#thoth-result">
<button type="submit" class="btn btn-dark w-full" hx-disabled-elt="this">
Generate Token
</button>
</form>
<div id="thoth-result"></div>
</div>
}
Loading
Loading