diff --git a/.gitignore b/.gitignore index a930ab6eab7ec337c6e3a4848fa7b42a788f411a..d9498b7c9e7a691536da937bacf823a9bdaf5309 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ *.dylib /backend +# database +/db + # Test binary, built with `go test -c` *.test diff --git a/app.go b/app.go new file mode 100644 index 0000000000000000000000000000000000000000..df0e0bc00b02fef6b8e5ef1c2107a68b1cb728d3 --- /dev/null +++ b/app.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/limiter" + "github.com/syndtr/goleveldb/leveldb" +) + +func serve(sig chan os.Signal, db *leveldb.DB) error { + app := fiber.New() + + // cors + app.Use(cors.New(cors.Config{ + AllowOrigins: conf[allowedOrigins], + AllowHeaders: "Origin, Content-Type, Accept", + })) + + // rate limiting + app.Use(limiter.New(limiter.Config{ + Max: 5, + Expiration: 7 * 24 * time.Hour, // 1 week expiration + LimitReached: func(c *fiber.Ctx) error { + log.Printf("Rate limit exceeded for IP: %s", c.IP()) + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "message": "Rate limit exceeded. Max 5 registrations per week.", + }) + }, + })) + + // /register + routeRegister(app, db) + + // Graceful shutdown + app.Use(func(c *fiber.Ctx) error { + return c.Next() + }) + + // graceful shutdown + go func() { + <-sig + log.Println("shutting down") + if err := app.Shutdown(); err != nil { + fmt.Printf("cannot shutdown: %v", err) + } + }() + + return app.Listen(conf[listenAddr]) +} diff --git a/conf.go b/conf.go new file mode 100644 index 0000000000000000000000000000000000000000..cb9cafb6c9df22993b66ac9dfe2e536b8e8346cd --- /dev/null +++ b/conf.go @@ -0,0 +1,47 @@ +package main + +import ( + "log" + "os" +) + +const ( + dbPath uint8 = iota + listenAddr + allowedOrigins + verboseLogging + + confLen +) + +// env variable, default pairing +var confEnv = [confLen][2]string{ + {"DB", "db"}, + {"LISTEN_ADDR", "127.0.0.1:3000"}, + {"ALLOWED_ORIGINS", "https://hizla.io"}, + {"VERBOSE", "1"}, +} + +// resolved config values +var conf [confLen]string + +var verbose bool + +func init() { + for i := 0; i < int(confLen); i++ { + if v, ok := os.LookupEnv(confEnv[i][0]); !ok { + conf[i] = confEnv[i][1] + } else { + conf[i] = v + } + } + + switch conf[verboseLogging] { + case "0": + verbose = false + case "1": + verbose = true + default: + log.Printf("invalid verbose value %q", conf[verboseLogging]) + } +} diff --git a/db.go b/db.go deleted file mode 100644 index 527af3ffa86668ee686985d66b4eaba4efe5ead5..0000000000000000000000000000000000000000 --- a/db.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "github.com/syndtr/goleveldb/leveldb" - "log" -) - -var db *leveldb.DB - -func InitDB(path string) error { - var err error - db, err = leveldb.OpenFile(path, nil) - if err != nil { - return err - } - return nil -} - -func CloseDB() { - if err := db.Close(); err != nil { - log.Printf("Warning: Failed to close LevelDB: %v", err) - } -} - -func SaveEmail(email string) error { - if err := db.Put([]byte(email), []byte("registered"), nil); err != nil { - log.Printf("Error saving email to LevelDB: %v", err) - return err - } - return nil -} - -func IsEmailRegistered(email string) (bool, error) { - _, err := db.Get([]byte(email), nil) - if err != nil { - if err == leveldb.ErrNotFound { - return false, nil - } - log.Printf("Error checking email registration: %v", err) - return false, err - } - return true, nil -} diff --git a/go.mod b/go.mod index f0ac863a8630d833b7755f90401238afb5e730da..9236dc386fbdb1360fdb2bdd28b0ef6fcbc7c196 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.randomchars.ne/hizla/waitlist/backend +module git.randomchars.net/hizla/waitlist/backend go 1.22.3 diff --git a/main.go b/main.go index e65f449f1c6a0f2d30edfeb080d0c756b14227f8..1f4cd6efd3d603ed5f3077b6c0b29fdbfd679bd4 100644 --- a/main.go +++ b/main.go @@ -1,104 +1,34 @@ package main import ( - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/gofiber/fiber/v2/middleware/limiter" "log" "os" - "regexp" - "time" -) + "os/signal" + "syscall" -var emailRegex = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`) + "github.com/syndtr/goleveldb/leveldb" +) func main() { + var db *leveldb.DB - app := fiber.New() - - app.Use(cors.New(cors.Config{ - AllowOrigins: os.Getenv("ALLOWED_ORIGINS"), - AllowHeaders: "Origin, Content-Type, Accept", - })) - - if err := InitDB("waitlistdb"); err != nil { - log.Fatalf("Failed to initialize LevelDB: %v", err) + if d, err := leveldb.OpenFile(conf[dbPath], nil); err != nil { + log.Fatalf("cannot open database %q: %v", conf[dbPath], err) + } else { + db = d } - - defer CloseDB() - - app.Use(limiter.New(limiter.Config{ - Max: 5, - Expiration: 7 * 24 * time.Hour, // 1 week expiration - LimitReached: func(c *fiber.Ctx) error { - log.Printf("Rate limit exceeded for IP: %s", c.IP()) - return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ - "message": "Rate limit exceeded. Max 5 registrations per week.", - }) - }, - })) - - // Waitlist registration route - app.Post("/register", func(c *fiber.Ctx) error { - type Request struct { - Email string `json:"email"` - } - req := new(Request) - - if err := c.BodyParser(req); err != nil { - log.Printf("Invalid request body: %v", err) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "Invalid request", - }) + defer func() { + if err := db.Close(); err != nil { + log.Printf("cannot close database %q: %v", conf[dbPath], err) } + }() - // Validate email format - if !emailRegex.MatchString(req.Email) { - log.Printf("Invalid email format: %s", req.Email) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "Invalid email format", - }) - } - - // Check if email is already registered - registered, err := IsEmailRegistered(req.Email) - if err != nil { - log.Printf("Error checking email registration: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "message": "Error checking registration status", - }) - } - if registered { - log.Printf("Email already registered: %s", req.Email) - return c.Status(fiber.StatusConflict).JSON(fiber.Map{ - "message": "Email already registered", - }) - } - - // Save the email to the waitlist - if err := SaveEmail(req.Email); err != nil { - log.Printf("Error registering email: %v", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "message": "Error registering email", - }) - } - - log.Printf("Email registered successfully: %s", req.Email) - return c.JSON(fiber.Map{ - "message": "Email registered successfully", - }) - }) - - - // Graceful shutdown - app.Use(func(c *fiber.Ctx) error { - return c.Next() - }) + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) - port := os.Getenv("PORT") - if port == "" { - port = "3000" + if err := serve(sig, db); err != nil { + log.Printf("cannot serve: %v", err) } - log.Fatal(app.Listen(":" + port)) + log.Println("application exit") } diff --git a/register.go b/register.go new file mode 100644 index 0000000000000000000000000000000000000000..38f846b80933dfd4718dca090482aa3b821d24c0 --- /dev/null +++ b/register.go @@ -0,0 +1,69 @@ +package main + +import ( + "log" + "regexp" + + "github.com/gofiber/fiber/v2" + "github.com/syndtr/goleveldb/leveldb" +) + +var emailRegexp = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`) + +type registration struct { + Email string `json:"email"` +} + +// Waitlist registration route +func routeRegister(app *fiber.App, db *leveldb.DB) { + app.Post("/register", func(c *fiber.Ctx) error { + req := new(registration) + + if err := c.BodyParser(req); err != nil { + if verbose { + log.Printf("invalid request from %q: %v", c.IP(), err) + } + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Invalid request", + }) + } + + // Validate email format + if !emailRegexp.MatchString(req.Email) { + if verbose { + log.Printf("invalid email from %q: %s", c.IP(), req.Email) + } + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Invalid email format", + }) + } + + // Check if email is already registered + if ok, err := db.Has([]byte(req.Email), nil); err != nil { + log.Printf("cannot check for existence of email %q: %v", req.Email, err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Error checking registration status", + }) + } else if ok { + if verbose { + log.Printf("duplicate email from %q: %s", c.IP(), req.Email) + } + return c.Status(fiber.StatusConflict).JSON(fiber.Map{ + "message": "Email already registered", + }) + } + + // Save the email to the waitlist + if err := db.Put([]byte(req.Email), []byte{'x'}, nil); err != nil { + log.Printf("cannot register email %q: %v", req.Email, err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Error registering email", + }) + } + + log.Printf("registered email %q", req.Email) + return c.JSON(fiber.Map{ + "message": "Email registered successfully", + }) + }) +}