Skip to content

Commit 01a904c

Browse files
feat(codersdk): export name validators (#14550)
* feat(codersdk): export name validators * review
1 parent 093d243 commit 01a904c

File tree

10 files changed

+154
-31
lines changed

10 files changed

+154
-31
lines changed

cli/usercreate.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"github.com/coder/pretty"
1212

1313
"github.com/coder/coder/v2/cli/cliui"
14-
"github.com/coder/coder/v2/coderd/httpapi"
1514
"github.com/coder/coder/v2/codersdk"
1615
"github.com/coder/coder/v2/cryptorand"
1716
"github.com/coder/serpent"
@@ -72,7 +71,7 @@ func (r *RootCmd) userCreate() *serpent.Command {
7271
if err != nil {
7372
return err
7473
}
75-
name = httpapi.NormalizeRealUsername(rawName)
74+
name = codersdk.NormalizeRealUsername(rawName)
7675
if !strings.EqualFold(rawName, name) {
7776
cliui.Warnf(inv.Stderr, "Normalized name to %q", name)
7877
}

coderd/externalauth/externalauth.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323

2424
"github.com/coder/coder/v2/coderd/database"
2525
"github.com/coder/coder/v2/coderd/database/dbtime"
26-
"github.com/coder/coder/v2/coderd/httpapi"
2726
"github.com/coder/coder/v2/coderd/promoauth"
2827
"github.com/coder/coder/v2/codersdk"
2928
"github.com/coder/retry"
@@ -486,7 +485,7 @@ func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAut
486485
// apply their client secret and ID, and have the UI appear nicely.
487486
applyDefaultsToConfig(&entry)
488487

489-
valid := httpapi.NameValid(entry.ID)
488+
valid := codersdk.NameValid(entry.ID)
490489
if valid != nil {
491490
return nil, xerrors.Errorf("external auth provider %q doesn't have a valid id: %w", entry.ID, valid)
492491
}

coderd/httpapi/httpapi.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func init() {
4343
if !ok {
4444
return false
4545
}
46-
valid := NameValid(str)
46+
valid := codersdk.NameValid(str)
4747
return valid == nil
4848
}
4949
for _, tag := range []string{"username", "organization_name", "template_name", "workspace_name", "oauth2_app_name"} {
@@ -59,7 +59,7 @@ func init() {
5959
if !ok {
6060
return false
6161
}
62-
valid := DisplayNameValid(str)
62+
valid := codersdk.DisplayNameValid(str)
6363
return valid == nil
6464
}
6565
for _, displayNameTag := range []string{"organization_display_name", "template_display_name", "group_display_name"} {
@@ -75,7 +75,7 @@ func init() {
7575
if !ok {
7676
return false
7777
}
78-
valid := TemplateVersionNameValid(str)
78+
valid := codersdk.TemplateVersionNameValid(str)
7979
return valid == nil
8080
}
8181
err := Validate.RegisterValidation("template_version_name", templateVersionNameValidator)
@@ -89,7 +89,7 @@ func init() {
8989
if !ok {
9090
return false
9191
}
92-
valid := UserRealNameValid(str)
92+
valid := codersdk.UserRealNameValid(str)
9393
return valid == nil
9494
}
9595
err = Validate.RegisterValidation("user_real_name", userRealNameValidator)
@@ -103,7 +103,7 @@ func init() {
103103
if !ok {
104104
return false
105105
}
106-
valid := GroupNameValid(str)
106+
valid := codersdk.GroupNameValid(str)
107107
return valid == nil
108108
}
109109
err = Validate.RegisterValidation("group_name", groupNameValidator)

coderd/httpapi/name.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func UsernameFrom(str string) string {
3838
}
3939

4040
// NameValid returns whether the input string is a valid name.
41-
// It is a generic validator for any name (user, workspace, template, role name, etc.).
41+
// It is a generic validator for any name that doesn't have it's own validator.
4242
func NameValid(str string) error {
4343
if len(str) > 32 {
4444
return xerrors.New("must be <= 32 characters")

coderd/userauth.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
602602
}
603603

604604
ghName := ghUser.GetName()
605-
normName := httpapi.NormalizeRealUsername(ghName)
605+
normName := codersdk.NormalizeRealUsername(ghName)
606606

607607
// If we have a nil GitHub ID, that is a big problem. That would mean we link
608608
// this user and all other users with this bug to the same uuid.
@@ -951,15 +951,15 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
951951
// The username is a required property in Coder. We make a best-effort
952952
// attempt at using what the claims provide, but if that fails we will
953953
// generate a random username.
954-
usernameValid := httpapi.NameValid(username)
954+
usernameValid := codersdk.NameValid(username)
955955
if usernameValid != nil {
956956
// If no username is provided, we can default to use the email address.
957957
// This will be converted in the from function below, so it's safe
958958
// to keep the domain.
959959
if username == "" {
960960
username = email
961961
}
962-
username = httpapi.UsernameFrom(username)
962+
username = codersdk.UsernameFrom(username)
963963
}
964964

965965
if len(api.OIDCConfig.EmailDomain) > 0 {
@@ -994,7 +994,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
994994
nameRaw, ok := mergedClaims[api.OIDCConfig.NameField]
995995
if ok {
996996
name, _ = nameRaw.(string)
997-
name = httpapi.NormalizeRealUsername(name)
997+
name = codersdk.NormalizeRealUsername(name)
998998
}
999999

10001000
var picture string
@@ -1389,7 +1389,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
13891389
for i := 0; i < 10; i++ {
13901390
alternate := fmt.Sprintf("%s-%s", original, namesgenerator.GetRandomName(1))
13911391

1392-
params.Username = httpapi.UsernameFrom(alternate)
1392+
params.Username = codersdk.UsernameFrom(alternate)
13931393

13941394
//nolint:gocritic
13951395
_, err := tx.GetUserByEmailOrUsername(dbauthz.AsSystemRestricted(ctx), database.GetUserByEmailOrUsernameParams{

coderd/users.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,7 +1287,7 @@ type CreateUserRequest struct {
12871287
func (api *API) CreateUser(ctx context.Context, store database.Store, req CreateUserRequest) (database.User, error) {
12881288
// Ensure the username is valid. It's the caller's responsibility to ensure
12891289
// the username is valid and unique.
1290-
if usernameValid := httpapi.NameValid(req.Username); usernameValid != nil {
1290+
if usernameValid := codersdk.NameValid(req.Username); usernameValid != nil {
12911291
return database.User{}, xerrors.Errorf("invalid username %q: %w", req.Username, usernameValid)
12921292
}
12931293

@@ -1299,7 +1299,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
12991299
ID: uuid.New(),
13001300
Email: req.Email,
13011301
Username: req.Username,
1302-
Name: httpapi.NormalizeRealUsername(req.Name),
1302+
Name: codersdk.NormalizeRealUsername(req.Name),
13031303
CreatedAt: dbtime.Now(),
13041304
UpdatedAt: dbtime.Now(),
13051305
HashedPassword: []byte{},

codersdk/name.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package codersdk
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/moby/moby/pkg/namesgenerator"
8+
"golang.org/x/xerrors"
9+
)
10+
11+
var (
12+
UsernameValidRegex = regexp.MustCompile("^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$")
13+
usernameReplace = regexp.MustCompile("[^a-zA-Z0-9-]*")
14+
15+
templateVersionName = regexp.MustCompile(`^[a-zA-Z0-9]+(?:[_.-]{1}[a-zA-Z0-9]+)*$`)
16+
templateDisplayName = regexp.MustCompile(`^[^\s](.*[^\s])?$`)
17+
)
18+
19+
// UsernameFrom returns a best-effort username from the provided string.
20+
//
21+
// It first attempts to validate the incoming string, which will
22+
// be returned if it is valid. It then will attempt to extract
23+
// the username from an email address. If no success happens during
24+
// these steps, a random username will be returned.
25+
func UsernameFrom(str string) string {
26+
if valid := NameValid(str); valid == nil {
27+
return str
28+
}
29+
emailAt := strings.LastIndex(str, "@")
30+
if emailAt >= 0 {
31+
str = str[:emailAt]
32+
}
33+
str = usernameReplace.ReplaceAllString(str, "")
34+
if valid := NameValid(str); valid == nil {
35+
return str
36+
}
37+
return strings.ReplaceAll(namesgenerator.GetRandomName(1), "_", "-")
38+
}
39+
40+
// NameValid returns whether the input string is a valid name.
41+
// It is a generic validator for any name (user, workspace, template, role name, etc.).
42+
func NameValid(str string) error {
43+
if len(str) > 32 {
44+
return xerrors.New("must be <= 32 characters")
45+
}
46+
if len(str) < 1 {
47+
return xerrors.New("must be >= 1 character")
48+
}
49+
// Avoid conflicts with routes like /templates/new and /groups/create.
50+
if str == "new" || str == "create" {
51+
return xerrors.Errorf("cannot use %q as a name", str)
52+
}
53+
matched := UsernameValidRegex.MatchString(str)
54+
if !matched {
55+
return xerrors.New("must be alphanumeric with hyphens")
56+
}
57+
return nil
58+
}
59+
60+
// TemplateVersionNameValid returns whether the input string is a valid template version name.
61+
func TemplateVersionNameValid(str string) error {
62+
if len(str) > 64 {
63+
return xerrors.New("must be <= 64 characters")
64+
}
65+
matched := templateVersionName.MatchString(str)
66+
if !matched {
67+
return xerrors.New("must be alphanumeric with underscores and dots")
68+
}
69+
return nil
70+
}
71+
72+
// DisplayNameValid returns whether the input string is a valid template display name.
73+
func DisplayNameValid(str string) error {
74+
if len(str) == 0 {
75+
return nil // empty display_name is correct
76+
}
77+
if len(str) > 64 {
78+
return xerrors.New("must be <= 64 characters")
79+
}
80+
matched := templateDisplayName.MatchString(str)
81+
if !matched {
82+
return xerrors.New("must be alphanumeric with spaces")
83+
}
84+
return nil
85+
}
86+
87+
// UserRealNameValid returns whether the input string is a valid real user name.
88+
func UserRealNameValid(str string) error {
89+
if len(str) > 128 {
90+
return xerrors.New("must be <= 128 characters")
91+
}
92+
93+
if strings.TrimSpace(str) != str {
94+
return xerrors.New("must not have leading or trailing whitespace")
95+
}
96+
return nil
97+
}
98+
99+
// GroupNameValid returns whether the input string is a valid group name.
100+
func GroupNameValid(str string) error {
101+
// 36 is to support using UUIDs as the group name.
102+
if len(str) > 36 {
103+
return xerrors.New("must be <= 36 characters")
104+
}
105+
// Avoid conflicts with routes like /groups/new and /groups/create.
106+
if str == "new" || str == "create" {
107+
return xerrors.Errorf("cannot use %q as a name", str)
108+
}
109+
matched := UsernameValidRegex.MatchString(str)
110+
if !matched {
111+
return xerrors.New("must be alphanumeric with hyphens")
112+
}
113+
return nil
114+
}
115+
116+
// NormalizeUserRealName normalizes a user name such that it will pass
117+
// validation by UserRealNameValid. This is done to avoid blocking
118+
// little Bobby Whitespace from using Coder.
119+
func NormalizeRealUsername(str string) string {
120+
s := strings.TrimSpace(str)
121+
if len(s) > 128 {
122+
s = s[:128]
123+
}
124+
return s
125+
}

coderd/httpapi/name_test.go renamed to codersdk/name_test.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package httpapi_test
1+
package codersdk_test
22

33
import (
44
"strings"
@@ -7,7 +7,7 @@ import (
77
"github.com/stretchr/testify/assert"
88
"github.com/stretchr/testify/require"
99

10-
"github.com/coder/coder/v2/coderd/httpapi"
10+
"github.com/coder/coder/v2/codersdk"
1111
"github.com/coder/coder/v2/testutil"
1212
)
1313

@@ -62,7 +62,7 @@ func TestUsernameValid(t *testing.T) {
6262
testCase := testCase
6363
t.Run(testCase.Username, func(t *testing.T) {
6464
t.Parallel()
65-
valid := httpapi.NameValid(testCase.Username)
65+
valid := codersdk.NameValid(testCase.Username)
6666
require.Equal(t, testCase.Valid, valid == nil)
6767
})
6868
}
@@ -117,7 +117,7 @@ func TestTemplateDisplayNameValid(t *testing.T) {
117117
testCase := testCase
118118
t.Run(testCase.Name, func(t *testing.T) {
119119
t.Parallel()
120-
valid := httpapi.DisplayNameValid(testCase.Name)
120+
valid := codersdk.DisplayNameValid(testCase.Name)
121121
require.Equal(t, testCase.Valid, valid == nil)
122122
})
123123
}
@@ -158,7 +158,7 @@ func TestTemplateVersionNameValid(t *testing.T) {
158158
testCase := testCase
159159
t.Run(testCase.Name, func(t *testing.T) {
160160
t.Parallel()
161-
valid := httpapi.TemplateVersionNameValid(testCase.Name)
161+
valid := codersdk.TemplateVersionNameValid(testCase.Name)
162162
require.Equal(t, testCase.Valid, valid == nil)
163163
})
164164
}
@@ -169,7 +169,7 @@ func TestGeneratedTemplateVersionNameValid(t *testing.T) {
169169

170170
for i := 0; i < 1000; i++ {
171171
name := testutil.GetRandomName(t)
172-
err := httpapi.TemplateVersionNameValid(name)
172+
err := codersdk.TemplateVersionNameValid(name)
173173
require.NoError(t, err, "invalid template version name: %s", name)
174174
}
175175
}
@@ -199,9 +199,9 @@ func TestFrom(t *testing.T) {
199199
testCase := testCase
200200
t.Run(testCase.From, func(t *testing.T) {
201201
t.Parallel()
202-
converted := httpapi.UsernameFrom(testCase.From)
202+
converted := codersdk.UsernameFrom(testCase.From)
203203
t.Log(converted)
204-
valid := httpapi.NameValid(converted)
204+
valid := codersdk.NameValid(converted)
205205
require.True(t, valid == nil)
206206
if testCase.Match == "" {
207207
require.NotEqual(t, testCase.From, converted)
@@ -245,9 +245,9 @@ func TestUserRealNameValid(t *testing.T) {
245245
testCase := testCase
246246
t.Run(testCase.Name, func(t *testing.T) {
247247
t.Parallel()
248-
err := httpapi.UserRealNameValid(testCase.Name)
249-
norm := httpapi.NormalizeRealUsername(testCase.Name)
250-
normErr := httpapi.UserRealNameValid(norm)
248+
err := codersdk.UserRealNameValid(testCase.Name)
249+
norm := codersdk.NormalizeRealUsername(testCase.Name)
250+
normErr := codersdk.UserRealNameValid(norm)
251251
assert.NoError(t, normErr)
252252
assert.Equal(t, testCase.Valid, err == nil)
253253
assert.Equal(t, testCase.Valid, norm == testCase.Name, "invalid name should be different after normalization")

enterprise/coderd/roles.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ func validOrganizationRoleRequest(ctx context.Context, req codersdk.CustomRoleRe
266266
return false
267267
}
268268

269-
if err := httpapi.NameValid(req.Name); err != nil {
269+
if err := codersdk.NameValid(req.Name); err != nil {
270270
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
271271
Message: "Invalid role name",
272272
Detail: err.Error(),

enterprise/coderd/scim.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,15 +206,15 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
206206
// The username is a required property in Coder. We make a best-effort
207207
// attempt at using what the claims provide, but if that fails we will
208208
// generate a random username.
209-
usernameValid := httpapi.NameValid(sUser.UserName)
209+
usernameValid := codersdk.NameValid(sUser.UserName)
210210
if usernameValid != nil {
211211
// If no username is provided, we can default to use the email address.
212212
// This will be converted in the from function below, so it's safe
213213
// to keep the domain.
214214
if sUser.UserName == "" {
215215
sUser.UserName = email
216216
}
217-
sUser.UserName = httpapi.UsernameFrom(sUser.UserName)
217+
sUser.UserName = codersdk.UsernameFrom(sUser.UserName)
218218
}
219219

220220
// TODO: This is a temporary solution that does not support multi-org

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy