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 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
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