diff --git a/go-backend/internal/handlers/auth_handler.go b/go-backend/internal/handlers/auth_handler.go index 099d06a..dfa4678 100644 --- a/go-backend/internal/handlers/auth_handler.go +++ b/go-backend/internal/handlers/auth_handler.go @@ -84,6 +84,24 @@ func (h *AuthHandler) Register(c *gin.Context) { return } + // Validate handle against reserved names and inappropriate content + handleCheck := services.ValidateUsername(req.Handle) + if handleCheck.Violation != services.UsernameOK { + status := http.StatusBadRequest + if handleCheck.Violation == services.UsernameReserved { + status = http.StatusForbidden + } + c.JSON(status, gin.H{"error": handleCheck.Message}) + return + } + + // Validate display name for inappropriate content + nameCheck := services.ValidateDisplayName(req.DisplayName) + if nameCheck.Violation != services.UsernameOK { + c.JSON(http.StatusBadRequest, gin.H{"error": nameCheck.Message}) + return + } + existingUser, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email) if err == nil && existingUser != nil { c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"}) diff --git a/go-backend/internal/handlers/user_handler.go b/go-backend/internal/handlers/user_handler.go index a525b7a..5eba558 100644 --- a/go-backend/internal/handlers/user_handler.go +++ b/go-backend/internal/handlers/user_handler.go @@ -185,6 +185,28 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { return } + // Validate handle if being changed + if req.Handle != nil { + handleCheck := services.ValidateUsername(*req.Handle) + if handleCheck.Violation != services.UsernameOK { + status := http.StatusBadRequest + if handleCheck.Violation == services.UsernameReserved { + status = http.StatusForbidden + } + c.JSON(status, gin.H{"error": handleCheck.Message}) + return + } + } + + // Validate display name if being changed + if req.DisplayName != nil { + nameCheck := services.ValidateDisplayName(*req.DisplayName) + if nameCheck.Violation != services.UsernameOK { + c.JSON(http.StatusBadRequest, gin.H{"error": nameCheck.Message}) + return + } + } + profile := &models.Profile{ ID: userID, Handle: req.Handle, diff --git a/go-backend/internal/services/username_validation_service.go b/go-backend/internal/services/username_validation_service.go new file mode 100644 index 0000000..022793a --- /dev/null +++ b/go-backend/internal/services/username_validation_service.go @@ -0,0 +1,309 @@ +package services + +import ( + "regexp" + "strings" +) + +type UsernameViolation int + +const ( + UsernameOK UsernameViolation = iota + UsernameReserved + UsernameInappropriate + UsernameInvalidFormat +) + +type UsernameCheckResult struct { + Violation UsernameViolation + Message string +} + +// ValidateUsername checks a handle against reserved names, inappropriate words, +// and format rules. Returns a result with a user-facing message. +func ValidateUsername(handle string) UsernameCheckResult { + h := strings.ToLower(strings.TrimSpace(handle)) + + // Format check + if len(h) < 3 || len(h) > 30 { + return UsernameCheckResult{UsernameInvalidFormat, "Username must be between 3 and 30 characters."} + } + if !validHandleRegex.MatchString(h) { + return UsernameCheckResult{UsernameInvalidFormat, "Username can only contain letters, numbers, underscores, and periods."} + } + + // Reserved check + if isReserved(h) { + return UsernameCheckResult{ + UsernameReserved, + "This username is reserved. If you officially represent this brand, company, or public figure, you can submit a verification request at support@sojorn.net to claim it.", + } + } + + // Inappropriate check + if reason := isInappropriate(h); reason != "" { + return UsernameCheckResult{UsernameInappropriate, "This username is not allowed: " + reason} + } + + return UsernameCheckResult{UsernameOK, ""} +} + +// ValidateDisplayName checks a display name for inappropriate content. +func ValidateDisplayName(name string) UsernameCheckResult { + n := strings.ToLower(strings.TrimSpace(name)) + if len(n) == 0 || len(n) > 50 { + return UsernameCheckResult{UsernameInvalidFormat, "Display name must be between 1 and 50 characters."} + } + if reason := isInappropriate(n); reason != "" { + return UsernameCheckResult{UsernameInappropriate, "This display name is not allowed: " + reason} + } + return UsernameCheckResult{UsernameOK, ""} +} + +var validHandleRegex = regexp.MustCompile(`^[a-z0-9_.]+$`) + +// ------------------------------------------------------------------- +// Reserved usernames +// ------------------------------------------------------------------- + +func isReserved(h string) bool { + // Exact match + if reservedSet[h] { + return true + } + // Prefix match (e.g. "sojorn_anything", "admin_anything") + for _, prefix := range reservedPrefixes { + if strings.HasPrefix(h, prefix) { + return true + } + } + // Contains match for brand names that shouldn't appear even as substrings + for _, substr := range reservedSubstrings { + if strings.Contains(h, substr) { + return true + } + } + return false +} + +// Platform terms, system accounts, and roles +var platformReserved = []string{ + "sojorn", "admin", "administrator", "moderator", "mod", + "support", "help", "helpdesk", "system", "official", + "root", "superuser", "staff", "team", "security", + "abuse", "postmaster", "webmaster", "info", "contact", + "noreply", "no_reply", "mailer", "daemon", "bot", + "api", "dev", "developer", "ceo", "cto", "cfo", "coo", + "founder", "cofounder", "intern", "hr", + "legal", "compliance", "privacy", "terms", + "news", "press", "media", "blog", "status", + "feedback", "report", "bug", "feature", + "billing", "payment", "sales", "marketing", + "everyone", "all", "here", "channel", + "null", "undefined", "anonymous", "unknown", + "test", "testing", "demo", "example", + "signup", "signin", "login", "logout", "register", + "settings", "account", "profile", "dashboard", + "home", "feed", "explore", "discover", "search", + "notification", "notifications", "message", "messages", + "chat", "dm", "dms", "inbox", "outbox", + "verified", "verification", "verify", + "beacon", "beacons", "quip", "quips", +} + +// Major tech companies and social platforms +var techCompanyReserved = []string{ + "google", "apple", "microsoft", "amazon", "meta", + "facebook", "instagram", "twitter", "tiktok", "snapchat", + "linkedin", "reddit", "pinterest", "youtube", "twitch", + "discord", "telegram", "whatsapp", "signal", + "netflix", "spotify", "hulu", "disney", "disneyplus", + "openai", "chatgpt", "anthropic", "claude", + "nvidia", "amd", "intel", "samsung", "sony", + "tesla", "spacex", "nasa", "boeing", "airbus", + "uber", "lyft", "airbnb", "doordash", "grubhub", + "paypal", "stripe", "venmo", "cashapp", "zelle", + "coinbase", "binance", "robinhood", "fidelity", + "github", "gitlab", "stackoverflow", "atlassian", + "slack", "zoom", "teams", "webex", + "shopify", "squarespace", "wordpress", "wix", + "dropbox", "icloud", "onedrive", + "oracle", "ibm", "salesforce", "adobe", "canva", +} + +// Major brands and corporations +var brandReserved = []string{ + "nike", "adidas", "puma", "reebok", "underarmour", + "cocacola", "coca_cola", "pepsi", "starbucks", "mcdonalds", + "walmart", "target", "costco", "kroger", "wholefoods", + "bmw", "mercedes", "audi", "porsche", "ferrari", + "lamborghini", "ford", "chevrolet", "toyota", "honda", + "gucci", "louisvuitton", "chanel", "prada", "hermes", + "rolex", "cartier", "tiffany", "burberry", + "nfl", "nba", "mlb", "nhl", "mls", "fifa", "ufc", + "espn", "cnn", "bbc", "foxnews", "msnbc", "nytimes", + "washingtonpost", "wsj", "reuters", "apnews", + "marvel", "dccomics", "nintendo", "playstation", "xbox", + "paramount", "warner", "universal", +} + +// Public figures, politicians, and notable people +var publicFigureReserved = []string{ + "elonmusk", "elon_musk", "jeffbezos", "jeff_bezos", + "markzuckerberg", "mark_zuckerberg", "zuckerberg", + "timcook", "tim_cook", "billgates", "bill_gates", + "satyanadella", "sundarpichai", "samaltman", + "joebiden", "joe_biden", "donaldtrump", "donald_trump", + "barackobama", "barack_obama", "kamalaharris", + "taylorswift", "taylor_swift", "beyonce", "rihanna", + "drake", "kanyewest", "kanye_west", "ye", + "kimkardashian", "kim_kardashian", "kyliejenner", + "cristiano", "ronaldo", "messi", "lebronjames", "lebron_james", + "therock", "the_rock", "dwaynejohnson", + "mrbeaast", "mrbeast", "pewdiepie", "ninja", + "joerogan", "joe_rogan", "oprah", + "pope", "popefrancis", "dalailama", +} + +var reservedPrefixes = []string{ + "sojorn_", "sojorn.", "official_", "official.", + "admin_", "admin.", "mod_", "mod.", + "support_", "support.", "team_", "team.", + "staff_", "staff.", "system_", "system.", +} + +var reservedSubstrings = []string{ + "sojorn", +} + +var reservedSet map[string]bool + +func init() { + reservedSet = make(map[string]bool) + for _, lists := range [][]string{ + platformReserved, + techCompanyReserved, + brandReserved, + publicFigureReserved, + } { + for _, name := range lists { + reservedSet[name] = true + } + } +} + +// ------------------------------------------------------------------- +// Inappropriate content filter +// ------------------------------------------------------------------- + +func isInappropriate(text string) string { + // Remove common substitutions for bypass attempts + normalized := normalizeUsername(text) + + for _, entry := range inappropriatePatterns { + if entry.regex.MatchString(normalized) || entry.regex.MatchString(text) { + return entry.reason + } + } + return "" +} + +type inappropriateEntry struct { + regex *regexp.Regexp + reason string +} + +var inappropriatePatterns []inappropriateEntry + +func init() { + type raw struct { + pattern string + reason string + } + entries := []raw{ + // Slurs and hate speech + {`\bn[i1!|]gg[e3a@][r]?\b`, "contains a racial slur"}, + {`\bf[a@]gg?[o0][t]?\b`, "contains a homophobic slur"}, + {`\bk[i1!]ke\b`, "contains an antisemitic slur"}, + {`\bsp[i1!]c\b`, "contains a racial slur"}, + {`\bch[i1!]nk\b`, "contains a racial slur"}, + {`\bw[e3]tb[a@]ck\b`, "contains a racial slur"}, + {`\bcoon\b`, "contains a racial slur"}, + {`\btr[a@]nn[yie]\b`, "contains a transphobic slur"}, + {`\bdyke\b`, "contains a homophobic slur"}, + {`\bretard(ed)?\b`, "contains an ableist slur"}, + + // Sexually explicit + {`\bp[o0]rn`, "contains sexually explicit content"}, + {`\bx{2,}`, "contains sexually explicit content"}, + {`\bhentai\b`, "contains sexually explicit content"}, + {`\bcum(sl[u]t|dump|bucket)\b`, "contains sexually explicit content"}, + {`\bpussy\b`, "contains sexually explicit content"}, + {`\bd[i1!]ck(head|face|sucker)`, "contains sexually explicit content"}, + {`\bc[o0]ck(sucker)?`, "contains sexually explicit content"}, + + // Violent / threatening + {`\bk[i1!]ll(er)?_(yo)?u`, "contains threatening language"}, + {`\bschool.?shoot`, "contains violent content"}, + {`\bmass.?murder`, "contains violent content"}, + {`\bgenocide\b`, "contains violent content"}, + {`\bterroris[tm]`, "contains references to terrorism"}, + {`\bisis\b`, "contains references to terrorism"}, + {`\bal.?qaeda\b`, "contains references to terrorism"}, + {`\bjihad(i|ist)?\b`, "contains references to terrorism"}, + + // Drugs (hard) + {`\bmeth(head|lab)\b`, "contains drug references"}, + {`\bcrackhead\b`, "contains drug references"}, + {`\bheroin(e)?\b`, "contains drug references"}, + {`\bfentanyl\b`, "contains drug references"}, + + // Impersonation indicators + {`\breal_?\b`, "may imply impersonation"}, + {`\bthe_?real\b`, "may imply impersonation"}, + {`\bofficial_\b`, "may imply impersonation"}, + {`\bnot_?fake\b`, "may imply impersonation"}, + + // Scam / fraud + {`\bfree.?money\b`, "suggests fraudulent activity"}, + {`\bcrypto.?scam\b`, "suggests fraudulent activity"}, + {`\bget.?rich\b`, "suggests fraudulent activity"}, + + // Self-harm + {`\bsu[i1!]c[i1!]de\b`, "contains references to self-harm"}, + {`\bkill.?myself\b`, "contains references to self-harm"}, + {`\bcut.?myself\b`, "contains references to self-harm"}, + + // General profanity as usernames (strong) + {`\bfuck`, "contains strong profanity"}, + {`\bsh[i1!]t(head|face|stain)`, "contains strong profanity"}, + {`\bass(hole|wipe|face|hat)`, "contains strong profanity"}, + {`\bbitch\b`, "contains strong profanity"}, + {`\bwhore\b`, "contains strong profanity"}, + {`\bslut\b`, "contains strong profanity"}, + {`\bcunt\b`, "contains strong profanity"}, + } + + inappropriatePatterns = make([]inappropriateEntry, 0, len(entries)) + for _, e := range entries { + re := regexp.MustCompile("(?i)" + e.pattern) + inappropriatePatterns = append(inappropriatePatterns, inappropriateEntry{regex: re, reason: e.reason}) + } +} + +// normalizeUsername applies common leet-speak substitutions to catch bypass attempts +func normalizeUsername(s string) string { + replacer := strings.NewReplacer( + "0", "o", + "1", "i", + "3", "e", + "4", "a", + "5", "s", + "7", "t", + "@", "a", + "$", "s", + "!", "i", + "|", "l", + ) + return replacer.Replace(s) +}