diff --git a/backend/authentication/rpc.go b/backend/authentication/rpc.go index 761cce6d..74f12a9c 100644 --- a/backend/authentication/rpc.go +++ b/backend/authentication/rpc.go @@ -18,6 +18,7 @@ import ( "time" pb "github.com/cc-0000/indeq/common/api" + "github.com/cc-0000/indeq/common/util" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "golang.org/x/crypto/argon2" @@ -241,7 +242,7 @@ func (s *authServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb } // Generate a random OTP - otp, err := generateOTP() + otp, err := util.GenerateCode("numeric") if err != nil { // if the OTP generation fails, return an error return &pb.RegisterResponse{ @@ -337,7 +338,7 @@ func (s *authServer) ResendOTP(ctx context.Context, req *pb.ResendOTPRequest) (* Error: "Something went wrong. Please try again later.", }, nil } - newOTP, err := generateOTP() + newOTP, err := util.GenerateCode("numeric") if err != nil { // if the OTP generation fails, return an error return &pb.ResendOTPResponse{ @@ -639,7 +640,7 @@ func (s *authServer) ForgotPassword(ctx context.Context, req *pb.ForgotPasswordR req.Email = strings.ToLower(req.Email) // generate a random OTP - otp, err := generateOTP() + otp, err := util.GenerateCode("numeric") if err != nil { // if the OTP generation fails, return an error return &pb.ForgotPasswordResponse{ diff --git a/backend/authentication/util.go b/backend/authentication/util.go index c8e785bc..f62d4007 100644 --- a/backend/authentication/util.go +++ b/backend/authentication/util.go @@ -10,41 +10,9 @@ import ( "net/smtp" "os" "strings" - "golang.org/x/crypto/argon2" ) -// func() (string, error) -// - generates a secure 6-digit numeric OTP using crypto/rand -// - avoids modulo bias by discarding bytes > 250 -// - returns: the OTP string and error (if random generation fails) -func generateOTP() (string, error) { - const digits = "0123456789" - const length = 6 - otp := make([]byte, 0, length) - // Use 250 as the maximum to avoid modulo bias - max := byte(250) - // buffer to read random bytes in batches - buf := make([]byte, 16) - for len(otp) < length { - _, err := rand.Read(buf) - if err != nil { - return "", fmt.Errorf("failed to generate OTP: %w", err) - } - for _, b := range buf { - if b > max { - continue - } - otp = append(otp, digits[b%10]) - if len(otp) == length { - break - } - } - } - - return string(otp), nil -} - // func(password string, argonParams *params) (string, error) // - generates a secure password hash using the given Argon2 params // - returns: the hashed password and error (if hashing fails) diff --git a/backend/common/api/http.proto b/backend/common/api/http.proto index 014e7391..08142e41 100644 --- a/backend/common/api/http.proto +++ b/backend/common/api/http.proto @@ -152,6 +152,16 @@ message HttpAddToWaitlistResponse { string message = 2; } +message HttpValidateBetaCodeRequest { + string email = 1; + string beta_code = 2; +} + +message HttpValidateBetaCodeResponse { + bool success = 1; + string message = 2; +} + message HttpGetDesktopStatsRequest { } diff --git a/backend/common/api/waitlist.proto b/backend/common/api/waitlist.proto index b9d7be26..37572262 100644 --- a/backend/common/api/waitlist.proto +++ b/backend/common/api/waitlist.proto @@ -6,6 +6,7 @@ package api; service WaitlistService { rpc AddToWaitlist(AddToWaitlistRequest) returns (AddToWaitlistResponse); + rpc ValidateBetaCode(ValidateBetaCodeRequest) returns (ValidateBetaCodeResponse); } message AddToWaitlistRequest { @@ -15,4 +16,14 @@ message AddToWaitlistRequest { message AddToWaitlistResponse { bool success = 1; string message = 2; +} + +message ValidateBetaCodeRequest { + string email = 1; + string beta_code = 2; +} + +message ValidateBetaCodeResponse { + bool success = 1; + string message = 2; } \ No newline at end of file diff --git a/backend/common/util/code.go b/backend/common/util/code.go new file mode 100644 index 00000000..98035798 --- /dev/null +++ b/backend/common/util/code.go @@ -0,0 +1,44 @@ +package util + +import ( + "crypto/rand" + "fmt" +) + +// GenerateCode generates a secure 6-character OTP. +// Accepts mode: "numeric" or "alphanumeric". +// Uses crypto/rand and avoids modulo bias. +func GenerateCode(mode string) (string, error) { + var charset string + switch mode { + case "numeric": + charset = "0123456789" + case "alphanumeric": + charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + default: + return "", fmt.Errorf("invalid mode: %s", mode) + } + + const otpLength = 6 + const maxByte = 250 // to avoid modulo bias + otp := make([]byte, 0, otpLength) + buf := make([]byte, 16) + + for len(otp) < otpLength { + _, err := rand.Read(buf) + if err != nil { + return "", fmt.Errorf("failed to generate OTP: %w", err) + } + for _, b := range buf { + if b > maxByte { + continue + } + otp = append(otp, charset[b%byte(len(charset))]) + if len(otp) == otpLength { + break + } + } + } + + return string(otp), nil +} diff --git a/backend/gateway/main.go b/backend/gateway/main.go index f5a929aa..922db278 100644 --- a/backend/gateway/main.go +++ b/backend/gateway/main.go @@ -683,6 +683,39 @@ func handleAddToWaitlist(clients *ServiceClients) http.HandlerFunc { } } +func handleValidateBetaCode(clients *ServiceClients) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var validateBetaCodeRequest pb.HttpValidateBetaCodeRequest + log.Println("Received validate beta code request") + if err := json.NewDecoder(r.Body).Decode(&validateBetaCodeRequest); err != nil { + log.Printf("Error: %v", err) + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + if validateBetaCodeRequest.Email == "" || validateBetaCodeRequest.BetaCode == "" { + http.Error(w, "Missing email or beta code", http.StatusBadRequest) + return + } + + res, err := clients.waitlistClient.ValidateBetaCode(r.Context(), &pb.ValidateBetaCodeRequest{ + BetaCode: validateBetaCodeRequest.BetaCode, + Email: validateBetaCodeRequest.Email, + }) + if err != nil { + http.Error(w, "Failed to validate beta code", http.StatusInternalServerError) + return + } + + httpResponse := &pb.HttpValidateBetaCodeResponse{ + Success: res.Success, + Message: res.Message, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(httpResponse) + } +} + func handleGetDesktopStatsGenerator(clients *ServiceClients) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Set up context @@ -1279,6 +1312,7 @@ func main() { mux.HandleFunc("POST /api/ssooauth", handleSSOOAuthGenerator(serviceClients)) mux.HandleFunc("POST /api/ssologin", handleSSOLoginGenerator(serviceClients)) mux.HandleFunc("POST /api/waitlist", handleAddToWaitlist(serviceClients)) + mux.HandleFunc("POST /api/validate-beta-code", handleValidateBetaCode(serviceClients)) mux.HandleFunc("GET /api/desktop_stats", authMiddleware(handleGetDesktopStatsGenerator(serviceClients), serviceClients)) mux.HandleFunc("POST /api/manualcrawl", authMiddleware(handleManualCrawlGenerator(serviceClients), serviceClients)) mux.HandleFunc("POST /api/verify-otp", handleVerifyOTPGenerator(serviceClients)) diff --git a/backend/waitlist/main.go b/backend/waitlist/main.go index b3333d08..143b3a1a 100644 --- a/backend/waitlist/main.go +++ b/backend/waitlist/main.go @@ -7,9 +7,11 @@ import ( "net" "net/mail" "os" + "strings" "time" pb "github.com/cc-0000/indeq/common/api" + "github.com/cc-0000/indeq/common/util" "github.com/cc-0000/indeq/common/config" _ "github.com/lib/pq" "google.golang.org/grpc" @@ -23,6 +25,7 @@ type WaitlistServer struct { func (s *WaitlistServer) AddToWaitlist(ctx context.Context, req *pb.AddToWaitlistRequest) (*pb.AddToWaitlistResponse, error) { log.Println("Adding to waitlist:", req.Email) + req.Email = strings.ToLower(req.Email) _, err := mail.ParseAddress(req.Email) if err != nil { return &pb.AddToWaitlistResponse{ @@ -31,10 +34,18 @@ func (s *WaitlistServer) AddToWaitlist(ctx context.Context, req *pb.AddToWaitlis }, nil } + betaCode, err := util.GenerateCode("alphanumeric") + if err != nil { + return &pb.AddToWaitlistResponse{ + Success: false, + Message: "Something went wrong. Please try again later.", + }, nil + } + result, err := s.db.ExecContext(ctx, ` - INSERT INTO waitlist (email) - VALUES ($1) - ON CONFLICT (email) DO NOTHING`, req.Email) + INSERT INTO waitlist (email, beta_code) + VALUES ($1, $2) + ON CONFLICT (email) DO NOTHING`, req.Email, betaCode) if err != nil { log.Println("Database insert error:", err) @@ -66,6 +77,40 @@ func (s *WaitlistServer) AddToWaitlist(ctx context.Context, req *pb.AddToWaitlis }, nil } +func (s *WaitlistServer) ValidateBetaCode(ctx context.Context, req *pb.ValidateBetaCodeRequest) (*pb.ValidateBetaCodeResponse, error) { + log.Println("Validating beta code:", req.BetaCode) + + if req.BetaCode == "" || req.Email == "" { + return &pb.ValidateBetaCodeResponse{ + Success: false, + Message: "Invalid request", + }, nil + } + + var betaCode string + err := s.db.QueryRowContext(ctx, ` + SELECT beta_code FROM waitlist WHERE email = $1 + `, req.Email).Scan(&betaCode) + if err != nil { + return &pb.ValidateBetaCodeResponse{ + Success: false, + Message: "Could not validate beta code. Please try again later.", + }, nil + } + + if betaCode != req.BetaCode { + return &pb.ValidateBetaCodeResponse{ + Success: false, + Message: "Invalid beta code", + }, nil + } + + return &pb.ValidateBetaCodeResponse{ + Success: true, + Message: "Beta code validated successfully", + }, nil +} + func main() { log.Println("Starting the waitlist server...") @@ -99,6 +144,7 @@ func main() { CREATE TABLE IF NOT EXISTS waitlist ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) UNIQUE NOT NULL, + beta_code VARCHAR(6) NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); `) diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 8258e565..9404af5f 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -6,12 +6,14 @@ import { APP_ENV } from '$env/static/private'; export const handle: Handle = async ({ event, resolve }) => { const jwt = event.cookies.get('jwt'); + const betaPassed = event.cookies.get('betaPassed') === 'true'; const isAuthenticated = jwt && (await verifyToken(jwt)); const publicRoutes = [ '/', '/login', '/register', + '/beta-code', '/enter-code', '/forgot-password', '/reset-password', @@ -24,11 +26,14 @@ export const handle: Handle = async ({ event, resolve }) => { ]; const authRoutes = ['/login', '/register']; + const productionRoutes = ['/', '/terms', '/privacy', '/api/waitlist', '/sitemap.xml', '/login', '/beta-code']; - const productionRoutes = ['/', '/terms', '/privacy', '/api/waitlist', '/sitemap.xml']; - - if (APP_ENV === 'PRODUCTION' && !productionRoutes.includes(event.url.pathname)) { - return redirect(302, '/'); + if (APP_ENV === 'PRODUCTION') { + if (!productionRoutes.includes(event.url.pathname)) { + if (!(event.url.pathname === '/register' && betaPassed)) { + return redirect(302, '/'); + } + } } // Redirect authenticated users away from login and register pages @@ -37,12 +42,11 @@ export const handle: Handle = async ({ event, resolve }) => { } if (!publicRoutes.includes(event.url.pathname)) { - const isValid = jwt && (await verifyToken(jwt)); - - if (!isValid) { + if (!isAuthenticated) { return redirect(302, '/login'); } } return resolve(event); }; + diff --git a/frontend/src/lib/components/beta/BetaCodeCallout.svelte b/frontend/src/lib/components/beta/BetaCodeCallout.svelte new file mode 100644 index 00000000..4acab7f5 --- /dev/null +++ b/frontend/src/lib/components/beta/BetaCodeCallout.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/components/ui/input-otp/input-otp.svelte b/frontend/src/lib/components/ui/input-otp/input-otp.svelte index f7d48832..805a371c 100644 --- a/frontend/src/lib/components/ui/input-otp/input-otp.svelte +++ b/frontend/src/lib/components/ui/input-otp/input-otp.svelte @@ -13,6 +13,9 @@ /** Disable the whole control. */ export let disabled = false; + /** Mode of the input. */ + export let mode: 'numeric' | 'alpha' = 'numeric'; + const dispatch = createEventDispatcher<{ change: string; complete: string }>(); let inputs: HTMLInputElement[] = []; @@ -27,10 +30,13 @@ function handleInput(e: Event, i: number) { const el = e.target as HTMLInputElement; let chars = el.value.replace(/\s+/g, ''); - if (!/^\d$/.test(chars)) { - el.value = ''; - return; - } + + const isValidChar = mode === 'numeric' ? /^\d$/ : /^[a-zA-Z0-9]$/; + if (!isValidChar.test(chars)) { + el.value = ''; + return; + } + const arr = value.split(''); for (let j = 0; j < chars.length && i + j < length; j++) { arr[i + j] = chars[j]; @@ -70,7 +76,7 @@ function handlePaste(e: ClipboardEvent) { const txt = (e.clipboardData?.getData('text') || '').replace(/\s+/g, ''); - if (/^\d+$/.test(txt)) { + if (mode === 'numeric' ? /^\d+$/.test(txt) : /^[a-zA-Z0-9]+$/.test(txt)) { const digits = txt.slice(0, length).padEnd(length, ''); value = digits; digits.split('').forEach((d, idx) => (inputs[idx].value = d)); @@ -101,8 +107,8 @@ class="w-12 h-12 text-center text-lg border rounded focus:outline-none focus:ring focus:ring-primary disabled:opacity-50" type="text" - inputmode="numeric" - pattern="\d*" + inputmode={mode === 'numeric' ? 'numeric' : 'text'} + pattern={mode === 'numeric' ? '\\d*' : '[a-zA-Z0-9]*'} maxlength="1" disabled={disabled} value={value[i] ?? ''} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 732e7e95..db830a78 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -2,6 +2,8 @@ import { BoxIcon, MailIcon, LoaderIcon } from 'svelte-feather-icons'; import { onMount } from 'svelte'; import { toast } from 'svelte-sonner'; + import { PUBLIC_GO_BACKEND_URL } from '$env/static/public'; + import BetaCodeCallout from '$lib/components/beta/BetaCodeCallout.svelte'; let email = ''; let submitStatus: 'idle' | 'loading' | 'success' | 'error' = 'idle'; @@ -11,7 +13,7 @@ submitStatus = 'loading'; try { - const response = await fetch('/api/waitlist', { + const response = await fetch(`${PUBLIC_GO_BACKEND_URL}/api/waitlist`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -158,6 +160,7 @@ +