Skip to content
This repository was archived by the owner on Feb 24, 2025. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from 3 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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
KEYCLOAK_URL=
KEYCLOAK_USER=
KEYCLOAK_PASSWORD=
KEYCLOAK_REALM=
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=
KEYCLOAK_MEMBERS_GROUP_ID=

SELF_URL=http://localhost:8080

TESTUSERID=

MOODLE_URL=
MOODLE_WS_TOKEN=
MOODLE_SECRET=
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 @@ -100,6 +100,40 @@ func (k *Keycloak) GetUser(ctx context.Context, userID string) (*User, error) {
}

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

Copy link
Copy Markdown
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 @@ -252,7 +286,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

Expand Down Expand Up @@ -280,3 +314,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)
}
58 changes: 58 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,61 @@ func newStripeWebhookHandler(env *conf.Env, kc *keycloak.Keycloak) http.HandlerF
}
}

func newMoodleWebhookHandler(env *conf.Env, kc *keycloak.Keycloak, m *moodle.Moodle) 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
}
if user != nil {
log.Printf("user %s completed moodle course %s", user.Email, body.CourseID)
Comment thread
ATechAdventurer marked this conversation as resolved.
Outdated
}

err = kc.AddUserToGroup(r.Context(), user.ID, "6e413212-c1d8-4bc9-abb3-51944ca35327")

Copy link
Copy Markdown
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"
"log"
"net/http"

"github.com/TheLab-ms/profile/conf"
)

type Moodle struct {
Comment thread
ATechAdventurer marked this conversation as resolved.
Outdated
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) *Moodle {
return &Moodle{url: c.MoodleURL, token: c.MoodleWSToken}
}

func (m *Moodle) 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.Client{}
Comment thread
ATechAdventurer marked this conversation as resolved.
Outdated
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 {
return nil, fmt.Errorf("received non-OK HTTP status: %s", res.Status)
Comment thread
ATechAdventurer marked this conversation as resolved.
Outdated
}

var users []User // Declare users as a slice of User
if err := json.NewDecoder(res.Body).Decode(&users); err != nil {
log.Println("Error decoding response. ", err)
Comment thread
ATechAdventurer marked this conversation as resolved.
Outdated
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
}