diff options
| author | Bobby <[email protected]> | 2026-03-03 18:30:54 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-03-03 18:31:56 +0530 |
| commit | c0a03e523b4d46a4070ceb6a5d9bad8797ba376a (patch) | |
| tree | b5ff6b43721551aa4ba67eabf6616604a7bf9dfa /shrine | |
| parent | f6e671264734b8d82b48102041ad7f92ec2f1048 (diff) | |
| download | pagoda-c0a03e523b4d46a4070ceb6a5d9bad8797ba376a.tar.xz pagoda-c0a03e523b4d46a4070ceb6a5d9bad8797ba376a.zip | |
feat: implement user authentication and registration with token support
Diffstat (limited to 'shrine')
| -rw-r--r-- | shrine/config/env.go | 13 | ||||
| -rw-r--r-- | shrine/controllers/auth.go | 89 | ||||
| -rw-r--r-- | shrine/controllers/responses.go (renamed from shrine/controllers/errors.go) | 8 | ||||
| -rw-r--r-- | shrine/controllers/sample.go | 17 | ||||
| -rw-r--r-- | shrine/database/migrate.go | 4 | ||||
| -rw-r--r-- | shrine/enums/role.go | 9 | ||||
| -rw-r--r-- | shrine/go.mod | 2 | ||||
| -rw-r--r-- | shrine/models/token.go | 34 | ||||
| -rw-r--r-- | shrine/models/user.go | 149 | ||||
| -rw-r--r-- | shrine/repositories/token.go | 23 | ||||
| -rw-r--r-- | shrine/repositories/user.go | 26 | ||||
| -rw-r--r-- | shrine/router/auth.go | 17 | ||||
| -rw-r--r-- | shrine/router/sample.go | 14 | ||||
| -rw-r--r-- | shrine/types/request.go | 13 | ||||
| -rw-r--r-- | shrine/types/response.go | 26 | ||||
| -rw-r--r-- | shrine/utils/auth/auth.go | 89 | ||||
| -rw-r--r-- | shrine/utils/collections/set.go | 28 | ||||
| -rw-r--r-- | shrine/utils/meta/request.go | 6 | ||||
| -rw-r--r-- | shrine/utils/meta/types.go | 2 | ||||
| -rw-r--r-- | shrine/utils/meta/value.go | 4 | ||||
| -rw-r--r-- | shrine/utils/validators/email.go | 21 | ||||
| -rw-r--r-- | shrine/utils/validators/username.go | 43 |
22 files changed, 590 insertions, 47 deletions
diff --git a/shrine/config/env.go b/shrine/config/env.go index f48cd90..931775b 100644 --- a/shrine/config/env.go +++ b/shrine/config/env.go @@ -1,11 +1,14 @@ package config +import "time" + type server struct { - Host string `env:"HOST" default:"0.0.0.0"` - Port int `env:"PORT" default:"3000"` - Secret string `env:"SECRET" default:"pagoda-secret"` - Debug bool `env:"DEBUG" default:"false"` - CorsOrigins string `env:"CORS_ORIGINS" default:"*"` + Host string `env:"HOST" default:"0.0.0.0"` + Port int `env:"PORT" default:"3000"` + Secret string `env:"SECRET" default:"pagoda-secret"` + Debug bool `env:"DEBUG" default:"false"` + CorsOrigins string `env:"CORS_ORIGINS" default:"*"` + TokenExpiry time.Duration `env:"TOKEN_EXPIRY" default:"720h"` } type database struct { diff --git a/shrine/controllers/auth.go b/shrine/controllers/auth.go new file mode 100644 index 0000000..c6c2b7a --- /dev/null +++ b/shrine/controllers/auth.go @@ -0,0 +1,89 @@ +package controllers + +import ( + "errors" + "shrine/models" + "shrine/repositories" + "shrine/types" + "shrine/utils/auth" + "shrine/utils/meta" + + "github.com/gofiber/fiber/v2" +) + +func RegisterController(context *fiber.Ctx) error { + body, err := meta.Body[types.RegisterRequest](context) + if err != nil { + return BadRequest(context, errors.New("invalid request body")) + } + + user := models.User{ + Username: body.Username, + Email: body.Email, + DisplayName: body.DisplayName, + } + + if err := user.SetPassword(body.Password); err != nil { + return BadRequest(context, err) + } + + if err := repositories.CreateUser(&user); err != nil { + return BadRequest(context, err) + } + + token, err := auth.IssueToken(context, user.ID) + if err != nil { + return InternalServerError(context, errors.New("failed to create session")) + } + + return Created(context, types.AuthResponse{ + Token: token, + User: user.ToResponse(), + }) +} + +func LoginController(context *fiber.Ctx) error { + body, err := meta.Body[types.LoginRequest](context) + if err != nil { + return BadRequest(context, errors.New("invalid request body")) + } + + user, err := repositories.FindUserByUsername(body.Username) + if err != nil { + return Unauthorized(context, errors.New("invalid credentials")) + } + + if !user.CanAuthenticate() { + return Forbidden(context, errors.New("account is not eligible for authentication")) + } + + if !user.CheckPassword(body.Password) { + return Unauthorized(context, errors.New("invalid credentials")) + } + + token, err := auth.IssueToken(context, user.ID) + if err != nil { + return InternalServerError(context, errors.New("failed to create session")) + } + + return Success(context, types.AuthResponse{ + Token: token, + User: user.ToResponse(), + }) +} + +func LogoutController(context *fiber.Ctx) error { + tokenHash := auth.GetTokenHash(context) + if err := repositories.DeleteToken(tokenHash); err != nil { + return InternalServerError(context, errors.New("failed to end session")) + } + + return Success(context, types.MessageResponse{ + Message: "logged out", + }) +} + +func MeController(context *fiber.Ctx) error { + user := auth.GetUser(context) + return Success(context, user.ToResponse()) +}
\ No newline at end of file diff --git a/shrine/controllers/errors.go b/shrine/controllers/responses.go index 55eb203..b3fa824 100644 --- a/shrine/controllers/errors.go +++ b/shrine/controllers/responses.go @@ -42,3 +42,11 @@ func DefaultError(context *fiber.Ctx, err error) error { Error: err.Error(), }).As(fiber.StatusInternalServerError) } + +func Success(context *fiber.Ctx, data any) error { + return shortcuts.Response(context, data).As(fiber.StatusOK) +} + +func Created(context *fiber.Ctx, data any) error { + return shortcuts.Response(context, data).As(fiber.StatusCreated) +} diff --git a/shrine/controllers/sample.go b/shrine/controllers/sample.go deleted file mode 100644 index 542d86c..0000000 --- a/shrine/controllers/sample.go +++ /dev/null @@ -1,17 +0,0 @@ -package controllers - -import ( - "fmt" - "shrine/types" - "shrine/utils/meta" - "shrine/utils/shortcuts" - - "github.com/gofiber/fiber/v2" -) - -func HelloController(context *fiber.Ctx) error { - name := meta.Request(context).Default("World").Query("name") - return shortcuts.Response(context, types.HelloResponse{ - Message: fmt.Sprintf("Hello, %s!", name), - }).As(fiber.StatusOK) -} diff --git a/shrine/database/migrate.go b/shrine/database/migrate.go index 7d99788..5835a76 100644 --- a/shrine/database/migrate.go +++ b/shrine/database/migrate.go @@ -1,12 +1,14 @@ package database import ( + "shrine/models" "shrine/utils/logger" ) func migrate() { err := DB.AutoMigrate( - // Models will be added here as they are created + &models.User{}, + &models.Token{}, ) if err != nil { logger.Fatalf("Database", "Error during database migration: %v", err) diff --git a/shrine/enums/role.go b/shrine/enums/role.go new file mode 100644 index 0000000..3b38cbb --- /dev/null +++ b/shrine/enums/role.go @@ -0,0 +1,9 @@ +package enums + +type UserRole string + +const ( + Member UserRole = "member" + Moderator UserRole = "moderator" + Admin UserRole = "admin" +)
\ No newline at end of file diff --git a/shrine/go.mod b/shrine/go.mod index 137a175..d61e853 100644 --- a/shrine/go.mod +++ b/shrine/go.mod @@ -6,6 +6,7 @@ require ( github.com/gofiber/fiber/v2 v2.52.12 github.com/joho/godotenv v1.5.1 go.uber.org/zap v1.27.1 + golang.org/x/crypto v0.31.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 @@ -31,7 +32,6 @@ require ( github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.31.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect diff --git a/shrine/models/token.go b/shrine/models/token.go new file mode 100644 index 0000000..2c25c4e --- /dev/null +++ b/shrine/models/token.go @@ -0,0 +1,34 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type Token struct { + gorm.Model + TokenHash string `gorm:"uniqueIndex;size:64;not null"` + UserID uint `gorm:"index;not null"` + User User `gorm:"foreignKey:UserID"` + ExpiresAt time.Time + IPAddress string `gorm:"size:45"` + UserAgent string `gorm:"size:512"` +} + +func (token *Token) IsExpired() bool { + return time.Now().After(token.ExpiresAt) +} + +func (token *Token) BeforeCreate(tx *gorm.DB) error { + if token.TokenHash == "" { + return gorm.ErrInvalidData + } + if token.UserID == 0 { + return gorm.ErrInvalidData + } + if token.ExpiresAt.IsZero() { + return gorm.ErrInvalidData + } + return nil +}
\ No newline at end of file diff --git a/shrine/models/user.go b/shrine/models/user.go new file mode 100644 index 0000000..85bd0bf --- /dev/null +++ b/shrine/models/user.go @@ -0,0 +1,149 @@ +package models + +import ( + "fmt" + "shrine/enums" + "shrine/types" + "shrine/utils/validators" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type User struct { + gorm.Model + Username string `gorm:"uniqueIndex;size:32;not null"` + Email string `gorm:"uniqueIndex;size:255;not null"` + PasswordHash string `gorm:"size:255;not null"` + DisplayName string `gorm:"size:50;not null"` + Bio string `gorm:"size:500"` + Birthday *time.Time + AvatarURL string `gorm:"size:512"` + BlinkieURL string `gorm:"size:512"` + Website string `gorm:"size:255"` + Location string `gorm:"size:100"` + Pronouns string `gorm:"size:50"` + Signature string `gorm:"size:500"` + Role enums.UserRole `gorm:"size:20;not null;default:member"` + EmailVerified bool `gorm:"not null;default:false"` + AccountBanned bool `gorm:"not null;default:false"` + BannedAt *time.Time + BannedReason string `gorm:"size:500"` + BannedBy *uint `gorm:"index"` + AccountDisabled bool `gorm:"not null;default:false"` + DisabledAt *time.Time + DisabledReason string `gorm:"size:500"` + DisabledBy *uint `gorm:"index"` + LastSeenAt *time.Time + RegistrationIP string `gorm:"size:45"` +} + +func (user *User) SetPassword(password string) error { + if len(password) < 8 { + return fmt.Errorf("password must be at least 8 characters") + } + if len(password) > 255 { + return fmt.Errorf("password must be at most 255 characters") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + user.PasswordHash = string(hash) + return nil +} + +func (user *User) CheckPassword(password string) bool { + return bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) == nil +} + +func (user *User) IsAdmin() bool { + return user.Role == enums.Admin +} + +func (user *User) IsModerator() bool { + return user.Role == enums.Moderator +} + +func (user *User) IsStaff() bool { + return user.IsAdmin() || user.IsModerator() +} + +func (user *User) IsBanned() bool { + return user.AccountBanned +} + +func (user *User) IsDisabled() bool { + return user.AccountDisabled +} + +func (user *User) CanAuthenticate() bool { + return !user.AccountBanned && !user.AccountDisabled && user.EmailVerified +} + +func (user *User) ToResponse() types.UserResponse { + return types.UserResponse{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + DisplayName: user.DisplayName, + Bio: user.Bio, + Birthday: user.Birthday, + AvatarURL: user.AvatarURL, + BlinkieURL: user.BlinkieURL, + Website: user.Website, + Location: user.Location, + Pronouns: user.Pronouns, + Signature: user.Signature, + Role: string(user.Role), + CreatedAt: user.CreatedAt, + } +} + +func (user *User) BeforeCreate(tx *gorm.DB) error { + _, bypassUsername := tx.Get("bypass_username_validation") + + if !bypassUsername { + if !validators.IsValidUsername(user.Username, 3) { + return fmt.Errorf("username must be 3-32 characters, alphanumeric and underscores only") + } + if validators.IsReservedUsername(user.Username) { + return fmt.Errorf("username is reserved") + } + } + + if !validators.IsValidEmail(user.Email) { + return fmt.Errorf("invalid email format") + } + + if len(strings.TrimSpace(user.DisplayName)) < 1 || len(strings.TrimSpace(user.DisplayName)) > 50 { + return fmt.Errorf("display name must be between 1 and 50 characters") + } + + if user.PasswordHash == "" { + return fmt.Errorf("password is required") + } + + user.Email = strings.ToLower(strings.TrimSpace(user.Email)) + user.DisplayName = strings.TrimSpace(user.DisplayName) + user.Username = strings.TrimSpace(user.Username) + + return nil +} + +func (user *User) BeforeUpdate(tx *gorm.DB) error { + if !validators.IsValidEmail(user.Email) { + return fmt.Errorf("invalid email format") + } + + if len(strings.TrimSpace(user.DisplayName)) < 1 || len(strings.TrimSpace(user.DisplayName)) > 50 { + return fmt.Errorf("display name must be between 1 and 50 characters") + } + + user.Email = strings.ToLower(strings.TrimSpace(user.Email)) + user.DisplayName = strings.TrimSpace(user.DisplayName) + + return nil +}
\ No newline at end of file diff --git a/shrine/repositories/token.go b/shrine/repositories/token.go new file mode 100644 index 0000000..ef671f8 --- /dev/null +++ b/shrine/repositories/token.go @@ -0,0 +1,23 @@ +package repositories + +import ( + "shrine/database" + "shrine/models" + "time" +) + +func CreateToken(token *models.Token) error { + return database.DB.Create(token).Error +} + +func FindValidToken(tokenHash string) (*models.Token, error) { + var token models.Token + err := database.DB.Preload("User"). + Where("token_hash = ? AND expires_at > ?", tokenHash, time.Now()). + First(&token).Error + return &token, err +} + +func DeleteToken(tokenHash string) error { + return database.DB.Where("token_hash = ?", tokenHash).Delete(&models.Token{}).Error +}
\ No newline at end of file diff --git a/shrine/repositories/user.go b/shrine/repositories/user.go new file mode 100644 index 0000000..93205a0 --- /dev/null +++ b/shrine/repositories/user.go @@ -0,0 +1,26 @@ +package repositories + +import ( + "shrine/database" + "shrine/enums" + "shrine/models" +) + +func CreateUser(user *models.User) error { + var count int64 + database.DB.Model(&models.User{}).Count(&count) + + tx := database.DB + if count == 0 { + user.Role = enums.Admin + tx = tx.Set("bypass_username_validation", true) + } + + return tx.Create(user).Error +} + +func FindUserByUsername(username string) (*models.User, error) { + var user models.User + err := database.DB.Where("username = ?", username).First(&user).Error + return &user, err +}
\ No newline at end of file diff --git a/shrine/router/auth.go b/shrine/router/auth.go new file mode 100644 index 0000000..660ba7f --- /dev/null +++ b/shrine/router/auth.go @@ -0,0 +1,17 @@ +package router + +import ( + "shrine/controllers" + "shrine/types" + "shrine/utils/auth" + "shrine/utils/urls" +) + +func init() { + urls.SetNamespace("auth") + + urls.Path(types.POST, "/register", controllers.RegisterController, "register") + urls.Path(types.POST, "/login", controllers.LoginController, "login") + urls.Path(types.POST, "/logout", auth.RequireAuthentication(controllers.LogoutController), "logout") + urls.Path(types.GET, "/me", auth.RequireAuthentication(controllers.MeController), "me") +}
\ No newline at end of file diff --git a/shrine/router/sample.go b/shrine/router/sample.go deleted file mode 100644 index e879371..0000000 --- a/shrine/router/sample.go +++ /dev/null @@ -1,14 +0,0 @@ -package router - -import ( - "shrine/controllers" - "shrine/types" - "shrine/utils/auth" - "shrine/utils/urls" -) - -func init() { - urls.SetNamespace("sample") - - urls.Path(types.GET, "/hello", auth.RequireAuthentication(controllers.HelloController), "hello") -} diff --git a/shrine/types/request.go b/shrine/types/request.go new file mode 100644 index 0000000..096c23e --- /dev/null +++ b/shrine/types/request.go @@ -0,0 +1,13 @@ +package types + +type RegisterRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"display_name"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +}
\ No newline at end of file diff --git a/shrine/types/response.go b/shrine/types/response.go index 3d267bc..7e15ecc 100644 --- a/shrine/types/response.go +++ b/shrine/types/response.go @@ -1,9 +1,33 @@ package types +import "time" + type ErrorResponse struct { Error string `json:"error"` } -type HelloResponse struct { +type MessageResponse struct { Message string `json:"message"` } + +type UserResponse struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Bio string `json:"bio"` + Birthday *time.Time `json:"birthday"` + AvatarURL string `json:"avatar_url"` + BlinkieURL string `json:"blinkie_url"` + Website string `json:"website"` + Location string `json:"location"` + Pronouns string `json:"pronouns"` + Signature string `json:"signature"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` +} + +type AuthResponse struct { + Token string `json:"token"` + User UserResponse `json:"user"` +} diff --git a/shrine/utils/auth/auth.go b/shrine/utils/auth/auth.go index 068ddd7..488ba91 100644 --- a/shrine/utils/auth/auth.go +++ b/shrine/utils/auth/auth.go @@ -1,9 +1,60 @@ package auth -import "github.com/gofiber/fiber/v2" +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "shrine/config" + "shrine/models" + "shrine/repositories" + "shrine/utils/meta" + "strings" + "time" + + "github.com/gofiber/fiber/v2" +) + +const ( + userKey = "__auth_user" + tokenHashKey = "__auth_token_hash" +) + +func GenerateToken() (string, error) { + bytes := make([]byte, 32) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func HashToken(rawToken string) string { + mac := hmac.New(sha256.New, []byte(config.Server.Secret)) + mac.Write([]byte(rawToken)) + return hex.EncodeToString(mac.Sum(nil)) +} func IsAuthenticated(context *fiber.Ctx) bool { - // We will implement token based authentication in the future + header, ok := meta.Request(context).Header("Authorization") + if !ok || !strings.HasPrefix(header, "Bearer ") { + return false + } + + rawToken := strings.TrimPrefix(header, "Bearer ") + tokenHash := HashToken(rawToken) + + token, err := repositories.FindValidToken(tokenHash) + if err != nil { + return false + } + + if !token.User.CanAuthenticate() { + return false + } + + context.Locals(userKey, &token.User) + context.Locals(tokenHashKey, tokenHash) return true } @@ -15,3 +66,37 @@ func RequireAuthentication(handler fiber.Handler) fiber.Handler { return handler(context) } } + +func GetUser(context *fiber.Ctx) *models.User { + user, _ := context.Locals(userKey).(*models.User) + return user +} + +func GetTokenHash(context *fiber.Ctx) string { + hash, _ := context.Locals(tokenHashKey).(string) + return hash +} + +func IssueToken(context *fiber.Ctx, userID uint) (string, error) { + token, err := GenerateToken() + if err != nil { + return "", err + } + + request := meta.Request(context) + userAgent, _ := request.Header("User-Agent") + + record := models.Token{ + TokenHash: HashToken(token), + UserID: userID, + ExpiresAt: time.Now().Add(config.Server.TokenExpiry), + IPAddress: request.IP, + UserAgent: userAgent, + } + + if err := repositories.CreateToken(&record); err != nil { + return "", err + } + + return token, nil +}
\ No newline at end of file diff --git a/shrine/utils/collections/set.go b/shrine/utils/collections/set.go new file mode 100644 index 0000000..69e9de1 --- /dev/null +++ b/shrine/utils/collections/set.go @@ -0,0 +1,28 @@ +package collections + +type Set[T comparable] map[T]struct{} + +func SetOf[T comparable](values ...T) Set[T] { + set := make(Set[T], len(values)) + for _, value := range values { + set[value] = struct{}{} + } + return set +} + +func (set Set[T]) Has(value T) bool { + _, exists := set[value] + return exists +} + +func (set Set[T]) Add(value T) { + set[value] = struct{}{} +} + +func (set Set[T]) Remove(value T) { + delete(set, value) +} + +func (set Set[T]) Len() int { + return len(set) +}
\ No newline at end of file diff --git a/shrine/utils/meta/request.go b/shrine/utils/meta/request.go index b7fd909..25d32a4 100644 --- a/shrine/utils/meta/request.go +++ b/shrine/utils/meta/request.go @@ -15,7 +15,7 @@ func Request(context *fiber.Ctx) facade { logger.Errorf("META", "RequestContext missing in fiber locals") return facade{} } - return facade{request: request, context: context} + return facade{Request: request, context: context} } func (f facade) Param(key string) (string, bool) { @@ -29,7 +29,7 @@ func (f facade) Param(key string) (string, bool) { } func (f facade) Query(key string) (string, bool) { - for _, q := range f.request.Query { + for _, q := range f.Request.Query { if q.Key == key { return q.Value, true } @@ -38,7 +38,7 @@ func (f facade) Query(key string) (string, bool) { } func (f facade) Header(key string) (string, bool) { - for _, h := range f.request.Headers { + for _, h := range f.Request.Headers { if h.Key == key { return h.Value, true } diff --git a/shrine/utils/meta/types.go b/shrine/utils/meta/types.go index 2f2f504..81b3579 100644 --- a/shrine/utils/meta/types.go +++ b/shrine/utils/meta/types.go @@ -7,7 +7,7 @@ import ( ) type facade struct { - request types.Request + types.Request context *fiber.Ctx } diff --git a/shrine/utils/meta/value.go b/shrine/utils/meta/value.go index 5402b75..73972e9 100644 --- a/shrine/utils/meta/value.go +++ b/shrine/utils/meta/value.go @@ -1,9 +1,9 @@ package meta func (f facade) MustHave() required { - return required{request: f.request, context: f.context} + return required{request: f.Request, context: f.context} } func (f facade) Default(defaults string) withDefault { - return withDefault{request: f.request, context: f.context, defaults: defaults} + return withDefault{request: f.Request, context: f.context, defaults: defaults} } diff --git a/shrine/utils/validators/email.go b/shrine/utils/validators/email.go new file mode 100644 index 0000000..95608b2 --- /dev/null +++ b/shrine/utils/validators/email.go @@ -0,0 +1,21 @@ +package validators + +import "strings" + +func IsValidEmail(email string) bool { + if len(email) < 5 || len(email) > 255 { + return false + } + + atIndex := strings.Index(email, "@") + if atIndex < 1 { + return false + } + + dotIndex := strings.LastIndex(email, ".") + if dotIndex < atIndex+2 || dotIndex >= len(email)-1 { + return false + } + + return true +}
\ No newline at end of file diff --git a/shrine/utils/validators/username.go b/shrine/utils/validators/username.go new file mode 100644 index 0000000..729548d --- /dev/null +++ b/shrine/utils/validators/username.go @@ -0,0 +1,43 @@ +package validators + +import ( + "regexp" + "shrine/utils/collections" + "strings" +) + +var validUsernamePattern = regexp.MustCompile(`^[a-zA-Z0-9_]+$`) + +var reservedUsernames = collections.SetOf( + "admin", "administrator", "owner", + "mod", "moderator", "janitor", "staff", + "api", "www", "mail", "email", + "support", "help", "helpdesk", + "about", "contact", "privacy", "terms", "tos", + "null", "undefined", "system", "bot", "guest", + "login", "register", "signup", "signin", "logout", + "profile", "settings", "account", "dashboard", + "shifoo", "pagoda", "deleted", "anonymous", + "root", "webmaster", "postmaster", "hostmaster", + "noreply", "no_reply", "mailer", "daemon", + "abuse", "security", "info", "status", + "home", "search", "explore", "discover", + "forums", "forum", "chat", "bazaar", "districts", + "members", "online", "buttons", "webring", + "guestbook", "hitcounter", "letters", + "rules", "faq", "blog", "news", "feed", + "static", "assets", "uploads", "images", "media", + "test", "testing", "debug", "dev", "staging", + "everyone", "all", "here", "channel", +) + +func IsValidUsername(username string, minimumLength int) bool { + if len(username) < minimumLength || len(username) > 32 { + return false + } + return validUsernamePattern.MatchString(username) +} + +func IsReservedUsername(username string) bool { + return reservedUsernames.Has(strings.ToLower(username)) +}
\ No newline at end of file |
