usernames 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +362 -0
- data/SECURITY.md +42 -0
- data/config/locales/usernames.en.yml +72 -0
- data/config/locales/usernames.es.yml +51 -0
- data/config/locales/usernames.pt.yml +51 -0
- data/data/LICENSES/allprofanity.txt +24 -0
- data/data/LICENSES/big-username-blocklist.txt +25 -0
- data/data/LICENSES/corpora.txt +128 -0
- data/data/LICENSES/cuss.txt +25 -0
- data/data/LICENSES/django-registration.txt +33 -0
- data/data/LICENSES/dsojevic-profanity-list.txt +24 -0
- data/data/LICENSES/forwardemail.txt +24 -0
- data/data/LICENSES/friendly-words.txt +24 -0
- data/data/LICENSES/gitea.txt +23 -0
- data/data/LICENSES/gitlab.txt +31 -0
- data/data/LICENSES/iana-well-known-uris.txt +130 -0
- data/data/LICENSES/imsky-wordlists.txt +24 -0
- data/data/LICENSES/mottie-github-reserved-names.txt +24 -0
- data/data/LICENSES/obscenity.txt +27 -0
- data/data/LICENSES/petname.txt +218 -0
- data/data/LICENSES/rfc2142.txt +9 -0
- data/data/LICENSES/shouldbee.txt +25 -0
- data/data/LICENSES/usernames.txt +30 -0
- data/data/NOTICE +36 -0
- data/data/offensive/README.md +90 -0
- data/data/offensive/allowlist/en.txt +727 -0
- data/data/offensive/allowlist/es.txt +39 -0
- data/data/offensive/en.json +5680 -0
- data/data/offensive/es.json +1014 -0
- data/data/offensive/hate.yml +51 -0
- data/data/offensive/leet.yml +27 -0
- data/data/reserved/ALLOWLIST_EXCEPTIONS.txt +6 -0
- data/data/reserved/impersonation.txt +69 -0
- data/data/reserved/mailboxes.txt +77 -0
- data/data/reserved/system.txt +124 -0
- data/data/reserved/web.txt +962 -0
- data/data/words/adjectives.txt +367 -0
- data/data/words/animals.txt +253 -0
- data/lib/generators/usernames/availability_generator.rb +67 -0
- data/lib/generators/usernames/existing_schema.rb +85 -0
- data/lib/generators/usernames/install_generator.rb +241 -0
- data/lib/generators/usernames/install_messages.rb +63 -0
- data/lib/generators/usernames/locale_generator.rb +56 -0
- data/lib/generators/usernames/templates/add_username.rb.erb +53 -0
- data/lib/generators/usernames/templates/availability/_field.html.erb.tt +25 -0
- data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +107 -0
- data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +115 -0
- data/lib/generators/usernames/templates/usernames.rb +34 -0
- data/lib/tasks/usernames.rake +38 -0
- data/lib/usernames/adapters/moderate.rb +37 -0
- data/lib/usernames/audit/output.rb +51 -0
- data/lib/usernames/audit/values.rb +34 -0
- data/lib/usernames/audit.rb +230 -0
- data/lib/usernames/candidates.rb +64 -0
- data/lib/usernames/comparison/index_contract.rb +241 -0
- data/lib/usernames/comparison/index_expression.rb +183 -0
- data/lib/usernames/comparison.rb +127 -0
- data/lib/usernames/configuration.rb +197 -0
- data/lib/usernames/data.rb +205 -0
- data/lib/usernames/engine.rb +32 -0
- data/lib/usernames/errors.rb +16 -0
- data/lib/usernames/generator.rb +206 -0
- data/lib/usernames/model.rb +397 -0
- data/lib/usernames/normalizer.rb +51 -0
- data/lib/usernames/offensive/hate.rb +124 -0
- data/lib/usernames/offensive/lexicon.rb +156 -0
- data/lib/usernames/offensive/name.rb +254 -0
- data/lib/usernames/offensive.rb +136 -0
- data/lib/usernames/phrase.rb +68 -0
- data/lib/usernames/reserved/listing.rb +79 -0
- data/lib/usernames/reserved.rb +219 -0
- data/lib/usernames/result.rb +52 -0
- data/lib/usernames/rules/options.rb +175 -0
- data/lib/usernames/rules.rb +257 -0
- data/lib/usernames/unique_violation.rb +176 -0
- data/lib/usernames/validator.rb +61 -0
- data/lib/usernames/version.rb +5 -0
- data/lib/usernames/words.rb +13 -0
- data/lib/usernames.rb +138 -0
- data/sig/usernames.rbs +75 -0
- metadata +217 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2ec486faa743e1c58387d30c1cac821ff0365b4aba93599c646eceb153706394
|
|
4
|
+
data.tar.gz: 45a21a2e40e4245a6e3304fe6ef7e74f365f1270a40fc6c79a9dab06f6a14f2f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 04e01ab2a747c93ca4f03419a8551becaf01cf4d3a624246f78e09d3511ce05a5fe42095a7617c62439885e5fabcdc5f7a760519d5683d71d1e82b7110a981f2
|
|
7
|
+
data.tar.gz: 7c732ebc8d69dda5e97f8f6267e118159e5d7a06827868ec6835bdb412580c50abac0c04e3ceb02892f4968734d83c99fec7cc5d47f4a4981561d67ad9af1429
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.3] - 2026-10-27
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- The gem's summary now says what it does in one line: add usernames with validations to a Rails app — block reserved names, screen profanity, enforce case-insensitive uniqueness, suggest alternatives and generate default names. No code changes; this is the first version published to RubyGems.
|
|
8
|
+
|
|
9
|
+
## [0.1.2] - 2026-10-27
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **The bundled lists are read as UTF-8 whatever the host's locale.** The data files were opened with Ruby's default external encoding, which is US-ASCII wherever `LANG` is unset (a cron job, a systemd unit, a bare container), and the non-ASCII entries then raised `invalid byte sequence in US-ASCII` while the gem loaded. Every read now names UTF-8; a test loads the gem under `-E US-ASCII`.
|
|
14
|
+
|
|
15
|
+
## [0.1.1] - 2026-10-27
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **The gemspec loads where git is missing.** Its file list came from `git ls-files`, which raised `Errno::ENOENT` wherever the git binary is absent — so a copy of the gem `git archive`d into an app's `vendor/` and loaded with `path:` crashed Bundler at boot inside a production image with no git (the app never started; nothing else was touched). The gemspec now falls back to the shipped directories (`lib/ config/ data/ sig/` and the top-level documents) when git is unavailable or the directory is not a checkout. Found dogfooding the gem in a vendored deployment; a test now loads the gemspec with an empty `PATH` and checks the fallback covers everything the built gem ships.
|
|
20
|
+
|
|
21
|
+
## [0.1.0] - 2026-10-27
|
|
22
|
+
|
|
23
|
+
First release. One line — `has_username` — gives a Rails model a username that is valid, unique, not reserved, not offensive and there from day one, with no table, no network and one unique index.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **`has_username`** (`column = :username, required: false, generate: nil, **rules`): installs `normalizes` (so `" @Javi_R "` is stored as `"javi_r"` and found by `where(username: "@Javi_R")`), validation **only when the value changes** (adopting the gem never breaks an existing account's other saves), presence when `required:` applies — `true`, or per record a method name or proc like Rails' `if:` (on create or when someone tries to clear it — a legacy blank still saves), uniqueness through the gem's own comparison contract, a free generated name on create when the field is blank (by default wherever a name is not required, record by record), and a race wrapper: a generated name taken by a concurrent sign-up is silently redrawn (up to three times, inside a savepoint so the surrounding transaction survives); a typed name taken by a concurrent sign-up or rename gets the ordinary `has already been taken` error (`save` → `false`, `save!` → `ActiveRecord::RecordInvalid`), never a `RecordNotUnique` 500 (lock contention between concurrent writers stays the adapter's concern: SQLite's `timeout`). Class helpers renamed with the column: `username_available?`, `suggest_usernames`, `generate_username`, `find_by_username` and `find_by_username!` (raises `ActiveRecord::RecordNotFound`); instance `username_generated?`.
|
|
28
|
+
- **`validates :username, username: true`** for apps that keep their own uniqueness and callbacks. Validates the attribute exactly as supplied (pair it with `normalizes :username, with: Usernames.normalizer`). Per-call rules (`username: { length: 2..15 }`), a `message:` that replaces every rule's message, and `validates_username_of`.
|
|
29
|
+
- **Format rules, in order, each with its own named error:** no whitespace; letters, digits and the configured separators only (default `_`; every other code point — accents, emoji, invisible and bidi characters — is refused, never stripped); length 3–30; no separator at either edge; no two separators in a row (the rule that rejects `oliver.__-hjj`); first character letter or digit; not all digits; and, when `.` is enabled, no file-extension ending (`john.json`, `x.git`, every extension Rails knows). Server regexes anchor with `\A…\z`; `Usernames.rules_for_browser` returns JavaScript-compatible sources with messages for live hints that never block submission.
|
|
30
|
+
- **An idempotent normaliser** (`Usernames.normalize`): UTF-8 encoding policy, NFKC, trim, lowercase (unless `case: :preserve`), and one pasted leading `@` removed as the last step. Measured idempotent on 50,000 random Unicode strings and the Big List of Naughty Strings in both case modes; never raises.
|
|
31
|
+
- **Reserved names** in two behaviours. *Impersonation* (~60 words plus the app's own name, read from the application class): matched on the whole name with separators and trailing digits removed and digits leet-folded, and on every segment, plus plurals — so `admin2`, `ad.min`, `adm1n_bob`, `bob.support`, `admins` are refused while `supportive`, `staffordshire`, `teamwork` and `adminbob` are not. *Exact* (~1,300): system words and placeholders (`null`, `undefined`, `deleted`, `ghost`, `everyone`, Windows device names, Unix accounts), RFC 2142 and certificate-authority mailboxes, autoconfiguration hostnames, and the union of the Big Username Blocklist, shouldbee, GitHub's, GitLab's and Gitea's route lists, django-registration and the IANA `.well-known` registry — minus company names and minus common first names. **Your routes** are reserved automatically from the router's AST and refreshed on `after_routes_loaded`. `config.reserved` (exact), `config.forbidden` (impersonation) and `config.allowed` (lift a word) extend or replace the bundled lists; `rails usernames:reserved` prints the effective set with each word's source.
|
|
32
|
+
- **Offensive words in severity tiers, per language** (`en` and `es` ship reviewed). Severe terms of four or more letters are matched anywhere in a name after folding leetspeak digits and separators; strong terms only as a whole name or whole segment (with `case: :preserve`, a capital after a lowercase letter separates words too: `ShitheadBob` is refused, `ScunthorpeUnited` passes); mild words are off by default. Exemptions (~800 real words and names such as `scunthorpe`, `sussex`, `cockburn`) apply to a whole name or whole segment and can never be manufactured by joining segments (`scunthorpe_united` passes; `scunt_horpe` and `f.u.c.k` do not). Hate numbers are rules, not entries: `1488`/`14_88` refused, bare `88` never, `kkk` only in English. `negro` — the Spanish word for black and a surname — is not blocked in any language. Error messages never echo the matched word. If the `moderate` gem is loaded its word list is consulted too (camelCase read as words); any callable can be an adapter, and receives the name as stored.
|
|
33
|
+
- **Uniqueness that uses your index.** Lowercase storage on a `"C"`-collated column (PostgreSQL) or `utf8mb4_bin` (MySQL) makes `=` bytewise, exactly as Ruby compares; `case: :preserve` keeps typed case and compares through `lower(username COLLATE "C")` on a functional unique index, with the same expression in every gem query. Rails' `uniqueness: { case_sensitive: false }` (`LOWER()`, collation-dependent, unindexable) is not used. `citext` is never used.
|
|
34
|
+
- **Generated names** (`quiet_otter_42`): adjective + animal + random digits from a curated vocabulary the test suite runs through the validator pair by pair; `SecureRandom` suffixes that grow on collision; `generate_username(from: "Pedro Gonzales Armillon")` → `pedro_gonzales`, cut at word boundaries, transliterated with `any_ascii` when that gem is present. Never derived from an email unless passed explicitly, and generic local parts (`info@`, `me@`) use the domain instead.
|
|
35
|
+
- **Suggestions** (`suggest_usernames("Javi R", count: 5)`): candidates built in memory, filtered through every rule, checked for availability in one indexed query; may return fewer than asked, never loops or raises on exhaustion.
|
|
36
|
+
- **Live availability check** generator (`rails g usernames:availability`): a controller, route, Stimulus controller and partial copied into the app. Rate-limited with Rails' `rate_limit` against a store captured once; refuses to run (503, logged) when that store is a `NullStore`; answers "taken" for taken, reserved and disallowed names alike; the word lists never reach the browser.
|
|
37
|
+
- **Generators and tasks:** `usernames:install [--model --column]` (initializer with ten options, column if missing with the right collation, the unique index; prints the adoption workflow on a populated column), `usernames:availability`, `usernames:locale --locale=fr`; `rails usernames:audit` (read-only: noncanonical rows, collision groups by comparison key, index and collation fit, values failing today's rules) and `rails usernames:reserved`. Deliberately no automatic rewriting task — renaming people silently is not a gem's job.
|
|
38
|
+
- **Messages in English, Spanish and Portuguese**, as standard Active Model error types (`username_reserved`, `username_edges`, `username_consecutive`, `username_digits_only`, `username_characters`, `username_not_allowed`, …) that your locale files override as usual.
|
|
39
|
+
- **Data provenance:** every bundled list is pinned to a source revision in `data/NOTICE` with its licence text in `data/LICENSES/`; the built gem ships both.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javi R
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# 👤 `usernames` - Usernames for your Rails app, done right in one line
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/usernames) [](https://github.com/rameerez/usernames/actions)
|
|
4
|
+
|
|
5
|
+
> [!TIP]
|
|
6
|
+
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=usernames)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=usernames)!
|
|
7
|
+
|
|
8
|
+
`usernames` gives any Rails model a username that is **valid, unique, not reserved, not offensive, and there from day one** — with one line:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
class User < ApplicationRecord
|
|
12
|
+
has_username
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That's it. Now:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
user.username = "javi_r" # ✅
|
|
20
|
+
user.username = "admin" # ❌ is reserved
|
|
21
|
+
user.username = "Ad_min2" # ❌ is reserved (that's still "admin" to a reader)
|
|
22
|
+
user.username = "_javi" # ❌ can't start or end with an underscore
|
|
23
|
+
user.username = "oliver___hjj" # ❌ can't have two symbols in a row
|
|
24
|
+
user.username = "12345" # ❌ can't be only numbers
|
|
25
|
+
user.username = "xXf_u_c_kXx" # ❌ isn't allowed (it never says which word)
|
|
26
|
+
user.username = " @Javi_R " # ✅ stored as "javi_r" (pasted @, spaces and case are normalised)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Every new account gets a free, pronounceable name from the moment it exists (`quiet_otter_42`), and when someone picks a name that's taken you can offer free alternatives in one query:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
User.create!(email: "ana@example.com").username # => "quiet_otter_42"
|
|
33
|
+
User.suggest_usernames("Javi R") # => ["javi_r", "javir", "javi_r_42", "javi_r_817", "javi_r_23"]
|
|
34
|
+
User.username_available?("javi") # => false (taken — or reserved, or not allowed)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
(The names and digits in these examples are illustrative: the digits are random, and a taken name is skipped.)
|
|
38
|
+
|
|
39
|
+
No table, no migration beyond one unique index, no network calls, no API key. Everything runs in-process in microseconds.
|
|
40
|
+
|
|
41
|
+
Check out my other 💎 Ruby gems: [`nondisposable`](https://github.com/rameerez/nondisposable) · [`moderate`](https://github.com/rameerez/moderate) · [`api_keys`](https://github.com/rameerez/api_keys) · [`usage_credits`](https://github.com/rameerez/usage_credits)
|
|
42
|
+
|
|
43
|
+
## Why this gem exists
|
|
44
|
+
|
|
45
|
+
Every Rails app with profiles writes username handling by hand, and gets it subtly wrong. The username regex everyone copies from Stack Overflow (197k views) accepts `12345678`, `Administrator`, `robots.txt` and `"validuser\n"`. The regex on Devise's own wiki accepts the empty string. Rails' `validates :username, uniqueness: true` is case-sensitive, so `Javi` and `javi` are two people — and the fix, `case_sensitive: false`, makes Rails emit `LOWER(username) = LOWER(?)`, which your unique index can't serve, and on MySQL and `citext` columns it doesn't even do that.
|
|
46
|
+
|
|
47
|
+
Then someone registers `admin`, or `login`, or `ad.min`, or `/robots.txt`, or a name that looks like yours with a `1` for an `l`, or something you'd rather not see on your homepage. Spotify once lost accounts to a username normaliser that wasn't idempotent (`ᴮᴵᴳᴮᴵᴿᴰ` became `bigbird` on the second pass).
|
|
48
|
+
|
|
49
|
+
`usernames` is that whole subsystem, written once, tested against 60 platforms' rules, 25 public reserved-name lists, 13 profanity datasets and 234,000 dictionary words, and reviewed adversarially before the first line of code. It is opinionated so you don't have to be.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
gem "usernames"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bundle install
|
|
59
|
+
rails generate usernames:install
|
|
60
|
+
rails db:migrate
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The generator writes `config/initializers/usernames.rb` (ten options, all commented), adds a `username` column to `users` if there isn't one, and adds **the one unique index the gem needs** — with the right collation, so the database agrees with Ruby about what "the same name" means (see [Uniqueness, case and collation](#uniqueness-case-and-collation)).
|
|
64
|
+
|
|
65
|
+
Then, in your model:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class User < ApplicationRecord
|
|
69
|
+
has_username
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
If your column has another name, or your rules differ for one model:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class Player < ApplicationRecord
|
|
77
|
+
has_username :handle, length: 2..24, case: :preserve, separators: %w[_ . -]
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
> [!IMPORTANT]
|
|
82
|
+
> **Already have users?** `has_username` only checks a username **when it changes**, so adding the gem never stops an existing account from saving its other attributes. But Rails' `normalizes` doesn't rewrite rows already in the database, and a stored `JAVI` next to a new `javi` would be two identities. Run `rails usernames:audit` first and read [Adopting an existing column](#adopting-an-existing-column) — it's a five-minute, read-only check.
|
|
83
|
+
|
|
84
|
+
## What it checks, in order
|
|
85
|
+
|
|
86
|
+
The first failing rule names the error. Messages never contain what the user typed, so they can never echo abuse back or trip I18n on a `%{` someone pasted.
|
|
87
|
+
|
|
88
|
+
| # | Rule | Error (English) |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| 1 | no whitespace | can't contain spaces |
|
|
91
|
+
| 2 | letters `a–z`, digits and `_` only (everything else — accents, emoji, invisible characters — fails here) | can only contain lowercase letters, numbers and underscores |
|
|
92
|
+
| 3 | 3 to 30 characters | is too short / is too long (minimum is 3 characters) |
|
|
93
|
+
| 4 | no `_` first or last | can't start or end with an underscore |
|
|
94
|
+
| 5 | no two symbols in a row | can't have two symbols in a row |
|
|
95
|
+
| 6 | not all digits (`231640921` reads as an id, and collides with `/users/:id`) | can't be only numbers |
|
|
96
|
+
| 7 | not a reserved name | is reserved |
|
|
97
|
+
| 8 | not an offensive word | isn't allowed |
|
|
98
|
+
| 9 | not taken (your unique index decides) | has already been taken |
|
|
99
|
+
|
|
100
|
+
Before any rule runs, the value is **normalised**: invalid bytes dropped, Unicode NFKC (so `Javi` typed on a Japanese keyboard becomes `Javi`, and `ᴮᴵᴳ` becomes `BIG`), trimmed, lowercased, and one pasted leading `@` removed. The normaliser is idempotent — running it twice gives the same answer — which is exactly the property Spotify's wasn't. It never strips invisible characters (zero-width spaces, right-to-left overrides): those fail rule 2, loudly.
|
|
101
|
+
|
|
102
|
+
Usernames are **ASCII** in this version. Unicode usernames are a lookalike-impersonation problem (Cyrillic `а` is not Latin `a`) that deserves its own design; GitHub, X, Discord, Mastodon and Bluesky made the same call and put Unicode in the display name. We recommend you do too — give people a free-form `display_name` column for who they are, and keep the username as the stable identifier.
|
|
103
|
+
|
|
104
|
+
## Reserved names
|
|
105
|
+
|
|
106
|
+
Nobody can register these, out of the box:
|
|
107
|
+
|
|
108
|
+
- **Impersonation** (~60): `admin`, `administrator`, `root`, `staff`, `support`, `official`, `moderator`, `mod`, `security`, `verified`, `trust`, `safety`, `team`, `bot`, `system`, `help`, `owner`, `webmaster`, `abuse` — **and your app's own name**, read from your application class. These are matched the way impersonators type them: separators and trailing digits removed, digits folded (`ad.min`, `adm1n`, `admin2`, `admin_bob`, `bob.support`, `admins` are all refused). They are *not* matched as prefixes: `supportive`, `staffordshire`, `teamwork` and `adminbob` are fine — the naive "contains `admin`" check most apps write would have refused `badminton_fan`.
|
|
109
|
+
- **System words** (~90): `null`, `nil`, `undefined`, `true`, `false`, `deleted`, `ghost` (GitHub's placeholder for deleted users), `anonymous`, `everyone`, `here`, `channel`, `me`, `you`, `constructor`, `__proto__`, Windows device names (`con`, `nul`, `com1`…), Unix accounts (`root`, `daemon`, `www-data`…).
|
|
110
|
+
- **Mailboxes** (~70): the RFC 2142 role addresses (`postmaster`, `hostmaster`, `abuse`, `noc`, `security`…), the addresses certificate authorities accept for domain validation (`ssladmin`, `webmaster`…), `noreply`, `mailer-daemon`, and hostnames that hijack autoconfiguration if a username ever becomes a subdomain (`autoconfig`, `autodiscover`, `wpad`, `isatap`, `localhost`).
|
|
111
|
+
- **Web paths** (~1,100): `login`, `logout`, `signup`, `settings`, `account`, `api`, `dashboard`, `assets`, `cable`, `up`, `rails`, `sidekiq`, `admin`, `search`, `about`, `help`, `terms`, `privacy`, `pricing`, `blog`, `sitemap`, `robots`, `favicon`… — the union of the Big Username Blocklist, shouldbee, GitHub's, GitLab's and Gitea's own reserved paths, django-registration and the IANA `.well-known` registry, minus company names, minus common first names (so Nick, Page and Mac can sign up), minus words people go by (`alpha`, `beta`, `flash`, `free`, `master`, `recent`) — if your app has such a page, your routes reserve it.
|
|
112
|
+
- **Your routes**: every static top-level path in `config/routes.rb` (`/pricing`, `/recipes`, `/play`…) is reserved automatically, and refreshed when routes reload in development. So a GitHub-style `/:username` can never be shadowed by a page you add next month.
|
|
113
|
+
|
|
114
|
+
Add your own:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
Usernames.configure do |config|
|
|
118
|
+
config.reserved += %w[recipe recipes] # exact: nobody can be exactly this
|
|
119
|
+
config.forbidden += %w[yourapp yourap] # impersonation matching, like admin — add your misspellings
|
|
120
|
+
config.allowed += %w[nick page] # lift a bundled word you disagree with
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`+=` adds to the bundled lists; `=` replaces them. Who wins when lists disagree (top wins):
|
|
125
|
+
|
|
126
|
+
| # | List | Effect |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| 1 | `config.allowed` | allowed |
|
|
129
|
+
| 2 | `config.reserved`, `config.forbidden` | reserved |
|
|
130
|
+
| 3 | your routes | reserved |
|
|
131
|
+
| 4 | the bundled lists | reserved |
|
|
132
|
+
|
|
133
|
+
Your own official account, under a name the gem reserves? Lift exactly that name — `config.allowed += %w[yourapp]` — and register it yourself before launch (a seed, or the console). It frees that exact name only: `yourapp2` and `your.app` stay reserved. `has_username` deliberately has no per-record skip: whether a name may be held is a fact about the name, and a skip that hangs off the record is one `update` away from letting someone else keep `admin`. `rails usernames:reserved` prints the effective list with the source of every word, for when you wonder why `x` is reserved.
|
|
134
|
+
|
|
135
|
+
## Offensive words
|
|
136
|
+
|
|
137
|
+
A username has no spaces, so `xxfuckxx`, `f_u_c_k` and `sh1thead` have to be caught — but `assassin`, `scunthorpe`, `therapist`, `cockburn` and `grape` must not be. The gem uses **severity tiers**, per language:
|
|
138
|
+
|
|
139
|
+
- **Severe** terms (slurs, the worst sexual and violent terms) are matched anywhere inside a name, after folding leetspeak digits and separators — but only terms of four letters or more (`gc` and `mj` are on some lists; matching them anywhere would refuse `gcastro` and `mjordan`).
|
|
140
|
+
- **Strong** terms are matched only as a whole name or a whole segment between separators and digits: `shithead_bob` is refused, `scunthorpe` is not, and a real surname like `Chujew` stays legal. With `case: :preserve`, capitals separate words too (a capital after a lowercase letter, and the last capital of a run that a lowercase letter follows), and a strong term spelled across them still counts: `ShitheadBob`, `SHITHEADBob` and `ShitHeadBob` are refused, `ScunthorpeUnited` is fine. Words with no capital or separator between them pass at `:strong` — a known limit: `shitheadbob` in lowercase, or `XxShitHeadxX`, where no capital ends `Head`.
|
|
141
|
+
- **Mild** words (`hell`, `damn`) are off by default.
|
|
142
|
+
- **Hate numbers** are rules, not list entries: `1488` is refused as a whole number or `14_88`; bare `88` never is (birth years, NASCAR, a lucky number in Chinese); `kkk` is refused in English — and, when a Portuguese word list ships, it will not be there, where `kkkk` is laughter (Portuguese messages ship today; Portuguese screening does not).
|
|
143
|
+
- An **allowlist** of ~800 real words and names that happen to contain a bad substring (`scunthorpe`, `sussex`, `cockburn`, `fukuda`, `dikshit`…) exempts a whole name or a whole segment: `scunthorpe_united` is fine. Joining segments never manufactures an exemption, so `scunt_horpe` and `f.u.c.k` are still refused.
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
Usernames.configure do |config|
|
|
147
|
+
config.offensive.severity = :strong # :severe (substring only) · :strong (default) · :mild · :off
|
|
148
|
+
config.offensive.languages = %i[en es] # English and Spanish ship reviewed; more as they're tiered
|
|
149
|
+
config.offensive.allowed += %w[sussex] # your own exemptions
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
If you already use [`moderate`](https://github.com/rameerez/moderate), its word list is consulted too — automatically, no configuration, and never required. Any `->(name) { … }` can be an adapter; it receives the name as stored (typed case and all, in preserve mode).
|
|
154
|
+
|
|
155
|
+
One deliberate choice worth stating: `negro` is the Spanish word for black and a common surname, and it is not blocked in any language. The slur is a different word.
|
|
156
|
+
|
|
157
|
+
The gem can't read context and never will; it clears the bar of "obvious slurs don't sail through" at zero latency and zero cost, and says so. Every word it ships has a source and a licence (see [Data and licences](#data-and-licences)), and its test suite runs the whole vocabulary against lists of real first names and surnames so a person is never refused for being called Dick or Gay.
|
|
158
|
+
|
|
159
|
+
## Uniqueness, case and collation
|
|
160
|
+
|
|
161
|
+
By default usernames are stored **lowercase**. That is the one configuration where Ruby, the validator and the database agree on what "equal" means: the stored value is already canonical, so uniqueness is a plain `username = ?` on an ordinary unique index — no `LOWER()`, no full-table scan.
|
|
162
|
+
|
|
163
|
+
In lowercase mode the generator sets the column's collation to `"C"` on PostgreSQL and `utf8mb4_bin` on MySQL. Why: PostgreSQL's `lower()` and `=` follow the column's collation, and under a Turkish collation `I` lowercases to `ı`, not `i`; MySQL's default `_ai_ci` collations make `jose` equal `josé`; PostgreSQL's nondeterministic collations can ignore punctuation entirely. Pinning the collation makes the database compare bytes, exactly as Ruby does. `citext` is never used — its own docs say it isn't truly case-insensitive.
|
|
164
|
+
|
|
165
|
+
Want to keep the case people type (`CoolCat` shows as `CoolCat`, but nobody else can be `coolcat`)?
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
has_username case: :preserve
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Declare it on the model first (or pass `--case=preserve` to the install generator) and the generator writes a functional unique index on `lower(username COLLATE "C")` instead; every query the gem makes — uniqueness, lookup, availability, suggestions — uses that same expression. Because the expression carries its own `COLLATE "C"`, on PostgreSQL the generator leaves the column's collation alone, so `ORDER BY username` keeps sorting `Alice, bob, Carol` rather than every capitalised name first (MySQL, whose `lower()` follows the column, still gets `utf8mb4_bin`).
|
|
172
|
+
|
|
173
|
+
Two names that differ only in punctuation are **different** names: `john.doe` and `johndoe` are two people. (npm and PyPI decided otherwise for package names; for people, literal is less surprising.)
|
|
174
|
+
|
|
175
|
+
When two sign-ups race for the same generated name, the unique index refuses the second and the gem silently draws another, inside a savepoint so your surrounding transaction survives. When two people race for the same *typed* name, the loser gets the ordinary `has already been taken` error — `save` returns `false`, `save!` raises `ActiveRecord::RecordInvalid` — never a `RecordNotUnique` 500. (What the gem handles is the unique index refusing a name. Two writers waiting on each other is the database's concern: on SQLite, give the database a `timeout` in `config/database.yml` so concurrent writers wait for the lock instead of failing with `SQLite3::BusyException`.)
|
|
176
|
+
|
|
177
|
+
A redraw tries the insert again, so your `before_create` and `around_create` callbacks may run once per attempt (at most four times); validations and `before_save` run once, and `after_create` and `after_commit` run once, for the attempt that succeeded. Keep side effects — emails, API calls — out of `before_create`: `after_commit` is the place for them.
|
|
178
|
+
|
|
179
|
+
## Adopting an existing column
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
rails usernames:audit # or: rails usernames:audit[Member,handle] · usernames:audit[Member,handle,preserve]
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Read-only. It prints: stored values that differ from their normalised form (`JAVI`, ` javi`, `@javi`); groups of rows that would collide once normalised (`Javi` and `javi`); whether your existing index and column collation match the chosen case mode; and values that fail today's rules (those keep working — only a *change* is validated). Run before `has_username` is declared, it checks the configured rules and case mode; the third argument names the mode you're adopting. It always exits 0 — it is a report, not a gate — and its last line is `Nothing to fix: …` or `N things to fix. …`, for a script to grep.
|
|
186
|
+
|
|
187
|
+
If the audit finds anything, fix it with a migration you own, under a short freeze of the username form: rewrite the noncanonical values with your own callbacks and policy, resolve collisions by contacting the people or appending a suffix, set the collation, add the index, reopen the form. The gem deliberately ships **no automatic rewriting task** — renaming people silently is not a thing a gem should do for you.
|
|
188
|
+
|
|
189
|
+
Alternatively, `case: :preserve` leaves stored case alone; the audit will tell you if anything else needs attention.
|
|
190
|
+
|
|
191
|
+
## Generated names and suggestions
|
|
192
|
+
|
|
193
|
+
Every new account has a name from day one. A profile with no name is a row that renders as plain text everywhere and has no page — so `has_username` assigns one on create when the field is blank. (Only on create: an existing account may still clear its username, unless you pass `required: true`.)
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
User.create!(email: "ana@example.com").username # => "quiet_otter_42"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Adjective + animal + two random digits, from a vocabulary curated for this purpose (no brands, no body parts, nothing a slur list could trip on — and the test suite proves it, pair by pair). Digits come from `SecureRandom`, never a counter, so names don't reveal sign-up order. On collision the digits grow (`quiet_otter_417`), then fall back to hex. If every name a batch draws is taken or not allowed (a tiny custom vocabulary, a very short length), the save fails with `username_unavailable` ("couldn't be assigned automatically, please choose one") rather than raising; `generate_username` itself raises `Usernames::NoFreeNameError`. Pass `required: true` instead if you'd rather make people choose, or `generate: false` to leave it blank.
|
|
200
|
+
|
|
201
|
+
Both also take a method name or a proc, evaluated on the record like Rails' `if:` — for an app where some sign-up flows ask for a name and others don't:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
has_username required: :handle_required # typed where the flow asks for one, generated everywhere else
|
|
205
|
+
has_username required: -> { invited? }, generate: false
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
By default a name is generated exactly where none is required, record by record. An explicit `generate:` is honoured as given: `required: true, generate: true` fills a blank on create and refuses clearing it later.
|
|
209
|
+
|
|
210
|
+
From a real name:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
User.generate_username(from: "Pedro Gonzales Armillon") # => "pedro_gonzales_armillon", or "pedro_gonzales_armillon_73" if taken
|
|
214
|
+
User.generate_username(from: "José García") # => "jose_garcia"
|
|
215
|
+
User.generate_username(from: "Иван Петров") # => "ivan_petrov" with the any_ascii gem; "quiet_otter_42" without
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Names are cut at word boundaries, never mid-word. The gem never derives a username from an **email** unless you pass one explicitly — the local part of someone's address is not something they agreed to publish. If you do pass one, generic local parts (`info@`, `admin@`, `me@`) use the domain instead.
|
|
219
|
+
|
|
220
|
+
When a chosen name is taken:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
User.suggest_usernames("javi", count: 5) # => ["javi_42", "javi_817", "javi_23", "javi_561", "javi_90"] (illustrative: random digits)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Candidates are built in memory, filtered through every rule, checked for availability in **one** query, and returned in order. You may get fewer than you asked for; you never get a loop or an exception for running out.
|
|
227
|
+
|
|
228
|
+
Bring your own vocabulary or shape:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
Usernames.configure do |config|
|
|
232
|
+
config.generator.adjectives = %w[swift silent iron lunar …]
|
|
233
|
+
config.generator.nouns = %w[comet pulsar nebula …]
|
|
234
|
+
# or anything at all:
|
|
235
|
+
config.generator = ->(attempt) { "player#{SecureRandom.random_number(10_000)}" }
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Whatever you return still goes through the validator and the redraw logic.
|
|
240
|
+
|
|
241
|
+
## Live availability check
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
rails generate usernames:availability
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Copies into **your** app — nothing is mounted behind your back — a small controller, a route (`GET /usernames/availability?username=…`), a Stimulus controller and a form partial. As people type, the browser shows the format rules instantly (same rules, same words as the server, no round trip) and asks the server whether the name is free, debounced, with stale answers discarded. Browser hints never block submission; the server is the only authority.
|
|
248
|
+
|
|
249
|
+
An availability endpoint reveals which names exist by design, so the generated controller comes with guard rails: it rate-limits with Rails' built-in `rate_limit`, it refuses to run (503, with a log line) if your cache store is the `NullStore` — Rails' limiter silently does nothing there — and it answers "taken" for taken, reserved and disallowed names alike. The word lists never reach the browser.
|
|
250
|
+
|
|
251
|
+
## Recipes
|
|
252
|
+
|
|
253
|
+
**Profile URLs with `/@username`** — dots and dashes in a dynamic segment need a constraint, or Rails reads `/@john.doe` as `username=john, format=doe`:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
# config/routes.rb
|
|
257
|
+
get "@:username", to: "profiles#show", as: :profile, format: false,
|
|
258
|
+
constraints: { username: Usernames.route }
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
In the controller, `User.find_by_username!(params[:username])` finds `@Javi` as `javi` and raises `ActiveRecord::RecordNotFound` — a 404 — when nobody has the name, as `find_by!` does.
|
|
262
|
+
|
|
263
|
+
`Usernames.route` is deliberately permissive (`/[A-Za-z0-9._-]+/`): it decides what *routes*, your database decides what *exists*, and your validator decides what can be *registered*. That way an account grandfathered in with a name today's rules would refuse still has a page.
|
|
264
|
+
|
|
265
|
+
**GitHub-style `/username`** — declare it **last**, after every other route; the gem already reserves your static paths so a new page can't shadow a person.
|
|
266
|
+
|
|
267
|
+
**Dots like Instagram** — `config.separators = %w[_ .]`. Enabling dots switches on one more rule: a name may not end like a file (`.json`, `.png`, `.php`, `.git`, `.atom` and every extension Rails knows), because `/@john.json` confuses routers, CDNs and crawlers. `john.doe` is fine.
|
|
268
|
+
|
|
269
|
+
**Subdomain-safe (`javi.yourapp.com`)** — only `-` is legal in a DNS label: `config.separators = %w[-]; config.length = 3..63`.
|
|
270
|
+
|
|
271
|
+
**Allow `_javi_`** — `config.edge_separators = true`. Gamers like it; dots are never allowed at an edge (a trailing dot is punctuation in a mention).
|
|
272
|
+
|
|
273
|
+
**Log in with username or email (Devise)** — set `config.authentication_keys = [:login]` and in your model `def self.find_for_database_authentication(conditions)` look up `find_by_username(login) || find_by(email: login)`; `find_by_username` normalises for you, so `@Javi` finds `javi`. Because `@` is never legal in a username, the two can't be confused.
|
|
274
|
+
|
|
275
|
+
**Just the validation, I'll handle the rest** —
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
normalizes :username, with: Usernames.normalizer
|
|
279
|
+
validates :username, username: true
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The validator checks the value as stored — pair it with the `normalizes` line, or typed `Javi` fails the lowercase rule, which is the point. You keep your own uniqueness validation and index.
|
|
283
|
+
|
|
284
|
+
**Pure functions, no model** —
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
Usernames.allowed?("admin") # => false (eligibility only; says nothing about availability)
|
|
288
|
+
Usernames.check("Ad_min") # => #<Usernames::Result ok?=false reason=:username_reserved canonical="ad_min" message="is reserved">
|
|
289
|
+
Usernames.normalize(" @Javi_R ") # => "javi_r"
|
|
290
|
+
Usernames.generate # => "quiet_otter_42"
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## What it deliberately doesn't do
|
|
294
|
+
|
|
295
|
+
- **Display names.** Add a `display_name` column; let it be anything. The username is the identifier.
|
|
296
|
+
- **Hold a released name.** When someone renames themselves, their old name is free immediately. Twitch holds names for 180 days and PlayStation never releases them, but that needs a table, and this gem has none. Put stable ids in URLs if that matters to you.
|
|
297
|
+
- **Unicode usernames**, in this version — see above.
|
|
298
|
+
- **Read context.** A word list is a word list. For real moderation of what people post, use [`moderate`](https://github.com/rameerez/moderate).
|
|
299
|
+
- **Authentication.** That's Devise's job; the gem just gives you `find_by_username`.
|
|
300
|
+
|
|
301
|
+
## Translating messages
|
|
302
|
+
|
|
303
|
+
Every message is a standard Active Model error (`errors.messages.username_reserved`, `username_edges`, `username_consecutive`, `username_digits_only`, `username_characters`, `username_not_allowed`, `username_unavailable`…, plus Rails' own `too_short`, `too_long`, `blank`, `taken`). English, Spanish and Portuguese ship with the gem; Rails loads an engine's locale files before your app's, so a key in your `config/locales/*.yml` always wins, and the usual per-model override works too:
|
|
304
|
+
|
|
305
|
+
```yaml
|
|
306
|
+
es:
|
|
307
|
+
activerecord:
|
|
308
|
+
errors:
|
|
309
|
+
models:
|
|
310
|
+
user:
|
|
311
|
+
attributes:
|
|
312
|
+
username:
|
|
313
|
+
username_reserved: "está reservado"
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
A per-model override reaches every place the gem speaks for that model: `User.check_username(value)` (`Usernames.check` with the model's rules and words) and `User.username_rules_for_browser` (the hints) look messages up the way the model's own errors do, and the generated availability check uses both, so the form, the hints and the endpoint say the same thing.
|
|
317
|
+
|
|
318
|
+
`rails generate usernames:locale --locale=fr` copies the English file for you to translate.
|
|
319
|
+
|
|
320
|
+
## Configuration reference
|
|
321
|
+
|
|
322
|
+
The initializer the generator writes (every line commented out — these are the defaults):
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
Usernames.configure do |config|
|
|
326
|
+
config.length = 3..30
|
|
327
|
+
config.separators = %w[_] # %w[_ .] Instagram-style (adds the file-extension rule) · %w[-] GitHub/subdomain style
|
|
328
|
+
config.edge_separators = false # true allows _javi_ (never a dot at an edge)
|
|
329
|
+
config.case = :lowercase # :preserve keeps typed case, compares case-insensitively
|
|
330
|
+
|
|
331
|
+
config.reserved += %w[] # exact
|
|
332
|
+
config.forbidden += %w[] # impersonation matching, like admin
|
|
333
|
+
config.allowed += %w[] # exceptions to the reserved checks
|
|
334
|
+
|
|
335
|
+
config.offensive.severity = :strong # :severe · :strong · :mild · :off
|
|
336
|
+
config.offensive.languages = %i[en es]
|
|
337
|
+
config.offensive.allowed += %w[] # exceptions to the offensive-word checks
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Less common, documented here rather than in the initializer: `first_character` (`:alphanumeric`, or `:letter` for the Snapchat/Telegram rule), `reserve_routes` (`true`), `app_name` (auto-detected; `false` to opt out), `offensive.adapters` (`[:moderate]`), `on_adapter_failure` (`:allow` logs and skips a raising adapter; `:reject` fails the save), `generator.adjectives` / `generator.nouns` / `generator` (a callable), `unique_index` (only if the generator didn't create yours). Every option is also accepted per model — `has_username :handle, length: 2..24` — and per validator call. `has_username` itself takes `required:` (`false`; `true`, a method name such as `required: :handle_required`, or a proc) and `generate:` (by default: wherever a name isn't required). An invalid value raises `Usernames::ConfigurationError` at boot, naming it.
|
|
342
|
+
|
|
343
|
+
## Data and licences
|
|
344
|
+
|
|
345
|
+
The bundled lists come from permissively licensed sources, each pinned to a revision and listed in `data/NOTICE` with its licence text in `data/LICENSES/`: The Big Username Blocklist and shouldbee (MIT), GitHub's reserved names via Mottie (MIT), GitLab's and Gitea's route lists (MIT), django-registration (BSD-3), forwardemail's mailbox names (MIT), RFC 2142 and the IANA well-known URI registry, dsojevic/profanity-list (MIT), jo3-l/obscenity's allowlist (MIT), wooorm/cuss (MIT), Glitch's friendly-words, petname and corpora for the generator vocabulary (MIT / Apache-2.0 / CC0). Nothing GPL, nothing CC-BY, nothing without a licence, no real people's surnames, no trademarks. Hate-symbol meanings follow the ADL and the Simon Wiesenthal Center; nothing of theirs is copied.
|
|
346
|
+
|
|
347
|
+
## Development
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
bin/setup
|
|
351
|
+
bundle exec rake test # Minitest 6; SQLite in memory
|
|
352
|
+
bundle exec appraisal rails-8.1 rake test # Rails 7.2 / 8.0 / 8.1 via Appraisals
|
|
353
|
+
DATABASE_URL=postgres://localhost/usernames_test bundle exec rake test # the PostgreSQL suite (races, collations)
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## Contributing
|
|
357
|
+
|
|
358
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/usernames. A word you think should be reserved, an innocent word the filter refuses, a language you can review — those are the most useful contributions of all. Please include a source.
|
|
359
|
+
|
|
360
|
+
## License
|
|
361
|
+
|
|
362
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
`usernames` decides which usernames an application accepts and how it compares them for uniqueness. Mistakes here become account impersonation, enumeration or duplicate identities, so please report suspected vulnerabilities privately and keep real user data out of any report.
|
|
4
|
+
|
|
5
|
+
## Supported versions
|
|
6
|
+
|
|
7
|
+
Security fixes are released for the latest published version. The maintained test matrix covers Ruby 3.3, 3.4 and 4.0 with patched Rails 7.2, 8.0 and 8.1 releases, on SQLite and PostgreSQL. MySQL support is experimental and not security-supported. Older Ruby and Rails versions may remain installable, but runtimes that no longer receive upstream security fixes are not security-supported.
|
|
8
|
+
|
|
9
|
+
## Reporting a vulnerability
|
|
10
|
+
|
|
11
|
+
Use GitHub's **Report a vulnerability** button on the [`usernames` security advisories page](https://github.com/rameerez/usernames/security/advisories) so the report and any proposed fix remain private. If GitHub's private reporting flow is unavailable, email `rubygems@rameerez.com` with the subject `usernames security report`.
|
|
12
|
+
|
|
13
|
+
Include:
|
|
14
|
+
|
|
15
|
+
- the affected version, Rails version and database adapter;
|
|
16
|
+
- a minimal reproduction (the input string, the configuration, the observed result);
|
|
17
|
+
- the impact you believe is possible; and
|
|
18
|
+
- any suggested mitigation or patch.
|
|
19
|
+
|
|
20
|
+
Do not open a public issue for an undisclosed vulnerability. We will acknowledge the report, investigate it, and coordinate disclosure and credit with you.
|
|
21
|
+
|
|
22
|
+
## What is in scope
|
|
23
|
+
|
|
24
|
+
- A username that passes validation but should not (a reserved name, an impersonation form such as `adm1n`, an evasion of the offensive-word tiers, an invisible or bidi character surviving normalisation).
|
|
25
|
+
- Two stored usernames that the gem's comparison treats as different but a reader, a URL or a database would treat as the same (normalisation that is not idempotent; a collation mismatch; a case or punctuation form that defeats the unique index).
|
|
26
|
+
- Anything that lets the generated availability endpoint be used to enumerate accounts faster than its rate limit intends.
|
|
27
|
+
- A generated or suggested name that is offensive, reserved or predictable.
|
|
28
|
+
|
|
29
|
+
## Operational security
|
|
30
|
+
|
|
31
|
+
Applications remain responsible for:
|
|
32
|
+
|
|
33
|
+
- running `rails usernames:audit` before adopting the gem on an existing column and resolving what it reports (noncanonical rows, collisions, incompatible indexes or collations);
|
|
34
|
+
- keeping the username column's collation as the generator sets it (`"C"` on PostgreSQL, `utf8mb4_bin` on MySQL) — a different collation changes what "equal" means;
|
|
35
|
+
- a working, shared cache store behind the generated availability controller's `rate_limit` (a `NullStore` disables rate limiting; a per-process `MemoryStore` cannot limit across workers), and deciding whether that endpoint should require sign-in;
|
|
36
|
+
- the words they add to `config.allowed` and `config.offensive.allowed`, which lift the gem's verdicts;
|
|
37
|
+
- their own `before_*` callbacks being safe to run more than once on a username model (a generated-name redraw repeats them);
|
|
38
|
+
- moderation of what people do with a username once they have one — this gem screens the string, not the person.
|
|
39
|
+
|
|
40
|
+
## Data provenance
|
|
41
|
+
|
|
42
|
+
Every bundled word list is derived from a permissively licensed source pinned to a revision in `data/NOTICE`, with the licence text in `data/LICENSES/`. If you believe a bundled entry is wrong — a real name the gem refuses, or a term it should refuse — open an ordinary issue with a source; that is not a security report.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# usernames' default English copy. Override any key in your app's own
|
|
2
|
+
# config/locales: Rails loads an engine's locale files BEFORE the host's, so
|
|
3
|
+
# your translation always wins.
|
|
4
|
+
en:
|
|
5
|
+
# The errors `has_username` and `validates :username, username: true` add,
|
|
6
|
+
# one at a time, for the first rule that fails. Override one per model and
|
|
7
|
+
# attribute the usual Active Record way
|
|
8
|
+
# (activerecord.errors.models.<model>.attributes.<attribute>.username_reserved).
|
|
9
|
+
#
|
|
10
|
+
# None of them repeats what was typed — never interpolate %{value} here: a
|
|
11
|
+
# message must not echo abuse back, and a `%{` someone pasted must not reach
|
|
12
|
+
# I18n. What they may interpolate:
|
|
13
|
+
# %{letters} "lowercase letters", or "letters" when the typed case is kept
|
|
14
|
+
# %{separators_phrase} every allowed separator: "underscores, periods and dashes"
|
|
15
|
+
# %{edge_separators_phrase} any separator that may not start or end a name: "an underscore or a period"
|
|
16
|
+
# %{count} the length limit (too_short, too_long); the number of
|
|
17
|
+
# separators allowed (username_characters)
|
|
18
|
+
errors:
|
|
19
|
+
messages:
|
|
20
|
+
username_spaces: "can't contain spaces"
|
|
21
|
+
# %{count} is how many separators are allowed: one finishes the list itself
|
|
22
|
+
# ("…, numbers and underscores"), more continue it ("…, numbers, underscores and periods").
|
|
23
|
+
username_characters:
|
|
24
|
+
one: "can only contain %{letters}, numbers and %{separators_phrase}"
|
|
25
|
+
other: "can only contain %{letters}, numbers, %{separators_phrase}"
|
|
26
|
+
# The same error when no separators are allowed at all.
|
|
27
|
+
username_characters_without_separators: "can only contain %{letters} and numbers"
|
|
28
|
+
# The same rule, when every character it refuses is a letter outside a–z (josé, иван).
|
|
29
|
+
username_unicode: "can only use the letters a–z (no accents or other alphabets)"
|
|
30
|
+
username_edges: "can't start or end with %{edge_separators_phrase}"
|
|
31
|
+
username_consecutive: "can't have two symbols in a row"
|
|
32
|
+
username_first_character: "must start with a letter"
|
|
33
|
+
username_digits_only: "can't be only numbers"
|
|
34
|
+
username_extension: "can't end like a file name (.json, .png…)"
|
|
35
|
+
username_reserved: "is reserved"
|
|
36
|
+
username_not_allowed: "isn't allowed"
|
|
37
|
+
# A name could not be generated for a new record: every candidate the
|
|
38
|
+
# generator drew was taken or not allowed.
|
|
39
|
+
username_unavailable: "couldn't be assigned automatically, please choose one"
|
|
40
|
+
# Rails' own errors, as Rails words them; here so every message a
|
|
41
|
+
# username can get ships in every language this gem ships.
|
|
42
|
+
blank: "can't be blank"
|
|
43
|
+
taken: "has already been taken"
|
|
44
|
+
too_short:
|
|
45
|
+
one: "is too short (minimum is 1 character)"
|
|
46
|
+
other: "is too short (minimum is %{count} characters)"
|
|
47
|
+
too_long:
|
|
48
|
+
one: "is too long (maximum is 1 character)"
|
|
49
|
+
other: "is too long (maximum is %{count} characters)"
|
|
50
|
+
usernames:
|
|
51
|
+
# What the generated availability check says, in its live region, when
|
|
52
|
+
# a name is free to take.
|
|
53
|
+
available: "is available"
|
|
54
|
+
# The pieces the phrases above are built from.
|
|
55
|
+
letters:
|
|
56
|
+
lowercase: "lowercase letters"
|
|
57
|
+
any_case: "letters"
|
|
58
|
+
separators:
|
|
59
|
+
underscore:
|
|
60
|
+
plural: "underscores"
|
|
61
|
+
singular: "an underscore"
|
|
62
|
+
period:
|
|
63
|
+
plural: "periods"
|
|
64
|
+
singular: "a period"
|
|
65
|
+
dash:
|
|
66
|
+
plural: "dashes"
|
|
67
|
+
singular: "a dash"
|
|
68
|
+
# "underscores, periods and dashes" / "an underscore, a period or a dash"
|
|
69
|
+
connectors:
|
|
70
|
+
words: ", "
|
|
71
|
+
and: " and "
|
|
72
|
+
or: " or "
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Copia en español de usernames. Cualquier clave de los config/locales de tu
|
|
2
|
+
# app la sustituye: Rails carga los archivos de un engine ANTES que los de la
|
|
3
|
+
# app, así que tu traducción siempre gana.
|
|
4
|
+
#
|
|
5
|
+
# (usernames' Spanish copy. Rails loads an engine's locale files BEFORE the
|
|
6
|
+
# host's, so a key in your app's own config/locales always wins. See
|
|
7
|
+
# usernames.en.yml for what each message may interpolate; none may use %{value}.)
|
|
8
|
+
es:
|
|
9
|
+
errors:
|
|
10
|
+
messages:
|
|
11
|
+
username_spaces: "no puede contener espacios"
|
|
12
|
+
username_characters:
|
|
13
|
+
one: "solo puede contener %{letters}, números y %{separators_phrase}"
|
|
14
|
+
other: "solo puede contener %{letters}, números, %{separators_phrase}"
|
|
15
|
+
username_characters_without_separators: "solo puede contener %{letters} y números"
|
|
16
|
+
username_unicode: "solo puede usar las letras a–z (sin tildes ni otros alfabetos)"
|
|
17
|
+
username_edges: "no puede empezar ni terminar con %{edge_separators_phrase}"
|
|
18
|
+
username_consecutive: "no puede tener dos símbolos seguidos"
|
|
19
|
+
username_first_character: "tiene que empezar por una letra"
|
|
20
|
+
username_digits_only: "no puede ser solo números"
|
|
21
|
+
username_extension: "no puede terminar como un nombre de archivo (.json, .png…)"
|
|
22
|
+
username_reserved: "está reservado"
|
|
23
|
+
username_not_allowed: "no está permitido"
|
|
24
|
+
username_unavailable: "no se pudo asignar automáticamente, elige uno"
|
|
25
|
+
blank: "no puede estar en blanco"
|
|
26
|
+
taken: "ya está en uso"
|
|
27
|
+
too_short:
|
|
28
|
+
one: "es demasiado corto (1 carácter mínimo)"
|
|
29
|
+
other: "es demasiado corto (%{count} caracteres mínimo)"
|
|
30
|
+
too_long:
|
|
31
|
+
one: "es demasiado largo (1 carácter máximo)"
|
|
32
|
+
other: "es demasiado largo (%{count} caracteres máximo)"
|
|
33
|
+
usernames:
|
|
34
|
+
available: "está disponible"
|
|
35
|
+
letters:
|
|
36
|
+
lowercase: "letras minúsculas"
|
|
37
|
+
any_case: "letras"
|
|
38
|
+
separators:
|
|
39
|
+
underscore:
|
|
40
|
+
plural: "guiones bajos"
|
|
41
|
+
singular: "un guion bajo"
|
|
42
|
+
period:
|
|
43
|
+
plural: "puntos"
|
|
44
|
+
singular: "un punto"
|
|
45
|
+
dash:
|
|
46
|
+
plural: "guiones"
|
|
47
|
+
singular: "un guion"
|
|
48
|
+
connectors:
|
|
49
|
+
words: ", "
|
|
50
|
+
and: " y "
|
|
51
|
+
or: " o "
|