package userapi import ( "errors" "fmt" "strings" ) // MaxUserIDLength is the spec's cap on a user ID, sigil and server name // included. const MaxUserIDLength = 255 // Errors from parsing and validating identifiers. Callers map these onto // M_INVALID_USERNAME or M_UNKNOWN depending on the endpoint. var ( // ErrInvalidLocalpart means the localpart is empty, too long, or // contains a character the spec does not allow. ErrInvalidLocalpart = errors.New("invalid user name") // ErrInvalidUserID means the string is not a well-formed user ID. ErrInvalidUserID = errors.New("invalid user ID") // ErrForeignServer means a well-formed user ID belongs to another // homeserver. Answering for it needs federation. ErrForeignServer = errors.New("user ID belongs to another server") ) // NormaliseLocalpart lower-cases a localpart. // // The spec's grammar for the localpart of a user ID allows only lowercase // letters, so a client sending "User-UPPER" is asking for "user-upper" // rather than making a mistake. Both registration and login normalise, which // is also what Synapse does and what Complement checks. func NormaliseLocalpart(localpart string) string { return strings.ToLower(localpart) } // ValidateLocalpart checks a normalised localpart against the user ID // grammar of the Client-Server API: lowercase letters, digits, and the // characters . _ = - / +. // // serverName is needed because the 255-byte limit applies to the whole user // ID, so how long a localpart may be depends on where it lives. func ValidateLocalpart(localpart, serverName string) error { if localpart == "" { return fmt.Errorf("%w: must not be empty", ErrInvalidLocalpart) } if n := len("@" + localpart + ":" + serverName); n > MaxUserIDLength { return fmt.Errorf("%w: the resulting user ID would be %d bytes, the limit is %d", ErrInvalidLocalpart, n, MaxUserIDLength) } for _, r := range localpart { if !isLocalpartRune(r) { return fmt.Errorf("%w: %q is not allowed; use lowercase letters, digits or . _ = - / +", ErrInvalidLocalpart, r) } } return nil } // isLocalpartRune reports whether r may appear in a user ID localpart. The // range is deliberately byte-oriented: the grammar is ASCII only, so any // multi-byte rune falls through to false. func isLocalpartRune(r rune) bool { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': return true case r == '.', r == '_', r == '=', r == '-', r == '/', r == '+': return true default: return false } } // UserID assembles "@localpart:serverName". func (a *API) UserID(localpart string) string { return "@" + localpart + ":" + a.serverName } // ParseLocalUserID turns a user identifier into a normalised localpart on // this server. // // It accepts both forms clients send: a full "@user:server" ID and a bare // localpart, which the login endpoint has always allowed. A full ID naming // another server is ErrForeignServer — a distinct case from a malformed one, // because that is the answer federation would change. func (a *API) ParseLocalUserID(identifier string) (string, error) { if identifier == "" { return "", fmt.Errorf("%w: must not be empty", ErrInvalidUserID) } if !strings.HasPrefix(identifier, "@") { localpart := NormaliseLocalpart(identifier) if err := ValidateLocalpart(localpart, a.serverName); err != nil { return "", err } return localpart, nil } if len(identifier) > MaxUserIDLength { return "", fmt.Errorf("%w: longer than %d bytes", ErrInvalidUserID, MaxUserIDLength) } localpart, serverName, ok := strings.Cut(identifier[1:], ":") if !ok || serverName == "" { return "", fmt.Errorf("%w: expected @localpart:servername", ErrInvalidUserID) } // Server names are compared case-insensitively for the same reason the // federation allowlist is: every real deployment uses a DNS name, and // treating "Warp.example" as somebody else would turn a client's // capitalisation into a login failure. if !strings.EqualFold(serverName, a.serverName) { return "", fmt.Errorf("%w: %s", ErrForeignServer, serverName) } localpart = NormaliseLocalpart(localpart) if err := ValidateLocalpart(localpart, a.serverName); err != nil { return "", err } return localpart, nil }