diff --git a/.env.example b/.env.example index 07e3289..1b2aa6d 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,8 @@ DB_NAME="" DB_HOST="" DB_USERNAME="" DB_PASSWORD="" + +SMTP_HOST="" +SMTP_PORT="" +SMTP_USER="" +SMTP_PASSWORD="" diff --git a/cmd/migrator/migrator.go b/cmd/migrator/migrator.go index 1a21b35..599fc63 100644 --- a/cmd/migrator/migrator.go +++ b/cmd/migrator/migrator.go @@ -14,5 +14,6 @@ func Migrate() error { return dbConn.Debug().AutoMigrate( new(models.Account), new(models.Profile), + new(models.EmailVerificationCode), ) } diff --git a/cmd/server/server.go b/cmd/server/server.go index b94d0e9..b93a621 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -2,33 +2,56 @@ package server import ( "dankmuzikk/config" + "dankmuzikk/db" "dankmuzikk/handlers/apis" "dankmuzikk/handlers/pages" "dankmuzikk/log" + "dankmuzikk/models" + "dankmuzikk/services/jwt" + "dankmuzikk/services/login" "dankmuzikk/services/youtube" "embed" "net/http" ) func StartServer(staticFS embed.FS) error { + ///////////// Pages and files ///////////// pagesHandler := http.NewServeMux() pagesHandler.Handle("/static/", http.FileServer(http.FS(staticFS))) pagesHandler.Handle("/music/", http.StripPrefix("/music", http.FileServer(http.Dir(config.Env().YouTube.MusicDir)))) + jwtUtil := jwt.NewJWTImpl() + pagesHandler.HandleFunc("/", pages.Handler(pages.HandleHomePage)) - pagesHandler.HandleFunc("/signup", pages.AuthHandler(pages.HandleSignupPage)) - pagesHandler.HandleFunc("/login", pages.AuthHandler(pages.HandleLoginPage)) - pagesHandler.HandleFunc("/profile", pages.AuthHandler(pages.HandleProfilePage)) + pagesHandler.HandleFunc("/signup", pages.AuthHandler(pages.HandleSignupPage, jwtUtil)) + pagesHandler.HandleFunc("/login", pages.AuthHandler(pages.HandleLoginPage, jwtUtil)) + pagesHandler.HandleFunc("/profile", pages.AuthHandler(pages.HandleProfilePage, jwtUtil)) pagesHandler.HandleFunc("/about", pages.Handler(pages.HandleAboutPage)) - pagesHandler.HandleFunc("/playlists", pages.AuthHandler(pages.HandlePlaylistsPage)) + pagesHandler.HandleFunc("/playlists", pages.AuthHandler(pages.HandlePlaylistsPage, jwtUtil)) pagesHandler.HandleFunc("/privacy", pages.Handler(pages.HandlePrivacyPage)) pagesHandler.HandleFunc("/search", pages.Handler(pages.HandleSearchResultsPage(&youtube.YouTubeScraperSearch{}))) + ///////////// APIs ///////////// + dbConn, err := db.Connector() + if err != nil { + log.Fatalln(log.ErrorLevel, err) + } + + accountRepo := db.NewBaseDB[models.Account](dbConn) + profileRepo := db.NewBaseDB[models.Profile](dbConn) + otpRepo := db.NewBaseDB[models.EmailVerificationCode](dbConn) + + emailLoginApi := apis.NewEmailLoginApi(login.NewEmailLoginService(accountRepo, profileRepo, otpRepo, jwtUtil)) + googleLoginApi := apis.NewGoogleLoginApi(login.NewGoogleLoginService(accountRepo, profileRepo, otpRepo, jwtUtil)) + apisHandler := http.NewServeMux() - apisHandler.HandleFunc("/login/google", apis.HandleGoogleOAuthLogin) - apisHandler.HandleFunc("/login/google/callback", apis.HandleGoogleOAuthLoginCallback) - apisHandler.HandleFunc("/search-suggession", apis.HandleSearchSugessions) - apisHandler.HandleFunc("/song/download/{youtube_video_id}", apis.HandleDownloadSong) + apisHandler.HandleFunc("POST /login/email", emailLoginApi.HandleEmailLogin) + apisHandler.HandleFunc("POST /signup/email", emailLoginApi.HandleEmailSignup) + apisHandler.HandleFunc("POST /verify-otp", emailLoginApi.HandleEmailOTPVerification) + apisHandler.HandleFunc("GET /login/google", googleLoginApi.HandleGoogleOAuthLogin) + apisHandler.HandleFunc("/login/google/callback", googleLoginApi.HandleGoogleOAuthLoginCallback) + apisHandler.HandleFunc("GET /search-suggession", apis.HandleSearchSugessions) + apisHandler.HandleFunc("GET /song/download/{youtube_video_id}", apis.HandleDownloadSong) applicationHandler := http.NewServeMux() applicationHandler.Handle("/", pagesHandler) diff --git a/config/env.go b/config/env.go index ca8ec20..9eebb0e 100644 --- a/config/env.go +++ b/config/env.go @@ -41,6 +41,17 @@ func initEnvVars() { Username: getEnv("DB_USERNAME"), Password: getEnv("DB_PASSWORD"), }, + Smtp: struct { + Host string + Port string + Username string + Password string + }{ + Host: getEnv("SMTP_HOST"), + Port: getEnv("SMTP_PORT"), + Username: getEnv("SMTP_USER"), + Password: getEnv("SMTP_PASSWORD"), + }, } } @@ -63,6 +74,12 @@ type config struct { Username string Password string } + Smtp struct { + Host string + Port string + Username string + Password string + } } // Env returns the thing's config values :) diff --git a/db/allowed_models.go b/db/allowed_models.go index 8f45a1e..9103ece 100644 --- a/db/allowed_models.go +++ b/db/allowed_models.go @@ -3,6 +3,6 @@ package db import "dankmuzikk/models" type AllowedModels interface { - models.Account | models.Profile + models.Account | models.Profile | models.EmailVerificationCode GetId() uint } diff --git a/entities/email_login.go b/entities/email_login.go new file mode 100644 index 0000000..6bb3b1a --- /dev/null +++ b/entities/email_login.go @@ -0,0 +1,14 @@ +package entities + +type LoginRequest struct { + Email string `json:"email"` +} + +type SignupRequest struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type OtpRequest struct { + Code string `json:"code"` +} diff --git a/handlers/apis/email_login.go b/handlers/apis/email_login.go new file mode 100644 index 0000000..280b082 --- /dev/null +++ b/handlers/apis/email_login.go @@ -0,0 +1,113 @@ +package apis + +import ( + "context" + "dankmuzikk/config" + "dankmuzikk/entities" + "dankmuzikk/handlers" + "dankmuzikk/log" + "dankmuzikk/services/login" + "dankmuzikk/views/components/otp" + "encoding/json" + "net/http" + "time" +) + +type emailLoginApi struct { + service *login.EmailLoginService +} + +func NewEmailLoginApi(service *login.EmailLoginService) *emailLoginApi { + return &emailLoginApi{service} +} + +func (e *emailLoginApi) HandleEmailLogin(w http.ResponseWriter, r *http.Request) { + var reqBody entities.LoginRequest + err := json.NewDecoder(r.Body).Decode(&reqBody) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + verificationToken, err := e.service.Login(reqBody) + if err != nil { + log.Errorf("[EMAIL LOGIN API]: Failed to login user: %+v, error: %s\n", reqBody, err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: handlers.VerificationTokenKey, + Value: verificationToken, + HttpOnly: true, + Path: "/api/verify-otp", + Domain: config.Env().Hostname, + Expires: time.Now().UTC().Add(time.Hour / 2), + }) + otp.VerifyOtp().Render(context.Background(), w) +} + +func (e *emailLoginApi) HandleEmailSignup(w http.ResponseWriter, r *http.Request) { + var reqBody entities.SignupRequest + err := json.NewDecoder(r.Body).Decode(&reqBody) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + verificationToken, err := e.service.Signup(reqBody) + if err != nil { + log.Errorf("[EMAIL LOGIN API]: Failed to sign up a new user: %+v, error: %s\n", reqBody, err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: handlers.VerificationTokenKey, + Value: verificationToken, + HttpOnly: true, + Path: "/api/verify-otp", + Domain: config.Env().Hostname, + Expires: time.Now().UTC().Add(time.Hour / 2), + }) + otp.VerifyOtp().Render(context.Background(), w) +} + +func (e *emailLoginApi) HandleEmailOTPVerification(w http.ResponseWriter, r *http.Request) { + verificationToken, err := r.Cookie(handlers.VerificationTokenKey) + if err != nil { + w.Write([]byte("Invalid verification token")) + return + } + if verificationToken.Expires.After(time.Now().UTC()) { + w.Write([]byte("Expired verification token")) + return + } + + var reqBody entities.OtpRequest + err = json.NewDecoder(r.Body).Decode(&reqBody) + if err != nil { + log.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + + sessionToken, err := e.service.VerifyOtp(verificationToken.Value, reqBody) + // TODO: specify errors further suka + if err != nil { + log.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: handlers.SessionTokenKey, + Value: sessionToken, + HttpOnly: true, + Path: "/", + Domain: config.Env().Hostname, + Expires: time.Now().UTC().Add(time.Hour * 24 * 30), + }) + + w.Header().Set("HX-Redirect", "/") +} diff --git a/handlers/apis/google_login.go b/handlers/apis/google_login.go index 2ef138b..36d4786 100644 --- a/handlers/apis/google_login.go +++ b/handlers/apis/google_login.go @@ -2,18 +2,27 @@ package apis import ( "dankmuzikk/config" + "dankmuzikk/handlers" "dankmuzikk/log" - "dankmuzikk/services/google" + "dankmuzikk/services/login" "net/http" "time" ) -func HandleGoogleOAuthLogin(w http.ResponseWriter, r *http.Request) { - url := config.GoogleOAuthConfig().AuthCodeURL(google.CurrentRandomState()) +type googleLoginApi struct { + service *login.GoogleLoginService +} + +func NewGoogleLoginApi(service *login.GoogleLoginService) *googleLoginApi { + return &googleLoginApi{service} +} + +func (g *googleLoginApi) HandleGoogleOAuthLogin(w http.ResponseWriter, r *http.Request) { + url := config.GoogleOAuthConfig().AuthCodeURL(g.service.CurrentRandomState()) http.Redirect(w, r, url, http.StatusTemporaryRedirect) } -func HandleGoogleOAuthLoginCallback(w http.ResponseWriter, r *http.Request) { +func (g *googleLoginApi) HandleGoogleOAuthLoginCallback(w http.ResponseWriter, r *http.Request) { state := r.FormValue("state") if state == "" { w.WriteHeader(http.StatusBadRequest) @@ -27,7 +36,7 @@ func HandleGoogleOAuthLoginCallback(w http.ResponseWriter, r *http.Request) { return } - sessionToken, err := google.CompleteLoginWithGoogle(state, code) + sessionToken, err := g.service.Login(state, code) if err != nil { w.WriteHeader(http.StatusUnauthorized) log.Errorln("[GOOGLE LOGIN API]: ", err) @@ -35,9 +44,11 @@ func HandleGoogleOAuthLoginCallback(w http.ResponseWriter, r *http.Request) { } http.SetCookie(w, &http.Cookie{ - Name: "token", + Name: handlers.SessionTokenKey, Value: sessionToken, HttpOnly: true, + Path: "/", + Domain: config.Env().Hostname, Expires: time.Now().UTC().Add(time.Hour * 24 * 30), }) http.Redirect(w, r, config.Env().Hostname, http.StatusTemporaryRedirect) diff --git a/handlers/cookie_keys.go b/handlers/cookie_keys.go new file mode 100644 index 0000000..72e2bfa --- /dev/null +++ b/handlers/cookie_keys.go @@ -0,0 +1,6 @@ +package handlers + +const ( + VerificationTokenKey = "verification-token" + SessionTokenKey = "token" +) diff --git a/handlers/pages/pages.go b/handlers/pages/pages.go index 201b2be..b3a0915 100644 --- a/handlers/pages/pages.go +++ b/handlers/pages/pages.go @@ -1,13 +1,19 @@ package pages import ( + "dankmuzikk/config" + "dankmuzikk/handlers" "dankmuzikk/log" + "dankmuzikk/services/jwt" "net/http" + "slices" "strings" _ "github.com/a-h/templ" ) +var noAuthPaths = []string{"/login", "/signup"} + func Handler(hand http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -15,9 +21,35 @@ func Handler(hand http.HandlerFunc) http.HandlerFunc { } } -func AuthHandler(hand http.HandlerFunc) http.HandlerFunc { +func AuthHandler(hand http.HandlerFunc, jwtUtil jwt.Manager[any]) http.HandlerFunc { return Handler(func(w http.ResponseWriter, r *http.Request) { - log.Info(r.Cookie("token")) + sessionToken, err := r.Cookie(handlers.SessionTokenKey) + if err != nil { + log.Errorln("[AUTH]:", err) + if slices.Contains(noAuthPaths, r.URL.Path) { + hand(w, r) + return + } + http.Redirect(w, r, config.Env().Hostname+"/login", http.StatusTemporaryRedirect) + return + } + + err = jwtUtil.Validate(sessionToken.Value, jwt.SessionToken) + if err != nil { + log.Errorln("[AUTH]:", err) + if slices.Contains(noAuthPaths, r.URL.Path) { + hand(w, r) + return + } + http.Redirect(w, r, config.Env().Hostname+"/login", http.StatusTemporaryRedirect) + return + } + + if slices.Contains(noAuthPaths, r.URL.Path) { + http.Redirect(w, r, config.Env().Hostname, http.StatusTemporaryRedirect) + return + } + hand(w, r) }) } diff --git a/models/account.go b/models/account.go index 846a731..fb7aa45 100644 --- a/models/account.go +++ b/models/account.go @@ -5,6 +5,7 @@ import "time" type Account struct { Id uint `gorm:"primaryKey;autoIncrement"` Email string `gorm:"unique;not null"` + IsOAuth bool CreatedAt time.Time UpdatedAt time.Time } diff --git a/models/verification_code.go b/models/verification_code.go new file mode 100644 index 0000000..f7640c6 --- /dev/null +++ b/models/verification_code.go @@ -0,0 +1,16 @@ +package models + +import "time" + +type EmailVerificationCode struct { + Id uint `gorm:"primaryKey;autoIncrement"` + AccountId uint + Account Account + Code string `gorm:"not null"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (e EmailVerificationCode) GetId() uint { + return e.Id +} diff --git a/services/google/login.go b/services/google/login.go deleted file mode 100644 index 1505a67..0000000 --- a/services/google/login.go +++ /dev/null @@ -1,75 +0,0 @@ -package google - -import ( - "context" - "dankmuzikk/config" - "dankmuzikk/log" - "dankmuzikk/services/jwt" - "encoding/json" - "errors" - "net/http" - "time" - - "github.com/google/uuid" -) - -var ( - randomState = uuid.NewString() - jwtUtil = jwt.NewJWTImpl() -) - -func init() { - timer := time.NewTicker(time.Hour / 2) - go func() { - for range timer.C { - randomState = uuid.NewString() - } - }() - -} - -func CurrentRandomState() string { - return randomState -} - -type oauthUserInfo struct { - Email string `json:"email"` - FullName string `json:"name"` - PfpLink string `json:"picture"` - Locale string `json:"locale"` -} - -func CompleteLoginWithGoogle(state, code string) (string, error) { - if state != CurrentRandomState() { - log.Errorln("[GOOGLE LOGIN]: State is invalid") - return "", errors.New("state is not valid") - } - - token, err := config.GoogleOAuthConfig().Exchange(context.Background(), code) - if err != nil { - log.Errorln("[GOOGLE LOGIN]: Exchange code is not valid") - return "", errors.New("Exchange code is not valid") - } - - response, err := http.Get("https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + token.AccessToken) - if err != nil { - log.Errorln("[GOOGLE LOGIN]: Failed to fetch user info: ", err) - return "", err - } - defer response.Body.Close() - - var respBody oauthUserInfo - err = json.NewDecoder(response.Body).Decode(&respBody) - if err != nil { - log.Errorln("[GOOGLE LOGIN]: Failed to decode user info: ", err) - return "", err - } - - sessionToken, err := jwtUtil.Sign(respBody, jwt.SessionToken, time.Hour*24*30) - if err != nil { - log.Errorln("[GOOGLE LOGIN]: Failed to generate jwt: ", err) - return "", err - } - - return sessionToken, nil -} diff --git a/services/jwt/jwt_impl.go b/services/jwt/jwt_impl.go index c6c8a7d..08a785b 100644 --- a/services/jwt/jwt_impl.go +++ b/services/jwt/jwt_impl.go @@ -71,7 +71,7 @@ func (s *JWTImpl[T]) Decode(token string, subject Subject) (Claims[T], error) { return nil, ErrExpiredToken.NewWithNoMessage() } - return config.Env().JwtSecret, nil + return []byte(config.Env().JwtSecret), nil }) if err != nil { diff --git a/services/login/email.go b/services/login/email.go new file mode 100644 index 0000000..c34ca77 --- /dev/null +++ b/services/login/email.go @@ -0,0 +1,165 @@ +package login + +import ( + "dankmuzikk/db" + "dankmuzikk/entities" + "dankmuzikk/models" + "dankmuzikk/services/jwt" + "dankmuzikk/services/mailer" + "errors" + "fmt" + "math/rand" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type EmailLoginService struct { + accountRepo db.CRUDRepo[models.Account] + profileRepo db.CRUDRepo[models.Profile] + otpRepo db.CRUDRepo[models.EmailVerificationCode] + jwtUtil jwt.Manager[any] +} + +func NewEmailLoginService( + accountRepo db.CRUDRepo[models.Account], + profileRepo db.CRUDRepo[models.Profile], + otpRepo db.CRUDRepo[models.EmailVerificationCode], + jwtUtil jwt.Manager[any], +) *EmailLoginService { + return &EmailLoginService{ + accountRepo: accountRepo, + profileRepo: profileRepo, + otpRepo: otpRepo, + jwtUtil: jwtUtil, + } +} + +func (e *EmailLoginService) Login(user entities.LoginRequest) (string, error) { + account, err := e.accountRepo.GetByConds("email = ?", user.Email) + if err != nil { + return "", err + } + + profile, err := e.profileRepo.GetByConds("account_id = ?", account[0].Id) + if err != nil { + return "", err + } + profile[0].Account = account[0] + profile[0].AccountId = account[0].Id + + verificationToken, err := e.jwtUtil.Sign(map[string]string{ + "name": profile[0].Name, + "email": profile[0].Account.Email, + }, jwt.VerificationToken, time.Hour/2) + if err != nil { + return "", err + } + + return verificationToken, e.sendOtp(profile[0]) +} + +func (e *EmailLoginService) Signup(user entities.SignupRequest) (string, error) { + profile := models.Profile{ + Account: models.Account{ + Email: user.Email, + }, + Name: user.Name, + Username: user.Email[:strings.Index(user.Email, "@")], + } + + // creating a new account will create the account underneeth it. + err := e.profileRepo.Add(&profile) + if err != nil { + return "", err + } + + verificationToken, err := e.jwtUtil.Sign(map[string]string{ + "name": profile.Name, + "email": profile.Account.Email, + }, jwt.VerificationToken, time.Hour/2) + if err != nil { + return "", err + } + + return verificationToken, e.sendOtp(profile) +} + +func (e *EmailLoginService) VerifyOtp(token string, otp entities.OtpRequest) (string, error) { + user, err := e.jwtUtil.Decode(token, jwt.VerificationToken) + if err != nil { + return "", err + } + + mappedUser := user.Payload.(map[string]any) + email, emailExists := mappedUser["email"].(string) + // TODO: ADD THE FUCKING ERRORS SUKA + if !emailExists { + return "", errors.New("missing email") + } + name, nameExists := mappedUser["name"].(string) + // TODO: ADD THE FUCKING ERRORS SUKA + if !nameExists { + return "", errors.New("missing name") + } + + account, err := e.accountRepo.GetByConds("email = ?", email) + if err != nil { + return "", err + } + + verCodes, err := e.otpRepo.GetByConds("account_id = ?", account[0].Id) + if err != nil { + return "", err + } + verCode := verCodes[len(verCodes)-1] + defer func() { + _ = e.otpRepo.Delete("id = ?", verCode.Id) + }() + + err = bcrypt.CompareHashAndPassword([]byte(verCode.Code), []byte(otp.Code)) + if err != nil { + return "", err + } + + sessionToken, err := e.jwtUtil.Sign(map[string]string{ + "email": email, + "name": name, + }, jwt.SessionToken, time.Hour*24*30) + if err != nil { + return "", err + } + + return sessionToken, nil +} + +func (e *EmailLoginService) sendOtp(profile models.Profile) error { + otp := generateOtp() + + hashed, err := bcrypt.GenerateFromPassword([]byte(otp), bcrypt.DefaultCost) + if err != nil { + return err + } + + err = e.otpRepo.Add(&models.EmailVerificationCode{ + AccountId: profile.AccountId, + Code: string(hashed), + }) + if err != nil { + return err + } + + err = mailer.SendOtpEmail(profile.Name, profile.Account.Email, otp) + if err != nil { + return err + } + + return nil +} + +func generateOtp() string { + r := rand.New(rand.NewSource(time.Now().UnixMilli())) + n := r.Intn(1_000_000_000-100001) + 100001 + return fmt.Sprint(n)[:6] +} diff --git a/services/login/google.go b/services/login/google.go new file mode 100644 index 0000000..73a30f9 --- /dev/null +++ b/services/login/google.go @@ -0,0 +1,151 @@ +package login + +import ( + "context" + "dankmuzikk/config" + "dankmuzikk/db" + "dankmuzikk/log" + "dankmuzikk/models" + "dankmuzikk/services/jwt" + "encoding/json" + "errors" + + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/joomcode/errorx" +) + +var ( + randomState = uuid.NewString() +) + +func init() { + timer := time.NewTicker(time.Hour / 2) + go func() { + for range timer.C { + randomState = uuid.NewString() + } + }() + +} + +type oauthUserInfo struct { + Email string `json:"email"` + FullName string `json:"name"` + PfpLink string `json:"picture"` + Locale string `json:"locale"` +} + +type GoogleLoginService struct { + accountRepo db.CRUDRepo[models.Account] + profileRepo db.CRUDRepo[models.Profile] + otpRepo db.CRUDRepo[models.EmailVerificationCode] + jwtUtil jwt.Manager[any] +} + +func NewGoogleLoginService( + accountRepo db.CRUDRepo[models.Account], + profileRepo db.CRUDRepo[models.Profile], + otpRepo db.CRUDRepo[models.EmailVerificationCode], + jwtUtil jwt.Manager[any], +) *GoogleLoginService { + return &GoogleLoginService{ + accountRepo: accountRepo, + profileRepo: profileRepo, + otpRepo: otpRepo, + jwtUtil: jwtUtil, + } +} + +func (g *GoogleLoginService) Login(state, code string) (string, error) { + googleUser, err := g.completeLoginWithGoogle(state, code) + if err != nil { + return "", err + } + + account, err := g.accountRepo.GetByConds("email = ?", googleUser.Email) + if errorx.IsOfType(err, db.ErrRecordNotFound) || len(account) == 0 { + return g.Signup(googleUser) + } + if err != nil { + return "", err + } + + profile, err := g.profileRepo.GetByConds("account_id = ?", account[0].Id) + if err != nil { + return "", err + } + profile[0].Account = account[0] + profile[0].AccountId = account[0].Id + + verificationToken, err := g.jwtUtil.Sign(map[string]string{ + "name": profile[0].Name, + "email": profile[0].Account.Email, + }, jwt.SessionToken, time.Hour*24*30) + if err != nil { + return "", err + } + + return verificationToken, nil +} + +func (g *GoogleLoginService) Signup(googleUser oauthUserInfo) (string, error) { + profile := models.Profile{ + Account: models.Account{ + Email: googleUser.Email, + }, + Name: googleUser.FullName, + Username: googleUser.Email[:strings.Index(googleUser.Email, "@")], + } + // creating a new account will create the account underneeth it. + err := g.profileRepo.Add(&profile) + if err != nil { + return "", err + } + + verificationToken, err := g.jwtUtil.Sign(map[string]string{ + "name": profile.Name, + "email": profile.Account.Email, + }, jwt.SessionToken, time.Hour*24*30) + if err != nil { + return "", err + } + + return verificationToken, nil +} + +func (g *GoogleLoginService) completeLoginWithGoogle(state, code string) (oauthUserInfo, error) { + if state != g.CurrentRandomState() { + log.Errorln("[GOOGLE LOGIN]: State is invalid") + return oauthUserInfo{}, errors.New("state is not valid") + } + + token, err := config.GoogleOAuthConfig().Exchange(context.Background(), code) + if err != nil { + log.Errorln("[GOOGLE LOGIN]: Exchange code is not valid") + return oauthUserInfo{}, errors.New("Exchange code is not valid") + } + + response, err := http.Get("https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + token.AccessToken) + if err != nil { + log.Errorln("[GOOGLE LOGIN]: Failed to fetch user info: ", err) + return oauthUserInfo{}, err + } + defer response.Body.Close() + + var respBody oauthUserInfo + err = json.NewDecoder(response.Body).Decode(&respBody) + if err != nil { + log.Errorln("[GOOGLE LOGIN]: Failed to decode user info: ", err) + return oauthUserInfo{}, err + } + + return respBody, nil +} + +func (g *GoogleLoginService) CurrentRandomState() string { + return randomState +} diff --git a/services/mailer/mailer.go b/services/mailer/mailer.go new file mode 100644 index 0000000..8e32c1a --- /dev/null +++ b/services/mailer/mailer.go @@ -0,0 +1,22 @@ +package mailer + +import ( + "dankmuzikk/config" + "fmt" + "net/smtp" +) + +func sendEmail(subject, content, to string) error { + receiver := []string{to} + + mime := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n" + _subject := "Subject: " + subject + _to := "To: " + to + _from := fmt.Sprintf("From: Baraa from DankMuzikk <%s>", config.Env().Smtp.Username) + body := []byte(fmt.Sprintf("%s\n%s\n%s\n%s\n%s", _from, _to, _subject, mime, content)) + + addr := config.Env().Smtp.Host + ":" + config.Env().Smtp.Port + auth := smtp.PlainAuth("", config.Env().Smtp.Username, config.Env().Smtp.Password, config.Env().Smtp.Host) + + return smtp.SendMail(addr, auth, config.Env().Smtp.Username, receiver, body) +} diff --git a/services/mailer/otp_sender.go b/services/mailer/otp_sender.go new file mode 100644 index 0000000..62f106a --- /dev/null +++ b/services/mailer/otp_sender.go @@ -0,0 +1,16 @@ +package mailer + +import ( + "bytes" + "context" + "dankmuzikk/views/components/otp" +) + +func SendOtpEmail(name, email, code string) error { + buf := bytes.NewBuffer([]byte{}) + err := otp.OtpEmail(name, code).Render(context.Background(), buf) + if err != nil { + return err + } + return sendEmail("Email verification", buf.String(), email) +} diff --git a/static/images/github.webp b/static/images/github.webp new file mode 100644 index 0000000..948d42c Binary files /dev/null and b/static/images/github.webp differ diff --git a/static/images/google.webp b/static/images/google.webp new file mode 100644 index 0000000..5eb6bb5 Binary files /dev/null and b/static/images/google.webp differ diff --git a/static/js/json-enc.js b/static/js/json-enc.js index 41296eb..368581f 100644 --- a/static/js/json-enc.js +++ b/static/js/json-enc.js @@ -1 +1 @@ -htmx.defineExtension("json-enc",{onEvent:function(e,n){"htmx:configRequest"===e&&(n.detail.headers["Content-Type"]="application/json")},encodeParameters:function(e,n,t){for(let o of(n={},t))if(console.log("type",o.type,"name",o.name,"value",o.value),o.name&&o.value){let a=!1;"number"===o.type&&(a=!0),n[o.name]=a?Number(o.value):o.value}return console.log(n),e.overrideMimeType("text/json"),JSON.stringify(n)}}); +htmx.defineExtension("json-enc",{onEvent:function(e,n){"htmx:configRequest"===e&&(n.detail.headers["Content-Type"]="application/json")},encodeParameters:function(e,n,t){n={};for(const e of t)if(e.name&&e.value){let t=!1;if("number"===e.type)t=!0;n[e.name]=t?Number(e.value):e.value}return e.overrideMimeType("text/json"),JSON.stringify(n)}}); diff --git a/views/components/info/intro.templ b/views/components/info/intro.templ new file mode 100644 index 0000000..a370807 --- /dev/null +++ b/views/components/info/intro.templ @@ -0,0 +1,10 @@ +package info + +templ Intro() { +
+

+ Create, share, vote and play music playlists + with DankMuzikk. +

+
+} diff --git a/views/components/info/links.templ b/views/components/info/links.templ new file mode 100644 index 0000000..9599b04 --- /dev/null +++ b/views/components/info/links.templ @@ -0,0 +1,46 @@ +package info + +type linkT struct { + logoUrl string + target string + alt string +} + +var links = []linkT{ + { + logoUrl: "/static/images/github.webp", + target: "https://github.com/mbaraa/dankmuzikk", + alt: "GitHub - dankmuzikk", + }, + { + logoUrl: "https://mbaraa.com/resources/images/favicon.png", + target: "https://mbaraa.com", + alt: "mbaraa.com", + }, +} + +templ Links() { +
+ for _, l := range links { + @link(l) + } +
+} + +templ link(l linkT) { + + { + +} diff --git a/views/components/otp/otp.templ b/views/components/otp/otp.templ new file mode 100644 index 0000000..48a20dc --- /dev/null +++ b/views/components/otp/otp.templ @@ -0,0 +1,42 @@ +package otp + +templ VerifyOtp() { +

+ One more step... +

+
+
+ + +
+
+ +
+
+ + +} diff --git a/views/components/otp/otp_email.templ b/views/components/otp/otp_email.templ new file mode 100644 index 0000000..c00210f --- /dev/null +++ b/views/components/otp/otp_email.templ @@ -0,0 +1,65 @@ +package otp + +templ OtpEmail(name, code string) { + + +
+
+
+ DankMuzikk +
+

Hi { name },

+

+ Thank you for using DankMuzikk, below lies your one-time-password, which + will be valid for the next 30 minutes, don't share this code + with anyone in order to keep your account safe 😁 +

+

+ { code } +

+

Regards,
DankMuzikk Admin

+
+
+

DankMuzikk

+

+ pub@mbaraa.com +

+
+
+
+ + +} diff --git a/views/pages/login.templ b/views/pages/login.templ index ec573a8..efa8769 100644 --- a/views/pages/login.templ +++ b/views/pages/login.templ @@ -1,6 +1,9 @@ package pages -import "dankmuzikk/views/layouts" +import ( + "dankmuzikk/views/layouts" + "dankmuzikk/views/components/info" +) /* This page uses desktop design after lg (1024px) @@ -18,45 +21,90 @@ templ login() {
- lol +
+ @info.Intro() + @info.Links() +

Login

- - - + @loginForm() +
+

New here?

+ Signup +
+
+
+ @info.Links()
- + +} + +templ loginForm() { +
+
+ + +
+
+ + + + +
+
} diff --git a/views/pages/signup.templ b/views/pages/signup.templ index 7b2c132..999fd2d 100644 --- a/views/pages/signup.templ +++ b/views/pages/signup.templ @@ -1,26 +1,119 @@ package pages -import "dankmuzikk/views/layouts" +import ( + "dankmuzikk/views/layouts" + "dankmuzikk/views/components/info" +) + +/* + This page uses desktop design after lg (1024px) +*/ templ Signup(isMobile bool) { @layouts.Default(isMobile, signup()) } templ signup() { -
-
+ +
+ +
+ +
+
+ @info.Intro() + @info.Links() +
+
+
+ +
- cyka -
+
+

+ Sign up +

+ @signupForm() +
+

Already a DankMuzikker?

+ Login +
+
+
+ @info.Links() +
+
- + +} + +templ signupForm() { +
+
+ + +
+
+ + +
+
+ + + + +
+
}