diff options
Diffstat (limited to 'api')
-rw-r--r-- | api/command.go | 112 | ||||
-rw-r--r-- | api/command_test.go | 37 | ||||
-rw-r--r-- | api/export.go | 292 | ||||
-rw-r--r-- | api/file.go | 42 | ||||
-rw-r--r-- | api/team.go | 21 | ||||
-rw-r--r-- | api/templates/error.html | 2 | ||||
-rw-r--r-- | api/templates/find_teams_body.html | 2 | ||||
-rw-r--r-- | api/templates/signup_team_body.html | 2 | ||||
-rw-r--r-- | api/user.go | 5 |
9 files changed, 448 insertions, 67 deletions
diff --git a/api/command.go b/api/command.go index f051bd42e..2919e93a0 100644 --- a/api/command.go +++ b/api/command.go @@ -4,15 +4,15 @@ package api import ( + "net/http" + "strconv" + "strings" + "time" + l4g "code.google.com/p/log4go" "github.com/gorilla/mux" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" - "net/http" - "reflect" - "runtime" - "strconv" - "strings" ) type commandHandler func(c *Context, command *model.Command) bool @@ -24,6 +24,8 @@ var commands = []commandHandler{ echoCommand, } +var echoSem chan bool + func InitCommand(r *mux.Router) { l4g.Debug("Initializing command api routes") r.Handle("/command", ApiUserRequired(command)).Methods("POST") @@ -41,7 +43,6 @@ func command(c *Context, w http.ResponseWriter, r *http.Request) { } checkCommand(c, command) - if c.Err != nil { return } else { @@ -56,8 +57,6 @@ func checkCommand(c *Context, command *model.Command) bool { return false } - tchan := Srv.Store.Team().Get(c.Session.TeamId) - if len(command.ChannelId) > 0 { cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, command.ChannelId, c.Session.UserId) @@ -66,24 +65,9 @@ func checkCommand(c *Context, command *model.Command) bool { } } - allowValet := false - if tResult := <-tchan; tResult.Err != nil { - c.Err = model.NewAppError("checkCommand", "Could not find the team for this session, team_id="+c.Session.TeamId, "") - return false - } else { - allowValet = tResult.Data.(*model.Team).AllowValet - } - - ec := runtime.FuncForPC(reflect.ValueOf(echoCommand).Pointer()).Name() - for _, v := range commands { - if !allowValet && ec == runtime.FuncForPC(reflect.ValueOf(v).Pointer()).Name() { - continue - } - if v(c, command) { - return true - } else if c.Err != nil { + if v(c, command) || c.Err != nil { return true } } @@ -112,55 +96,65 @@ func logoutCommand(c *Context, command *model.Command) bool { } func echoCommand(c *Context, command *model.Command) bool { - cmd := "/echo" + maxThreads := 100 - if strings.Index(command.Command, cmd) == 0 { - parts := strings.SplitN(command.Command, " ", 3) - - channelName := "" - if len(parts) >= 2 { - channelName = parts[1] + if !command.Suggest && strings.Index(command.Command, cmd) == 0 { + parameters := strings.SplitN(command.Command, " ", 2) + if len(parameters) != 2 || len(parameters[1]) == 0 { + return false } - - message := "" - if len(parts) >= 3 { - message = parts[2] + message := strings.Trim(parameters[1], " ") + delay := 0 + if endMsg := strings.LastIndex(message, "\""); string(message[0]) == "\"" && endMsg > 1 { + if checkDelay, err := strconv.Atoi(strings.Trim(message[endMsg:], " \"")); err == nil { + delay = checkDelay + } + message = message[1:endMsg] + } else if strings.Index(message, " ") > -1 { + delayIdx := strings.LastIndex(message, " ") + delayStr := strings.Trim(message[delayIdx:], " ") + + if checkDelay, err := strconv.Atoi(delayStr); err == nil { + delay = checkDelay + message = message[:delayIdx] + } } - if result := <-Srv.Store.Channel().GetChannels(c.Session.TeamId, c.Session.UserId); result.Err != nil { - c.Err = result.Err + if delay > 10000 { + c.Err = model.NewAppError("echoCommand", "Delays must be under 10000 seconds", "") return false - } else { - channels := result.Data.(*model.ChannelList) + } - for _, v := range channels.Channels { - if v.Type == model.CHANNEL_DIRECT { - continue - } + if echoSem == nil { + // We want one additional thread allowed so we never reach channel lockup + echoSem = make(chan bool, maxThreads+1) + } - if v.Name == channelName && !command.Suggest { - post := &model.Post{} - post.ChannelId = v.Id - post.Message = message + if len(echoSem) >= maxThreads { + c.Err = model.NewAppError("echoCommand", "High volume of echo request, cannot process request", "") + return false + } - if _, err := CreateValetPost(c, post); err != nil { - c.Err = err - return false - } + echoSem <- true + go func() { + defer func() { <-echoSem }() + post := &model.Post{} + post.ChannelId = command.ChannelId + post.Message = message - command.Response = model.RESP_EXECUTED - return true - } + time.Sleep(time.Duration(delay) * time.Second) - if len(channelName) == 0 || (strings.Index(v.Name, channelName) == 0 && len(parts) < 3) { - command.AddSuggestion(&model.SuggestCommand{Suggestion: cmd + " " + v.Name, Description: "Echo a message using Valet in a channel"}) - } + if _, err := CreatePost(c, post, false); err != nil { + l4g.Error("Unable to create /echo post, err=%v", err) } - } + }() + + command.Response = model.RESP_EXECUTED + return true } else if strings.Index(cmd, command.Command) == 0 { - command.AddSuggestion(&model.SuggestCommand{Suggestion: cmd, Description: "Echo a message using Valet in a channel"}) + command.AddSuggestion(&model.SuggestCommand{Suggestion: cmd, Description: "Echo back text from your account, /echo \"message\" [delay in seconds]"}) } return false diff --git a/api/command_test.go b/api/command_test.go index a58ef9be5..fe52dd41b 100644 --- a/api/command_test.go +++ b/api/command_test.go @@ -4,9 +4,10 @@ package api import ( + "testing" + "github.com/mattermost/platform/model" "github.com/mattermost/platform/store" - "testing" ) func TestSuggestRootCommands(t *testing.T) { @@ -50,6 +51,12 @@ func TestSuggestRootCommands(t *testing.T) { if rs3.Suggestions[0].Suggestion != "/join" { t.Fatal("should have join cmd") } + + rs4 := Client.Must(Client.Command("", "/ech", true)).Data.(*model.Command) + + if rs4.Suggestions[0].Suggestion != "/echo" { + t.Fatal("should have echo cmd") + } } func TestLogoutCommands(t *testing.T) { @@ -145,3 +152,31 @@ func TestJoinCommands(t *testing.T) { t.Fatal("didn't join channel") } } + +func TestEchoCommand(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user1 := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user1 = Client.Must(Client.CreateUser(user1, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user1.Id)) + + Client.LoginByEmail(team.Name, user1.Email, "pwd") + + channel1 := &model.Channel{DisplayName: "AA", Name: "aa" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + echoTestString := "/echo test" + + r1 := Client.Must(Client.Command(channel1.Id, echoTestString, false)).Data.(*model.Command) + if r1.Response != model.RESP_EXECUTED { + t.Fatal("Echo command failed to execute") + } + + p1 := Client.Must(Client.GetPosts(channel1.Id, 0, 2, "")).Data.(*model.PostList) + if len(p1.Order) != 1 { + t.Fatal("Echo command failed to send") + } +} diff --git a/api/export.go b/api/export.go new file mode 100644 index 000000000..9345f892f --- /dev/null +++ b/api/export.go @@ -0,0 +1,292 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "archive/zip" + "encoding/json" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + "io" + "os" +) + +const ( + EXPORT_PATH = "export/" + EXPORT_FILENAME = "MattermostExport.zip" + EXPORT_OPTIONS_FILE = "options.json" + EXPORT_TEAMS_FOLDER = "teams" + EXPORT_CHANNELS_FOLDER = "channels" + EXPORT_CHANNEL_MEMBERS_FOLDER = "members" + EXPORT_POSTS_FOLDER = "posts" + EXPORT_USERS_FOLDER = "users" + EXPORT_LOCAL_STORAGE_FOLDER = "files" +) + +type ExportWriter interface { + Create(name string) (io.Writer, error) +} + +type ExportOptions struct { + TeamsToExport []string `json:"teams"` + ChannelsToExport []string `json:"channels"` + UsersToExport []string `json:"users"` + ExportLocalStorage bool `json:"export_local_storage"` +} + +func (options *ExportOptions) ToJson() string { + b, err := json.Marshal(options) + if err != nil { + return "" + } else { + return string(b) + } +} + +func ExportOptionsFromJson(data io.Reader) *ExportOptions { + decoder := json.NewDecoder(data) + var o ExportOptions + decoder.Decode(&o) + return &o +} + +func ExportToFile(options *ExportOptions) (link string, err *model.AppError) { + // Open file for export + if file, err := openFileWriteStream(EXPORT_PATH + EXPORT_FILENAME); err != nil { + return "", err + } else { + defer closeFileWriteStream(file) + ExportToWriter(file, options) + } + + return "/api/v1/files/get_export", nil +} + +func ExportToWriter(w io.Writer, options *ExportOptions) *model.AppError { + // Open a writer to write to zip file + zipWriter := zip.NewWriter(w) + defer zipWriter.Close() + + // Write our options to file + if optionsFile, err := zipWriter.Create(EXPORT_OPTIONS_FILE); err != nil { + return model.NewAppError("ExportToWriter", "Unable to create options file", err.Error()) + } else { + if _, err := optionsFile.Write([]byte(options.ToJson())); err != nil { + return model.NewAppError("ExportToWriter", "Unable to write to options file", err.Error()) + } + } + + // Export Teams + ExportTeams(zipWriter, options) + + return nil +} + +func ExportTeams(writer ExportWriter, options *ExportOptions) *model.AppError { + // Get the teams + var teams []*model.Team + if len(options.TeamsToExport) == 0 { + if result := <-Srv.Store.Team().GetForExport(); result.Err != nil { + return result.Err + } else { + teams = result.Data.([]*model.Team) + } + } else { + for _, teamId := range options.TeamsToExport { + if result := <-Srv.Store.Team().Get(teamId); result.Err != nil { + return result.Err + } else { + team := result.Data.(*model.Team) + teams = append(teams, team) + } + } + } + + // Export the teams + for i := range teams { + // Sanitize + teams[i].PreExport() + + if teamFile, err := writer.Create(EXPORT_TEAMS_FOLDER + "/" + teams[i].Name + ".json"); err != nil { + return model.NewAppError("ExportTeams", "Unable to open file for export", err.Error()) + } else { + if _, err := teamFile.Write([]byte(teams[i].ToJson())); err != nil { + return model.NewAppError("ExportTeams", "Unable to write to team export file", err.Error()) + } + } + + } + + // Export the channels, local storage and users + for _, team := range teams { + if err := ExportChannels(writer, options, team.Id); err != nil { + return err + } + if err := ExportUsers(writer, options, team.Id); err != nil { + return err + } + if err := ExportLocalStorage(writer, options, team.Id); err != nil { + return err + } + } + + return nil +} + +func ExportChannels(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError { + // Get the channels + var channels []*model.Channel + if len(options.ChannelsToExport) == 0 { + if result := <-Srv.Store.Channel().GetForExport(teamId); result.Err != nil { + return result.Err + } else { + channels = result.Data.([]*model.Channel) + } + } else { + for _, channelId := range options.ChannelsToExport { + if result := <-Srv.Store.Channel().Get(channelId); result.Err != nil { + return result.Err + } else { + channel := result.Data.(*model.Channel) + channels = append(channels, channel) + } + } + } + + for i := range channels { + // Get members + mchan := Srv.Store.Channel().GetMembers(channels[i].Id) + + // Sanitize + channels[i].PreExport() + + if channelFile, err := writer.Create(EXPORT_CHANNELS_FOLDER + "/" + channels[i].Id + ".json"); err != nil { + return model.NewAppError("ExportChannels", "Unable to open file for export", err.Error()) + } else { + if _, err := channelFile.Write([]byte(channels[i].ToJson())); err != nil { + return model.NewAppError("ExportChannels", "Unable to write to export file", err.Error()) + } + } + + var members []model.ChannelMember + if result := <-mchan; result.Err != nil { + return result.Err + } else { + members = result.Data.([]model.ChannelMember) + } + + if membersFile, err := writer.Create(EXPORT_CHANNELS_FOLDER + "/" + channels[i].Id + "_members.json"); err != nil { + return model.NewAppError("ExportChannels", "Unable to open file for export", err.Error()) + } else { + result, err2 := json.Marshal(members) + if err2 != nil { + return model.NewAppError("ExportChannels", "Unable to convert to json", err.Error()) + } + if _, err3 := membersFile.Write([]byte(result)); err3 != nil { + return model.NewAppError("ExportChannels", "Unable to write to export file", err.Error()) + } + } + } + + for _, channel := range channels { + if err := ExportPosts(writer, options, channel.Id); err != nil { + return err + } + } + + return nil +} + +func ExportPosts(writer ExportWriter, options *ExportOptions, channelId string) *model.AppError { + // Get the posts + var posts []*model.Post + if result := <-Srv.Store.Post().GetForExport(channelId); result.Err != nil { + return result.Err + } else { + posts = result.Data.([]*model.Post) + } + + // Export the posts + if postsFile, err := writer.Create(EXPORT_POSTS_FOLDER + "/" + channelId + "_posts.json"); err != nil { + return model.NewAppError("ExportPosts", "Unable to open file for export", err.Error()) + } else { + result, err2 := json.Marshal(posts) + if err2 != nil { + return model.NewAppError("ExportPosts", "Unable to convert to json", err.Error()) + } + if _, err3 := postsFile.Write([]byte(result)); err3 != nil { + return model.NewAppError("ExportPosts", "Unable to write to export file", err.Error()) + } + } + + return nil +} + +func ExportUsers(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError { + // Get the users + var users []*model.User + if result := <-Srv.Store.User().GetForExport(teamId); result.Err != nil { + return result.Err + } else { + users = result.Data.([]*model.User) + } + + // Write the users + if usersFile, err := writer.Create(EXPORT_USERS_FOLDER + "/" + teamId + "_users.json"); err != nil { + return model.NewAppError("ExportUsers", "Unable to open file for export", err.Error()) + } else { + result, err2 := json.Marshal(users) + if err2 != nil { + return model.NewAppError("ExportUsers", "Unable to convert to json", err.Error()) + } + if _, err3 := usersFile.Write([]byte(result)); err3 != nil { + return model.NewAppError("ExportUsers", "Unable to write to export file", err.Error()) + } + } + return nil +} + +func copyDirToExportWriter(writer ExportWriter, inPath string, outPath string) *model.AppError { + dir, err := os.Open(inPath) + if err != nil { + return model.NewAppError("copyDirToExportWriter", "Unable to open directory", err.Error()) + } + + fileInfoList, err := dir.Readdir(0) + if err != nil { + return model.NewAppError("copyDirToExportWriter", "Unable to read directory", err.Error()) + } + + for _, fileInfo := range fileInfoList { + if fileInfo.IsDir() { + copyDirToExportWriter(writer, inPath+"/"+fileInfo.Name(), outPath+"/"+fileInfo.Name()) + } else { + if toFile, err := writer.Create(outPath + "/" + fileInfo.Name()); err != nil { + return model.NewAppError("copyDirToExportWriter", "Unable to open file for export", err.Error()) + } else { + fromFile, err := os.Open(inPath + "/" + fileInfo.Name()) + if err != nil { + return model.NewAppError("copyDirToExportWriter", "Unable to open file", err.Error()) + } + io.Copy(toFile, fromFile) + } + } + } + + return nil +} + +func ExportLocalStorage(writer ExportWriter, options *ExportOptions, teamId string) *model.AppError { + teamDir := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + teamId + + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + return model.NewAppError("ExportLocalStorage", "S3 is not supported for local storage export.", "") + } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 { + if err := copyDirToExportWriter(writer, teamDir, EXPORT_LOCAL_STORAGE_FOLDER); err != nil { + return err + } + } + + return nil +} diff --git a/api/file.go b/api/file.go index 800c512c5..1d8244fac 100644 --- a/api/file.go +++ b/api/file.go @@ -40,6 +40,7 @@ func InitFile(r *mux.Router) { sr.Handle("/get/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandler(getFile)).Methods("GET") sr.Handle("/get_info/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandler(getFileInfo)).Methods("GET") sr.Handle("/get_public_link", ApiUserRequired(getPublicLink)).Methods("POST") + sr.Handle("/get_export", ApiUserRequired(getExport)).Methods("GET") } func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { @@ -414,6 +415,23 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(rData))) } +func getExport(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasPermissionsToTeam(c.Session.TeamId, "export") || !c.IsTeamAdmin(c.Session.UserId) { + c.Err = model.NewAppError("getExport", "Only a team admin can retrieve exported data.", "userId="+c.Session.UserId) + c.Err.StatusCode = http.StatusForbidden + return + } + data, err := readFile(EXPORT_PATH + EXPORT_FILENAME) + if err != nil { + c.Err = model.NewAppError("getExport", "Unable to retrieve exported file. Please re-export", err.Error()) + return + } + + w.Header().Set("Content-Disposition", "attachment; filename="+EXPORT_FILENAME) + w.Header().Set("Content-Type", "application/octet-stream") + w.Write(data) +} + func writeFile(f []byte, path string) *model.AppError { if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { @@ -488,3 +506,27 @@ func readFile(path string) ([]byte, *model.AppError) { return nil, model.NewAppError("readFile", "File storage not configured properly. Please configure for either S3 or local server file storage.", "") } } + +func openFileWriteStream(path string) (io.Writer, *model.AppError) { + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + return nil, model.NewAppError("openFileWriteStream", "S3 is not supported.", "") + } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 { + if err := os.MkdirAll(filepath.Dir(utils.Cfg.ServiceSettings.StorageDirectory+path), 0774); err != nil { + return nil, model.NewAppError("openFileWriteStream", "Encountered an error creating the directory for the new file", err.Error()) + } + + if fileHandle, err := os.Create(utils.Cfg.ServiceSettings.StorageDirectory + path); err != nil { + return nil, model.NewAppError("openFileWriteStream", "Encountered an error writing to local server storage", err.Error()) + } else { + fileHandle.Chmod(0644) + return fileHandle, nil + } + + } + + return nil, model.NewAppError("openFileWriteStream", "File storage not configured properly. Please configure for either S3 or local server file storage.", "") +} + +func closeFileWriteStream(file io.Writer) { + file.(*os.File).Close() +} diff --git a/api/team.go b/api/team.go index 8cce384c3..e1b3b274a 100644 --- a/api/team.go +++ b/api/team.go @@ -32,7 +32,9 @@ func InitTeam(r *mux.Router) { sr.Handle("/update_name", ApiUserRequired(updateTeamDisplayName)).Methods("POST") sr.Handle("/update_valet_feature", ApiUserRequired(updateValetFeature)).Methods("POST") sr.Handle("/me", ApiUserRequired(getMyTeam)).Methods("GET") + // These should be moved to the global admain console sr.Handle("/import_team", ApiUserRequired(importTeam)).Methods("POST") + sr.Handle("/export_team", ApiUserRequired(exportTeam)).Methods("GET") } func signupTeam(c *Context, w http.ResponseWriter, r *http.Request) { @@ -675,3 +677,22 @@ func importTeam(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") http.ServeContent(w, r, "MattermostImportLog.txt", time.Now(), bytes.NewReader(log.Bytes())) } + +func exportTeam(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasPermissionsToTeam(c.Session.TeamId, "export") || !c.IsTeamAdmin(c.Session.UserId) { + c.Err = model.NewAppError("exportTeam", "Only a team admin can export data.", "userId="+c.Session.UserId) + c.Err.StatusCode = http.StatusForbidden + return + } + + options := ExportOptionsFromJson(r.Body) + + if link, err := ExportToFile(options); err != nil { + c.Err = err + return + } else { + result := map[string]string{} + result["link"] = link + w.Write([]byte(model.MapToJson(result))) + } +} diff --git a/api/templates/error.html b/api/templates/error.html index 3474c9e1e..adb8f9f7d 100644 --- a/api/templates/error.html +++ b/api/templates/error.html @@ -5,7 +5,7 @@ <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css"> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/js/bootstrap.min.js"></script> - <link href='http://fonts.googleapis.com/css?family=Open+Sans:400,600,700' rel='stylesheet' type='text/css'> + <link href='https://fonts.googleapis.com/css?family=Open+Sans:400,600,700' rel='stylesheet' type='text/css'> <link rel="stylesheet" href="/static/css/styles.css"> </head> <body class="white error"> diff --git a/api/templates/find_teams_body.html b/api/templates/find_teams_body.html index 64bff8126..00c5628dd 100644 --- a/api/templates/find_teams_body.html +++ b/api/templates/find_teams_body.html @@ -19,7 +19,7 @@ <td style="border-bottom: 1px solid #ddd; padding: 0 0 20px;"> <h2 style="font-weight: normal; margin-top: 10px;">Finding teams</h2> <p>{{ if .Props }} - The following teams were found:<br> + Your request to find teams associated with your email found the following:<br> {{range $index, $element := .Props}} <a href="{{ $element }}" style="text-decoration: none; color:#2389D7;">{{ $index }}</a><br> {{ end }} diff --git a/api/templates/signup_team_body.html b/api/templates/signup_team_body.html index 71df0b9c8..b49cf5f36 100644 --- a/api/templates/signup_team_body.html +++ b/api/templates/signup_team_body.html @@ -21,7 +21,7 @@ <p style="margin: 20px 0 25px"> <a href="{{.Props.Link}}" style="background: #2389D7; border-radius: 3px; color: #fff; border: none; outline: none; min-width: 200px; padding: 15px 25px; font-size: 14px; font-family: inherit; cursor: pointer; -webkit-appearance: none;text-decoration: none;">Set up your team</a> </p> - {{ .SiteName }} is free for an unlimited time, for unlimited users.<br>You'll get more out of {{ .SiteName }} when your team is in constant communication--let's get them on board.<br></p> + {{ .SiteName }} is one place for all your team communication, searchable and available anywhere.<br>You'll get more out of {{ .SiteName }} when your team is in constant communication--let's get them on board.<br></p> <p> Learn more by <a href="{{.Props.TourUrl}}" style="text-decoration: none; color:#2389D7;">taking a tour</a> </p> diff --git a/api/user.go b/api/user.go index d69244fad..727accd1f 100644 --- a/api/user.go +++ b/api/user.go @@ -71,10 +71,7 @@ func createUser(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !model.IsUsernameValid(user.Username) { - c.Err = model.NewAppError("createUser", "That username is invalid", "might be using a resrved username") - return - } + // the user's username is checked to be valid when they are saved to the database user.EmailVerified = false |