diff --git a/Dockerfile b/Dockerfile index 9280bd6..1820c7f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,6 +41,7 @@ WORKDIR /app ### Copy built binary application from 'builder' image COPY --from=builder /app/main . +COPY --from=builder /app/verify.html . ### Copy the certs from builder COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ diff --git a/go.mod b/go.mod index a2165dc..c3cbe98 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect github.com/google/go-querystring v1.0.0 // indirect + github.com/google/uuid v1.5.0 github.com/gorilla/websocket v1.5.0 // indirect github.com/jackmerrill/go-mapbox v0.4.5 // indirect github.com/jackmerrill/hamp-api v0.0.0-20230818235104-8d222c9674c9 // indirect diff --git a/go.sum b/go.sum index 7b7a8d7..fd49398 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= diff --git a/internal/commands/util/verify.go b/internal/commands/util/verify.go new file mode 100644 index 0000000..aa01da1 --- /dev/null +++ b/internal/commands/util/verify.go @@ -0,0 +1,270 @@ +package commands + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "net/smtp" + "os" + "regexp" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/google/uuid" + "github.com/jackmerrill/hampbot/internal/utils/config" + "github.com/jackmerrill/hampbot/internal/utils/embed" + "github.com/zekroTJA/shireikan" +) + +type VerifyCommand struct { +} + +func (c *VerifyCommand) GetInvokes() []string { + return []string{"verify"} +} + +func (c *VerifyCommand) GetDescription() string { + return "Verify your student/staff/faculty status with your school email." +} + +func (c *VerifyCommand) GetHelp() string { + return "`verify` - `verify [email]`" +} + +func (c *VerifyCommand) GetGroup() string { + return config.GroupUtil +} + +func (c *VerifyCommand) GetDomainName() string { + return "hamp.util.verify" +} + +func (c *VerifyCommand) GetSubPermissionRules() []shireikan.SubPermission { + return nil +} +func (c *VerifyCommand) IsExecutableInDMChannels() bool { + return true +} + +type Verification struct { + User *discordgo.User + Email string + Expires time.Time + IsStaffFaculty bool +} + +var VerificationCodes map[string]Verification = make(map[string]Verification) + +func (c *VerifyCommand) Exec(ctx shireikan.Context) error { + // first check if the user is already verified (has the role) + guildMember, err := ctx.GetSession().GuildMember(config.BotGuild, ctx.GetUser().ID) + + if err != nil { + return err + } + + for _, role := range guildMember.Roles { + if role == config.VerifiedRoleId { + ctx.GetSession().ChannelMessageSendComplex(ctx.GetChannel().ID, &discordgo.MessageSend{ + Reference: ctx.GetMessage().Reference(), + Embed: embed.NewErrorEmbed(ctx).SetTitle("Already Verified").SetDescription("You are already verified.").MessageEmbed, + }) + return nil + } + } + + // first arg is the email + email := ctx.GetArgs().Get(0).AsString() + + // check if the email is valid, and if it is a hampshire.edu email + if !strings.Contains(email, "@hampshire.edu") { + ctx.GetSession().ChannelMessageSendComplex(ctx.GetChannel().ID, &discordgo.MessageSend{ + Reference: ctx.GetMessage().Reference(), + Embed: embed.NewErrorEmbed(ctx).SetTitle("Invalid Email").SetDescription("Please use a valid @hampshire.edu email.").MessageEmbed, + }) + return nil + } + + code := uuid.New().String() + + err = SendEmail([]string{email}, code, ctx.GetUser()) + + if err != nil { + return err + } + + // expires in 5 minutes + expires := time.Now().Add(time.Minute * 5) + + m, err := ctx.GetSession().ChannelMessageSendComplex(ctx.GetChannel().ID, &discordgo.MessageSend{ + Reference: ctx.GetMessage().Reference(), + Embed: embed.NewSuccessEmbed(ctx).SetTitle("Sent Verification Email").SetDescription("Waiting for you to verify...").AddField("Expires", fmt.Sprintf("", expires.Unix()), false).MessageEmbed, + }) + + if err != nil { + return err + } + + // check if the email is student or staff/faculty + // student emails are in the format of their initials plus their first year (e.g. jmm18). To be safe, lets allow up to six initials plus the year (e.g. jmmmmmm18) + // staff/faculty emails are in the format of their first initial plus their last name (e.g. jsmith), or their first and last initial plus their department (e.g. jsIA) + + hampnetUser := strings.Split(email, "@")[0] + + // use regex to check + // student + if match, _ := regexp.MatchString(`^[a-z]{1,6}[0-9]{2}$`, hampnetUser); match { + VerificationCodes[code] = Verification{ + User: ctx.GetUser(), + Email: email, + Expires: expires, + IsStaffFaculty: false, + } + } else if match, _ := regexp.MatchString(`^[a-z]{1,2}[a-z]{1,2}[a-z]{1,2}[a-z]{1,2}$`, hampnetUser); match { + VerificationCodes[code] = Verification{ + User: ctx.GetUser(), + Email: email, + Expires: expires, + IsStaffFaculty: true, + } + } else { + // potentially student, but not in the format of a student email + VerificationCodes[code] = Verification{ + User: ctx.GetUser(), + Email: email, + Expires: expires, + IsStaffFaculty: false, + } + } + + // wait for the user to verify + for { + if time.Now().After(expires) { + ctx.GetSession().ChannelMessageEditComplex(&discordgo.MessageEdit{ + ID: m.ID, + Channel: ctx.GetChannel().ID, + Embed: embed.NewErrorEmbed(ctx).SetTitle("Verification Expired").SetDescription("Please try again.").MessageEmbed, + }) + } + + _, ok := VerificationCodes[code] + + if !ok { + ctx.GetSession().ChannelMessageEditComplex(&discordgo.MessageEdit{ + ID: m.ID, + Channel: ctx.GetChannel().ID, + Embed: embed.NewSuccessEmbed(ctx).SetTitle("Verified!").SetDescription("You can access the server now.").MessageEmbed, + }) + break + } + + time.Sleep(time.Second * 5) + } + + return nil +} + +func StartWebserver(session *discordgo.Session) { + // listen to /verify + + http.HandleFunc("/verify", func(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + + if code == "" { + http.Error(w, "Code is missing", http.StatusBadRequest) + return + } + + v, ok := VerificationCodes[code] + + if !ok { + http.Error(w, "Code is invalid", http.StatusBadRequest) + return + } + + err := session.GuildMemberRoleAdd(config.BotGuild, v.User.ID, config.VerifiedRoleId) + + if err != nil { + http.Error(w, "Failed to add role", http.StatusInternalServerError) + return + } + + // remove the code from the map + delete(VerificationCodes, code) + + fmt.Fprintf(w, "Verified! You can close this tab now.") + }) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "OK!") + }) + + http.ListenAndServe(":8080", nil) +} + +func SendEmail(to []string, code string, discordUser *discordgo.User) error { + sender := "automated@jackmerrill.com" + + user := os.Getenv("SMTP_USER") + password := os.Getenv("SMTP_PASSWORD") + + subject := "Hampshire Hangout Verification" + tmpl, err := template.ParseFiles("verify.html") + + if err != nil { + return err + } + + buf := new(bytes.Buffer) + + err = tmpl.Execute(buf, struct { + Code string + DiscordUserName string + }{ + Code: code, + DiscordUserName: discordUser.Username, + }) + + if err != nil { + return err + } + + request := Mail{ + Sender: sender, + To: to, + Subject: subject, + Body: buf.String(), + } + + addr := "smtp.gmail.com:587" + host := "smtp.gmail.com" + + msg := BuildMessage(request) + auth := smtp.PlainAuth("", user, password, host) + err = smtp.SendMail(addr, auth, sender, to, []byte(msg)) + + if err != nil { + return err + } + + return nil +} + +func BuildMessage(mail Mail) string { + msg := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\r\n" + msg += fmt.Sprintf("From: %s\r\n", mail.Sender) + msg += fmt.Sprintf("To: %s\r\n", strings.Join(mail.To, ";")) + msg += fmt.Sprintf("Subject: %s\r\n", mail.Subject) + msg += fmt.Sprintf("\r\n%s\r\n", mail.Body) + + return msg +} + +type Mail struct { + Sender string + To []string + Subject string + Body string +} diff --git a/internal/utils/config/config.go b/internal/utils/config/config.go index aaa2299..f856a77 100644 --- a/internal/utils/config/config.go +++ b/internal/utils/config/config.go @@ -27,6 +27,9 @@ var ( HampAPIHost = "api.hamp.sh" // HampAPIHost = "localhost:1323" // HampAPI = "http://localhost:1323" + + VerifiedRoleId = "1020858770659745844" + StaffFacultyRoleId = "1183920480361648228" ) var ( diff --git a/main.go b/main.go index cb2fc35..30b7a52 100644 --- a/main.go +++ b/main.go @@ -102,6 +102,10 @@ func main() { handler.Register(&fun.MetricTime{}) log.Debug("Registered metrictime command") + handler.Register(&util.VerifyCommand{}) + go util.StartWebserver(session) + log.Debug("Registered verify command") + log.Info("Registered all commands") log.Info("Setting up activities...") diff --git a/verify.html b/verify.html new file mode 100644 index 0000000..5d4c3c7 --- /dev/null +++ b/verify.html @@ -0,0 +1,50 @@ + + + + + + + Hampshire Hangout Email Verification + + + + +
+
+

Hampshire Hangout Email Verification

+

+ Thanks for joining Hampshire Hangout, {{ .DiscordUserName }}! We're + excited to have you join our community. Before you can start using + your account, you need to verify your email address by clicking the + button below: +

+
+ Click to Verify Email +

+ If you're having trouble clicking the "Verify Email Address" button, + copy and paste the URL below into your web browser: + https://hampbot.fly.dev/verify?code={{ .Code }} +

+
+

+ If you didn't request this, ignore it! +

+
+
+ +