diff --git a/conf/conf.go b/conf/conf.go index 9142be6..da2496b 100644 --- a/conf/conf.go +++ b/conf/conf.go @@ -11,4 +11,8 @@ type Env struct { SelfURL string `split_words:"true" required:"true"` StripeKey string `split_words:"true"` StripeWebhookKey string `split_words:"true"` + + MoodleURL string `split_words:"true" required:"true"` + MoodleWSToken string `split_words:"true" required:"true"` + MoodleSecret string `split_words:"true" required:"true"` } diff --git a/keycloak/keycloak.go b/keycloak/keycloak.go index 2a8d8b8..232f863 100644 --- a/keycloak/keycloak.go +++ b/keycloak/keycloak.go @@ -101,6 +101,40 @@ func (k *Keycloak) GetUser(ctx context.Context, userID string) (*User, error) { } user := &User{ + ID: gocloak.PString(kcuser.ID), + First: gocloak.PString(kcuser.FirstName), + Last: gocloak.PString(kcuser.LastName), + Email: gocloak.PString(kcuser.Email), + SignedWaiver: safeGetAttr(kcuser, "waiverState") == "Signed", + ActivePayment: safeGetAttr(kcuser, "stripeID") != "", + StripeSubscriptionID: safeGetAttr(kcuser, "stripeSubscriptionID"), + } + user.FobID, _ = strconv.Atoi(safeGetAttr(kcuser, "keyfobID")) + user.StripeCancelationTime, _ = strconv.ParseInt(safeGetAttr(kcuser, "stripeCancelationTime"), 10, 0) + user.StripeETag = safeGetAttr(kcuser, "stripeCancelationTime") + + return user, nil +} + +func (k *Keycloak) GetUserByEmail(ctx context.Context, email string) (*User, error) { + token, err := k.ensureToken(ctx) + if err != nil { + return nil, fmt.Errorf("getting token: %w", err) + } + + kcusers, err := k.client.GetUsers(ctx, token.AccessToken, k.env.KeycloakRealm, gocloak.GetUsersParams{ + Email: &email, + }) + if err != nil { + return nil, fmt.Errorf("getting current user: %w", err) + } + if len(kcusers) == 0 { + return nil, errors.New("user not found") + } + kcuser := kcusers[0] + + user := &User{ + ID: gocloak.PString(kcuser.ID), First: gocloak.PString(kcuser.FirstName), Last: gocloak.PString(kcuser.LastName), Email: gocloak.PString(kcuser.Email), @@ -270,7 +304,7 @@ func (k *Keycloak) ensureToken(ctx context.Context) (*gocloak.JWT, error) { } type User struct { - First, Last, Email string + ID, First, Last, Email string FobID int SignedWaiver, ActivePayment bool Discord discord.DiscordUserData @@ -299,3 +333,12 @@ func firstElOrZeroVal[T any](slice []T) (val T) { } return slice[0] } + +func (k *Keycloak) AddUserToGroup(ctx context.Context, userID, groupID string) error { + token, err := k.ensureToken(ctx) + if err != nil { + return fmt.Errorf("getting token: %w", err) + } + + return k.client.AddUserToGroup(ctx, token.AccessToken, k.env.KeycloakRealm, userID, groupID) +} diff --git a/main.go b/main.go index 76a867b..0e23a05 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "github.com/TheLab-ms/profile/conf" "github.com/TheLab-ms/profile/keycloak" + "github.com/TheLab-ms/profile/moodle" "github.com/TheLab-ms/profile/stripeutil" ) @@ -50,6 +51,7 @@ func main() { stripe.Key = env.StripeKey kc := keycloak.New(env) + m := moodle.New(env) priceCache := stripeutil.StartPriceCache() // Redirect from / to /profile @@ -71,6 +73,7 @@ func main() { // Webhooks http.HandleFunc("/webhooks/docuseal", newDocusealWebhookHandler(kc)) http.HandleFunc("/webhooks/stripe", newStripeWebhookHandler(env, kc)) + http.HandleFunc("/webhooks/moodle", newMoodleWebhookHandler(env, kc, m)) // Embed (into the compiled binary) and serve any files from the assets directory http.Handle("/assets/", http.FileServer(http.FS(assets))) @@ -306,6 +309,58 @@ func newStripeWebhookHandler(env *conf.Env, kc *keycloak.Keycloak) http.HandlerF } } +func newMoodleWebhookHandler(env *conf.Env, kc *keycloak.Keycloak, m *moodle.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body := struct { + EventName string `json:"eventname"` + Host string `json:"host"` + Token string `json:"token"` + CourseID string `json:"courseid"` + UserID string `json:"relateduserid"` + }{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + log.Printf("invalid json sent to moodle webhook endpoint: %s", err) + w.WriteHeader(400) + return + } + if body.Token != env.MoodleSecret { + log.Printf("invalid moodle webhook secret") + w.WriteHeader(400) + return + } + switch body.EventName { + case "\\core\\event\\course_completed": + log.Printf("got moodle course completion submission webhook") + + // Lookup user by moodle ID to get email address + moodleUser, err := m.GetUserByID(body.UserID) + if err != nil { + log.Printf("error while looking up user by moodle ID: %s", err) + w.WriteHeader(500) + return + } + // Use email address to lookup user in Keycloak + user, err := kc.GetUserByEmail(r.Context(), moodleUser.Email) + if err != nil { + log.Printf("error while looking up user by email: %s", err) + w.WriteHeader(500) + return + } + + err = kc.AddUserToGroup(r.Context(), user.ID, "6e413212-c1d8-4bc9-abb3-51944ca35327") + if err != nil { + log.Printf("error while adding user to group: %s", err) + w.WriteHeader(500) + return + } + + default: + log.Printf("unhandled moodle webhook event type: %s, ignoring", body.EventName) + } + + } +} + // getUserID allows the oauth2proxy header to be overridden for testing. func getUserID(r *http.Request) string { user := r.Header.Get("X-Forwarded-Preferred-Username") diff --git a/moodle/moodle.go b/moodle/moodle.go new file mode 100644 index 0000000..a5692f5 --- /dev/null +++ b/moodle/moodle.go @@ -0,0 +1,64 @@ +package moodle + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/TheLab-ms/profile/conf" +) + +type Client struct { + url string + token string +} + +type User struct { + ID int `json:"id"` + UserName string `json:"username"` + FirstName string `json:"firstname"` + LastName string `json:"lastname"` + FullName string `json:"fullname"` + Email string `json:"email"` + Suspended bool `json:"suspended"` + Confirmed bool `json:"confirmed"` + ProfileImageURL string `json:"profileimageurl"` +} + +func New(c *conf.Env) *Client { + return &Client{url: c.MoodleURL, token: c.MoodleWSToken} +} + +func (m *Client) GetUserByID(id string) (*User, error) { + url := fmt.Sprintf("%s/webservice/rest/server.php?wstoken=%s&wsfunction=core_user_get_users_by_field&field=id&values[0]=%s&moodlewsrestformat=json", m.url, m.token, id) + + client := http.DefaultClient + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + res, err := client.Do(req) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(res.Body) + return nil, fmt.Errorf("received non-OK HTTP status %d: %s", res.StatusCode, body) + } + + var users []User // Declare users as a slice of User + if err := json.NewDecoder(res.Body).Decode(&users); err != nil { + return nil, err + } + + if len(users) == 0 { + return nil, fmt.Errorf("no user found with ID %s", id) + } + + return &users[0], nil // Return the first user in the array +} diff --git a/templates/profile.html b/templates/profile.html index bc0f4b1..3fd0212 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -149,7 +149,9 @@