Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Moodle Course Completion Webhooks #4

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions conf/conf.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
45 changes: 44 additions & 1 deletion keycloak/keycloak.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,40 @@ func (k *Keycloak) GetUser(ctx context.Context, userID string) (*User, error) {
}

user := &User{
ID: gocloak.PString(kcuser.ID),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary in this PR, but it would be nice to move the gocloak.User -> User conversion logic into a helper function to avoid duplication.

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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
55 changes: 55 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand All @@ -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)))
Expand Down Expand Up @@ -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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider exposing the group ID as an env var in case it ever changes

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")
Expand Down
64 changes: 64 additions & 0 deletions moodle/moodle.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 3 additions & 1 deletion templates/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ <h3 class="panel-title">Discord</h3>
</div>
</div>
<br />
<a class="btn btn-default" href="/profile/discord/unlink"
<a
class="btn btn-default"
href="https://keycloak.apps.thelab.ms/realms/master/account/#/security/linked-accounts"
>Unlink Account</a
>
{{- else }}
Expand Down