From 36b17bf99ddd35c0c223722f8b6f4f1c71b2235e Mon Sep 17 00:00:00 2001 From: =Corey Hulen Date: Mon, 14 Mar 2016 16:07:58 -0700 Subject: PLT-2115 Adding compliance --- api/admin.go | 112 +++++- api/admin_test.go | 3 +- api/license.go | 20 ++ config/config.json | 34 +- einterfaces/compliance.go | 2 +- i18n/en.json | 37 +- mattermost.go | 22 +- model/client.go | 36 ++ model/compliance.go | 132 +++++++ model/compliance_post.go | 18 +- model/compliance_test.go | 19 + model/config.go | 46 ++- model/license.go | 12 +- store/sql_audit_store.go | 15 +- store/sql_compliance_store.go | 234 +++++++++++++ store/sql_compliance_store_test.go | 210 +++++++++++ store/sql_post_store.go | 56 --- store/sql_post_store_test.go | 76 ---- store/sql_store.go | 8 + store/store.go | 10 +- utils/config.go | 2 + utils/html.go | 11 +- utils/i18n.go | 6 +- utils/license.go | 1 + .../components/admin_console/admin_controller.jsx | 3 + .../components/admin_console/admin_sidebar.jsx | 17 + web/react/components/admin_console/audits.jsx | 55 +-- .../admin_console/compliance_reports.jsx | 384 +++++++++++++++++++++ .../admin_console/compliance_settings.jsx | 271 +++++++++++++++ web/react/components/audit_table.jsx | 9 +- web/react/stores/admin_store.jsx | 30 ++ web/react/utils/async_client.jsx | 26 ++ web/react/utils/client.jsx | 29 ++ web/react/utils/constants.jsx | 1 + web/sass-files/sass/partials/_admin-console.scss | 20 ++ web/static/i18n/en.json | 35 ++ 36 files changed, 1791 insertions(+), 211 deletions(-) create mode 100644 model/compliance.go create mode 100644 model/compliance_test.go create mode 100644 store/sql_compliance_store.go create mode 100644 store/sql_compliance_store_test.go create mode 100644 web/react/components/admin_console/compliance_reports.jsx create mode 100644 web/react/components/admin_console/compliance_settings.jsx diff --git a/api/admin.go b/api/admin.go index feb70aae3..9de9f5dd8 100644 --- a/api/admin.go +++ b/api/admin.go @@ -5,15 +5,18 @@ package api import ( "bufio" + "io/ioutil" "net/http" "os" + "strconv" "strings" l4g "github.com/alecthomas/log4go" "github.com/gorilla/mux" - + "github.com/mattermost/platform/einterfaces" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" + "github.com/mssola/user_agent" ) func InitAdmin(r *mux.Router) { @@ -27,8 +30,11 @@ func InitAdmin(r *mux.Router) { sr.Handle("/test_email", ApiUserRequired(testEmail)).Methods("POST") sr.Handle("/client_props", ApiAppHandler(getClientConfig)).Methods("GET") sr.Handle("/log_client", ApiAppHandler(logClient)).Methods("POST") - sr.Handle("/analytics/{id:[A-Za-z0-9]+}/{name:[A-Za-z0-9_]+}", ApiAppHandler(getAnalytics)).Methods("GET") - sr.Handle("/analytics/{name:[A-Za-z0-9_]+}", ApiAppHandler(getAnalytics)).Methods("GET") + sr.Handle("/analytics/{id:[A-Za-z0-9]+}/{name:[A-Za-z0-9_]+}", ApiUserRequired(getAnalytics)).Methods("GET") + sr.Handle("/analytics/{name:[A-Za-z0-9_]+}", ApiUserRequired(getAnalytics)).Methods("GET") + sr.Handle("/save_compliance_report", ApiUserRequired(saveComplianceReport)).Methods("POST") + sr.Handle("/compliance_reports", ApiUserRequired(getComplianceReports)).Methods("GET") + sr.Handle("/download_compliance_report/{id:[A-Za-z0-9]+}", ApiUserRequired(downloadComplianceReport)).Methods("GET") } func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { @@ -142,6 +148,8 @@ func saveConfig(c *Context, w http.ResponseWriter, r *http.Request) { return } + c.LogAudit("") + utils.SaveConfig(utils.CfgFileName, cfg) utils.LoadConfig(utils.CfgFileName) json := utils.Cfg.ToJson() @@ -174,6 +182,104 @@ func testEmail(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(m))) } +func getComplianceReports(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasSystemAdminPermissions("getComplianceReports") { + return + } + + if !*utils.Cfg.ComplianceSettings.Enable || !utils.IsLicensed || !*utils.License.Features.Compliance { + c.Err = model.NewLocAppError("getComplianceReports", "ent.compliance.licence_disable.app_error", nil, "") + return + } + + if result := <-Srv.Store.Compliance().GetAll(); result.Err != nil { + c.Err = result.Err + return + } else { + crs := result.Data.(model.Compliances) + w.Write([]byte(crs.ToJson())) + } +} + +func saveComplianceReport(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasSystemAdminPermissions("getComplianceReports") { + return + } + + if !*utils.Cfg.ComplianceSettings.Enable || !utils.IsLicensed || !*utils.License.Features.Compliance || einterfaces.GetComplianceInterface() == nil { + c.Err = model.NewLocAppError("saveComplianceReport", "ent.compliance.licence_disable.app_error", nil, "") + return + } + + job := model.ComplianceFromJson(r.Body) + if job == nil { + c.SetInvalidParam("saveComplianceReport", "compliance") + return + } + + job.UserId = c.Session.UserId + job.Type = model.COMPLIANCE_TYPE_ADHOC + + if result := <-Srv.Store.Compliance().Save(job); result.Err != nil { + c.Err = result.Err + return + } else { + job = result.Data.(*model.Compliance) + go einterfaces.GetComplianceInterface().RunComplianceJob(job) + } + + w.Write([]byte(job.ToJson())) +} + +func downloadComplianceReport(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasSystemAdminPermissions("downloadComplianceReport") { + return + } + + if !*utils.Cfg.ComplianceSettings.Enable || !utils.IsLicensed || !*utils.License.Features.Compliance || einterfaces.GetComplianceInterface() == nil { + c.Err = model.NewLocAppError("downloadComplianceReport", "ent.compliance.licence_disable.app_error", nil, "") + return + } + + params := mux.Vars(r) + + id := params["id"] + if len(id) != 26 { + c.SetInvalidParam("downloadComplianceReport", "id") + return + } + + if result := <-Srv.Store.Compliance().Get(id); result.Err != nil { + c.Err = result.Err + return + } else { + job := result.Data.(*model.Compliance) + c.LogAudit("downloaded " + job.JobName()) + + if f, err := ioutil.ReadFile(*utils.Cfg.ComplianceSettings.Directory + "compliance/" + job.JobName() + ".zip"); err != nil { + c.Err = model.NewLocAppError("readFile", "api.file.read_file.reading_local.app_error", nil, err.Error()) + return + } else { + w.Header().Set("Cache-Control", "max-age=2592000, public") + w.Header().Set("Content-Length", strconv.Itoa(len(f))) + w.Header().Del("Content-Type") // Content-Type will be set automatically by the http writer + + // attach extra headers to trigger a download on IE, Edge, and Safari + ua := user_agent.New(r.UserAgent()) + bname, _ := ua.Browser() + + w.Header().Set("Content-Disposition", "attachment;filename=\""+job.JobName()+".zip\"") + + if bname == "Edge" || bname == "Internet Explorer" || bname == "Safari" { + // trim off anything before the final / so we just get the file's name + w.Header().Set("Content-Type", "application/octet-stream") + } + + w.Write(f) + } + } +} + func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { if !c.HasSystemAdminPermissions("getAnalytics") { return diff --git a/api/admin_test.go b/api/admin_test.go index bdea0bc5b..67bc1d38b 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -4,11 +4,10 @@ package api import ( - "testing" - "github.com/mattermost/platform/model" "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" + "testing" ) func TestGetLogs(t *testing.T) { diff --git a/api/license.go b/api/license.go index 542b45e26..ed0771d17 100644 --- a/api/license.go +++ b/api/license.go @@ -23,6 +23,26 @@ func InitLicense(r *mux.Router) { sr.Handle("/client_config", ApiAppHandler(getClientLicenceConfig)).Methods("GET") } +func LoadLicense() { + licenseId := "" + if result := <-Srv.Store.System().Get(); result.Err == nil { + props := result.Data.(model.StringMap) + licenseId = props[model.SYSTEM_ACTIVE_LICENSE_ID] + } + + if len(licenseId) != 26 { + l4g.Warn(utils.T("mattermost.load_license.find.warn")) + return + } + + if result := <-Srv.Store.License().Get(licenseId); result.Err == nil { + record := result.Data.(*model.LicenseRecord) + utils.LoadLicense([]byte(record.Bytes)) + } else { + l4g.Warn(utils.T("mattermost.load_license.find.warn")) + } +} + func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { c.LogAudit("attempt") err := r.ParseMultipartForm(model.MAX_FILE_SIZE) diff --git a/config/config.json b/config/config.json index b211b16d3..5ed5d61bc 100644 --- a/config/config.json +++ b/config/config.json @@ -19,7 +19,9 @@ "SessionLengthWebInDays": 30, "SessionLengthMobileInDays": 30, "SessionLengthSSOInDays": 30, - "SessionCacheInMinutes": 10 + "SessionCacheInMinutes": 10, + "WebsocketSecurePort": 443, + "WebsocketPort": 80 }, "TeamSettings": { "SiteName": "Mattermost", @@ -113,5 +115,33 @@ "AuthEndpoint": "", "TokenEndpoint": "", "UserApiEndpoint": "" + }, + "GoogleSettings": { + "Enable": false, + "Secret": "", + "Id": "", + "Scope": "", + "AuthEndpoint": "", + "TokenEndpoint": "", + "UserApiEndpoint": "" + }, + "LdapSettings": { + "Enable": false, + "LdapServer": "", + "LdapPort": 389, + "BaseDN": "", + "BindUsername": "", + "BindPassword": "", + "FirstNameAttribute": "", + "LastNameAttribute": "", + "EmailAttribute": "", + "UsernameAttribute": "", + "IdAttribute": "", + "QueryTimeout": 60 + }, + "ComplianceSettings": { + "Enable": true, + "Directory": "./data/", + "EnableDaily": false } -} +} \ No newline at end of file diff --git a/einterfaces/compliance.go b/einterfaces/compliance.go index cd43152da..2e72c67d3 100644 --- a/einterfaces/compliance.go +++ b/einterfaces/compliance.go @@ -9,7 +9,7 @@ import ( type ComplianceInterface interface { StartComplianceDailyJob() - RunComplianceJob(jobName string, dir string, filename string, startTime int64, endTime int64) *model.AppError + RunComplianceJob(job *model.Compliance) *model.AppError } var theComplianceInterface ComplianceInterface diff --git a/i18n/en.json b/i18n/en.json index e42ade162..fb4cf50b4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1811,7 +1811,10 @@ "id": "ent.compliance.run_finished.info", "translation": "Compliance export finished for job '{{.JobName}}' exported {{.Count}} records to '{{.FilePath}}'" }, - + { + "id": "ent.compliance.licence_disable.app_error", + "translation": "Compliance functionality disabled by current license. Please contact your system administrator about upgrading your enterprise license." + }, { "id": "mattermost.security_checks.debug", "translation": "Checking for security update from Mattermost" @@ -2232,6 +2235,30 @@ "id": "model.post.is_valid.user_id.app_error", "translation": "Invalid user id" }, + { + "id": "model.compliance.is_valid.id.app_error", + "translation": "Invalid Id" + }, + { + "id": "model.compliance.is_valid.create_at.app_error", + "translation": "Create at must be a valid time" + }, + { + "id": "model.compliance.is_valid.desc.app_error", + "translation": "Invalid description" + }, + { + "id": "model.compliance.is_valid.start_at.app_error", + "translation": "From must be a valid time" + }, + { + "id": "model.compliance.is_valid.end_at.app_error", + "translation": "To must be a valid time" + }, + { + "id": "model.compliance.is_valid.start_end_at.app_error", + "translation": "To must be greater than From" + }, { "id": "model.preference.is_valid.category.app_error", "translation": "Invalid category" @@ -2508,6 +2535,14 @@ "id": "store.sql_audit.save.saving.app_error", "translation": "We encountered an error saving the audit" }, + { + "id": "store.sql_compliance.save.saving.app_error", + "translation": "We encountered an error saving the compliance report" + }, + { + "id": "store.sql_compliance.get.finding.app_error", + "translation": "We encountered an error retrieving the compliance reports" + }, { "id": "store.sql_channel.analytics_type_count.app_error", "translation": "We couldn't get channel type counts" diff --git a/mattermost.go b/mattermost.go index de97d36a2..c555862e9 100644 --- a/mattermost.go +++ b/mattermost.go @@ -70,7 +70,7 @@ func main() { web.InitWeb() if model.BuildEnterpriseReady == "true" { - loadLicense() + api.LoadLicense() } if !utils.IsLicensed && len(utils.Cfg.SqlSettings.DataSourceReplicas) > 1 { @@ -106,26 +106,6 @@ func main() { } } -func loadLicense() { - licenseId := "" - if result := <-api.Srv.Store.System().Get(); result.Err == nil { - props := result.Data.(model.StringMap) - licenseId = props[model.SYSTEM_ACTIVE_LICENSE_ID] - } - - if len(licenseId) != 26 { - l4g.Warn(utils.T("mattermost.load_license.find.warn")) - return - } - - if result := <-api.Srv.Store.License().Get(licenseId); result.Err == nil { - record := result.Data.(*model.LicenseRecord) - utils.LoadLicense([]byte(record.Bytes)) - } else { - l4g.Warn(utils.T("mattermost.load_license.find.warn")) - } -} - func setDiagnosticId() { if result := <-api.Srv.Store.System().Get(); result.Err == nil { props := result.Data.(model.StringMap) diff --git a/model/client.go b/model/client.go index 3adcb980d..f5c8ad641 100644 --- a/model/client.go +++ b/model/client.go @@ -471,6 +471,42 @@ func (c *Client) TestEmail(config *Config) (*Result, *AppError) { } } +func (c *Client) GetComplianceReports() (*Result, *AppError) { + if r, err := c.DoApiGet("/admin/compliance_reports", "", ""); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), CompliancesFromJson(r.Body)}, nil + } +} + +func (c *Client) SaveComplianceReport(job *Compliance) (*Result, *AppError) { + if r, err := c.DoApiPost("/admin/save_compliance_report", job.ToJson()); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), ComplianceFromJson(r.Body)}, nil + } +} + +func (c *Client) DownloadComplianceReport(id string) (*Result, *AppError) { + var rq *http.Request + rq, _ = http.NewRequest("GET", c.ApiUrl+"/admin/download_compliance_report/"+id, nil) + + if len(c.AuthToken) > 0 { + rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken) + } + + if rp, err := c.HttpClient.Do(rq); err != nil { + return nil, NewLocAppError("/admin/download_compliance_report", "model.client.connecting.app_error", nil, err.Error()) + } else if rp.StatusCode >= 300 { + return nil, AppErrorFromJson(rp.Body) + } else { + return &Result{rp.Header.Get(HEADER_REQUEST_ID), + rp.Header.Get(HEADER_ETAG_SERVER), rp.Body}, nil + } +} + func (c *Client) GetTeamAnalytics(teamId, name string) (*Result, *AppError) { if r, err := c.DoApiGet("/admin/analytics/"+teamId+"/"+name, "", ""); err != nil { return nil, err diff --git a/model/compliance.go b/model/compliance.go new file mode 100644 index 000000000..4a96a597a --- /dev/null +++ b/model/compliance.go @@ -0,0 +1,132 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" + "strings" +) + +const ( + COMPLIANCE_STATUS_CREATED = "created" + COMPLIANCE_STATUS_RUNNING = "running" + COMPLIANCE_STATUS_FINISHED = "finished" + COMPLIANCE_STATUS_FAILED = "failed" + COMPLIANCE_STATUS_REMOVED = "removed" + + COMPLIANCE_TYPE_DAILY = "daily" + COMPLIANCE_TYPE_ADHOC = "adhoc" +) + +type Compliance struct { + Id string `json:"id"` + CreateAt int64 `json:"create_at"` + UserId string `json:"user_id"` + Status string `json:"status"` + Count int `json:"count"` + Desc string `json:"desc"` + Type string `json:"type"` + StartAt int64 `json:"start_at"` + EndAt int64 `json:"end_at"` + Keywords string `json:"keywords"` + Emails string `json:"emails"` +} + +type Compliances []Compliance + +func (o *Compliance) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func (me *Compliance) PreSave() { + if me.Id == "" { + me.Id = NewId() + } + + if me.Status == "" { + me.Status = COMPLIANCE_STATUS_CREATED + } + + me.Count = 0 + me.Emails = strings.ToLower(me.Emails) + me.Keywords = strings.ToLower(me.Keywords) + + me.CreateAt = GetMillis() +} + +func (me *Compliance) JobName() string { + jobName := me.Type + if me.Type == COMPLIANCE_TYPE_DAILY { + jobName += "-" + me.Desc + } + + jobName += "-" + me.Id + + return jobName +} + +func (me *Compliance) IsValid() *AppError { + + if len(me.Id) != 26 { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.id.app_error", nil, "") + } + + if me.CreateAt == 0 { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.create_at.app_error", nil, "") + } + + if len(me.Desc) > 512 || len(me.Desc) == 0 { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.desc.app_error", nil, "") + } + + if me.StartAt == 0 { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.start_at.app_error", nil, "") + } + + if me.EndAt == 0 { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.end_at.app_error", nil, "") + } + + if me.EndAt <= me.StartAt { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.start_end_at.app_error", nil, "") + } + + return nil +} + +func ComplianceFromJson(data io.Reader) *Compliance { + decoder := json.NewDecoder(data) + var o Compliance + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} + +func (o Compliances) ToJson() string { + if b, err := json.Marshal(o); err != nil { + return "[]" + } else { + return string(b) + } +} + +func CompliancesFromJson(data io.Reader) Compliances { + decoder := json.NewDecoder(data) + var o Compliances + err := decoder.Decode(&o) + if err == nil { + return o + } else { + return nil + } +} diff --git a/model/compliance_post.go b/model/compliance_post.go index 636be8f17..ce26a3660 100644 --- a/model/compliance_post.go +++ b/model/compliance_post.go @@ -65,6 +65,17 @@ func CompliancePostHeader() []string { } func (me *CompliancePost) Row() []string { + + postDeleteAt := "" + if me.PostDeleteAt > 0 { + postDeleteAt = time.Unix(0, me.PostDeleteAt*int64(1000*1000)).Format(time.RFC3339) + } + + postUpdateAt := "" + if me.PostUpdateAt != me.PostCreateAt { + postUpdateAt = time.Unix(0, me.PostUpdateAt*int64(1000*1000)).Format(time.RFC3339) + } + return []string{ me.TeamName, me.TeamDisplayName, @@ -77,9 +88,10 @@ func (me *CompliancePost) Row() []string { me.UserNickname, me.PostId, - time.Unix(0, me.PostCreateAt*1000).Format(time.RFC3339), - time.Unix(0, me.PostUpdateAt*1000).Format(time.RFC3339), - time.Unix(0, me.PostDeleteAt*1000).Format(time.RFC3339), + time.Unix(0, me.PostCreateAt*int64(1000*1000)).Format(time.RFC3339), + postUpdateAt, + postDeleteAt, + me.PostRootId, me.PostParentId, me.PostOriginalId, diff --git a/model/compliance_test.go b/model/compliance_test.go new file mode 100644 index 000000000..6acc5a882 --- /dev/null +++ b/model/compliance_test.go @@ -0,0 +1,19 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestCompliance(t *testing.T) { + o := Compliance{Desc: "test", CreateAt: GetMillis()} + json := o.ToJson() + result := ComplianceFromJson(strings.NewReader(json)) + + if o.Desc != result.Desc { + t.Fatal("JobName do not match") + } +} diff --git a/model/config.go b/model/config.go index 82c51224e..8f5865f28 100644 --- a/model/config.go +++ b/model/config.go @@ -170,19 +170,26 @@ type LdapSettings struct { QueryTimeout *int } +type ComplianceSettings struct { + Enable *bool + Directory *string + EnableDaily *bool +} + type Config struct { - ServiceSettings ServiceSettings - TeamSettings TeamSettings - SqlSettings SqlSettings - LogSettings LogSettings - FileSettings FileSettings - EmailSettings EmailSettings - RateLimitSettings RateLimitSettings - PrivacySettings PrivacySettings - SupportSettings SupportSettings - GitLabSettings SSOSettings - GoogleSettings SSOSettings - LdapSettings LdapSettings + ServiceSettings ServiceSettings + TeamSettings TeamSettings + SqlSettings SqlSettings + LogSettings LogSettings + FileSettings FileSettings + EmailSettings EmailSettings + RateLimitSettings RateLimitSettings + PrivacySettings PrivacySettings + SupportSettings SupportSettings + GitLabSettings SSOSettings + GoogleSettings SSOSettings + LdapSettings LdapSettings + ComplianceSettings ComplianceSettings } func (o *Config) ToJson() string { @@ -383,6 +390,21 @@ func (o *Config) SetDefaults() { o.ServiceSettings.AllowCorsFrom = new(string) *o.ServiceSettings.AllowCorsFrom = "" } + + if o.ComplianceSettings.Enable == nil { + o.ComplianceSettings.Enable = new(bool) + *o.ComplianceSettings.Enable = false + } + + if o.ComplianceSettings.Directory == nil { + o.ComplianceSettings.Directory = new(string) + *o.ComplianceSettings.Directory = "./data/" + } + + if o.ComplianceSettings.EnableDaily == nil { + o.ComplianceSettings.EnableDaily = new(bool) + *o.ComplianceSettings.EnableDaily = false + } } func (o *Config) IsValid() *AppError { diff --git a/model/license.go b/model/license.go index ea66fef0d..8461c9f76 100644 --- a/model/license.go +++ b/model/license.go @@ -32,9 +32,10 @@ type Customer struct { } type Features struct { - Users *int `json:"users"` - LDAP *bool `json:"ldap"` - GoogleSSO *bool `json:"google_sso"` + Users *int `json:"users"` + LDAP *bool `json:"ldap"` + GoogleSSO *bool `json:"google_sso"` + Compliance *bool `json:"compliance"` } func (f *Features) SetDefaults() { @@ -52,6 +53,11 @@ func (f *Features) SetDefaults() { f.GoogleSSO = new(bool) *f.GoogleSSO = true } + + if f.Compliance == nil { + f.Compliance = new(bool) + *f.Compliance = true + } } func (l *License) IsExpired() bool { diff --git a/store/sql_audit_store.go b/store/sql_audit_store.go index dbcb9a616..7609ebc25 100644 --- a/store/sql_audit_store.go +++ b/store/sql_audit_store.go @@ -18,8 +18,8 @@ func NewSqlAuditStore(sqlStore *SqlStore) AuditStore { table := db.AddTableWithName(model.Audit{}, "Audits").SetKeys(false, "Id") table.ColMap("Id").SetMaxSize(26) table.ColMap("UserId").SetMaxSize(26) - table.ColMap("Action").SetMaxSize(64) - table.ColMap("ExtraInfo").SetMaxSize(128) + table.ColMap("Action").SetMaxSize(512) + table.ColMap("ExtraInfo").SetMaxSize(1024) table.ColMap("IpAddress").SetMaxSize(64) table.ColMap("SessionId").SetMaxSize(26) } @@ -28,6 +28,17 @@ func NewSqlAuditStore(sqlStore *SqlStore) AuditStore { } func (s SqlAuditStore) UpgradeSchemaIfNeeded() { + // ADDED for 2.2 REMOVE for 2.6 + extraLength := s.GetMaxLengthOfColumnIfExists("Audits", "ExtraInfo") + if len(extraLength) > 0 && extraLength != "1024" { + s.AlterColumnTypeIfExists("Audits", "ExtraInfo", "VARCHAR(1024)", "VARCHAR(1024)") + } + + // ADDED for 2.2 REMOVE for 2.6 + actionLength := s.GetMaxLengthOfColumnIfExists("Audits", "Action") + if len(actionLength) > 0 && actionLength != "512" { + s.AlterColumnTypeIfExists("Audits", "Action", "VARCHAR(512)", "VARCHAR(512)") + } } func (s SqlAuditStore) CreateIndexesIfNotExists() { diff --git a/store/sql_compliance_store.go b/store/sql_compliance_store.go new file mode 100644 index 000000000..57872aef4 --- /dev/null +++ b/store/sql_compliance_store.go @@ -0,0 +1,234 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" + "strconv" + "strings" +) + +type SqlComplianceStore struct { + *SqlStore +} + +func NewSqlComplianceStore(sqlStore *SqlStore) ComplianceStore { + s := &SqlComplianceStore{sqlStore} + + for _, db := range sqlStore.GetAllConns() { + table := db.AddTableWithName(model.Compliance{}, "Compliances").SetKeys(false, "Id") + table.ColMap("Id").SetMaxSize(26) + table.ColMap("UserId").SetMaxSize(26) + table.ColMap("Status").SetMaxSize(64) + table.ColMap("Desc").SetMaxSize(512) + table.ColMap("Type").SetMaxSize(64) + table.ColMap("Keywords").SetMaxSize(512) + table.ColMap("Emails").SetMaxSize(1024) + } + + return s +} + +func (s SqlComplianceStore) UpgradeSchemaIfNeeded() { +} + +func (s SqlComplianceStore) CreateIndexesIfNotExists() { +} + +func (s SqlComplianceStore) Save(compliance *model.Compliance) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + compliance.PreSave() + if result.Err = compliance.IsValid(); result.Err != nil { + storeChannel <- result + close(storeChannel) + return + } + + if err := s.GetMaster().Insert(compliance); err != nil { + result.Err = model.NewLocAppError("SqlComplianceStore.Save", "store.sql_compliance.save.saving.app_error", nil, err.Error()) + } else { + result.Data = compliance + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (us SqlComplianceStore) Update(compliance *model.Compliance) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if result.Err = compliance.IsValid(); result.Err != nil { + storeChannel <- result + close(storeChannel) + return + } + + if _, err := us.GetMaster().Update(compliance); err != nil { + result.Err = model.NewLocAppError("SqlComplianceStore.Update", "store.sql_compliance.save.saving.app_error", nil, err.Error()) + } else { + result.Data = compliance + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlComplianceStore) GetAll() StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + query := "SELECT * FROM Compliances ORDER BY CreateAt DESC" + + var compliances model.Compliances + if _, err := s.GetReplica().Select(&compliances, query); err != nil { + result.Err = model.NewLocAppError("SqlComplianceStore.Get", "store.sql_compliance.get.finding.app_error", nil, err.Error()) + } else { + result.Data = compliances + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (us SqlComplianceStore) Get(id string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if obj, err := us.GetReplica().Get(model.Compliance{}, id); err != nil { + result.Err = model.NewLocAppError("SqlComplianceStore.Get", "store.sql_compliance.get.finding.app_error", nil, err.Error()) + } else if obj == nil { + result.Err = model.NewLocAppError("SqlComplianceStore.Get", "store.sql_compliance.get.finding.app_error", nil, err.Error()) + } else { + result.Data = obj.(*model.Compliance) + } + + storeChannel <- result + close(storeChannel) + + }() + + return storeChannel +} + +func (s SqlComplianceStore) ComplianceExport(job *model.Compliance) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + props := map[string]interface{}{"StartTime": job.StartAt, "EndTime": job.EndAt} + + keywordQuery := "" + keywords := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(job.Keywords, ",", " ", -1)))) + if len(keywords) > 0 { + + keywordQuery = "AND (" + + for index, keyword := range keywords { + if index >= 1 { + keywordQuery += " OR LOWER(Posts.Message) LIKE :Keyword" + strconv.Itoa(index) + } else { + keywordQuery += "LOWER(Posts.Message) LIKE :Keyword" + strconv.Itoa(index) + } + + props["Keyword"+strconv.Itoa(index)] = "%" + keyword + "%" + } + + keywordQuery += ")" + } + + emailQuery := "" + emails := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(job.Emails, ",", " ", -1)))) + if len(emails) > 0 { + + emailQuery = "AND (" + + for index, email := range emails { + if index >= 1 { + emailQuery += " OR Users.Email = :Email" + strconv.Itoa(index) + } else { + emailQuery += "Users.Email = :Email" + strconv.Itoa(index) + } + + props["Email"+strconv.Itoa(index)] = email + } + + emailQuery += ")" + } + + query := + `SELECT + Teams.Name AS TeamName, + Teams.DisplayName AS TeamDisplayName, + Channels.Name AS ChannelName, + Channels.DisplayName AS ChannelDisplayName, + Users.Username AS UserUsername, + Users.Email AS UserEmail, + Users.Nickname AS UserNickname, + Posts.Id AS PostId, + Posts.CreateAt AS PostCreateAt, + Posts.UpdateAt AS PostUpdateAt, + Posts.DeleteAt AS PostDeleteAt, + Posts.RootId AS PostRootId, + Posts.ParentId AS PostParentId, + Posts.OriginalId AS PostOriginalId, + Posts.Message AS PostMessage, + Posts.Type AS PostType, + Posts.Props AS PostProps, + Posts.Hashtags AS PostHashtags, + Posts.Filenames AS PostFilenames + FROM + Teams, + Channels, + Users, + Posts + WHERE + Teams.Id = Channels.TeamId + AND Posts.ChannelId = Channels.Id + AND Posts.UserId = Users.Id + AND Posts.CreateAt > :StartTime + AND Posts.CreateAt <= :EndTime + ` + emailQuery + ` + ` + keywordQuery + ` + ORDER BY Posts.CreateAt + LIMIT 30000` + + var cposts []*model.CompliancePost + + if _, err := s.GetReplica().Select(&cposts, query, props); err != nil { + result.Err = model.NewLocAppError("SqlPostStore.ComplianceExport", "store.sql_post.compliance_export.app_error", nil, err.Error()) + } else { + result.Data = cposts + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_compliance_store_test.go b/store/sql_compliance_store_test.go new file mode 100644 index 000000000..2f3ef3569 --- /dev/null +++ b/store/sql_compliance_store_test.go @@ -0,0 +1,210 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" + "testing" + "time" +) + +func TestSqlComplianceStore(t *testing.T) { + Setup() + + compliance1 := &model.Compliance{Desc: "Desc", UserId: model.NewId(), Status: "TestStatus1", StartAt: model.GetMillis() - 1, EndAt: model.GetMillis() + 1} + Must(store.Compliance().Save(compliance1)) + time.Sleep(100 * time.Millisecond) + + compliance2 := &model.Compliance{Desc: "Desc", UserId: model.NewId(), Status: "TestStatus2", StartAt: model.GetMillis() - 1, EndAt: model.GetMillis() + 1} + Must(store.Compliance().Save(compliance2)) + time.Sleep(100 * time.Millisecond) + + c := store.Compliance().GetAll() + result := <-c + compliances := result.Data.(model.Compliances) + + if compliances[0].Status != "TestStatus2" && compliance2.Id != compliances[0].Id { + t.Fatal() + } + + compliance2.Status = "TestUpdateStatus2" + Must(store.Compliance().Update(compliance2)) + + c = store.Compliance().GetAll() + result = <-c + compliances = result.Data.(model.Compliances) + + if compliances[0].Status != "TestUpdateStatus2" && compliance2.Id != compliances[0].Id { + t.Fatal() + } + + rc2 := (<-store.Compliance().Get(compliance2.Id)).Data.(*model.Compliance) + if rc2.Status != compliance2.Status { + t.Fatal() + } +} + +func TestComplianceExport(t *testing.T) { + Setup() + + time.Sleep(100 * time.Millisecond) + + t1 := &model.Team{} + t1.DisplayName = "DisplayName" + t1.Name = "a" + model.NewId() + "b" + t1.Email = model.NewId() + "@nowhere.com" + t1.Type = model.TEAM_OPEN + t1 = Must(store.Team().Save(t1)).(*model.Team) + + u1 := &model.User{} + u1.TeamId = t1.Id + u1.Email = model.NewId() + u1.Username = model.NewId() + u1 = Must(store.User().Save(u1)).(*model.User) + + u2 := &model.User{} + u2.TeamId = t1.Id + u2.Email = model.NewId() + u2.Username = model.NewId() + u2 = Must(store.User().Save(u2)).(*model.User) + + c1 := &model.Channel{} + c1.TeamId = t1.Id + c1.DisplayName = "Channel2" + c1.Name = "a" + model.NewId() + "b" + c1.Type = model.CHANNEL_OPEN + c1 = Must(store.Channel().Save(c1)).(*model.Channel) + + o1 := &model.Post{} + o1.ChannelId = c1.Id + o1.UserId = u1.Id + o1.CreateAt = model.GetMillis() + o1.Message = "a" + model.NewId() + "b" + o1 = Must(store.Post().Save(o1)).(*model.Post) + + o1a := &model.Post{} + o1a.ChannelId = c1.Id + o1a.UserId = u1.Id + o1a.CreateAt = o1.CreateAt + 10 + o1a.Message = "a" + model.NewId() + "b" + o1a = Must(store.Post().Save(o1a)).(*model.Post) + + o2 := &model.Post{} + o2.ChannelId = c1.Id + o2.UserId = u1.Id + o2.CreateAt = o1.CreateAt + 20 + o2.Message = "a" + model.NewId() + "b" + o2 = Must(store.Post().Save(o2)).(*model.Post) + + o2a := &model.Post{} + o2a.ChannelId = c1.Id + o2a.UserId = u2.Id + o2a.CreateAt = o1.CreateAt + 30 + o2a.Message = "a" + model.NewId() + "b" + o2a = Must(store.Post().Save(o2a)).(*model.Post) + + time.Sleep(100 * time.Millisecond) + + cr1 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1} + if r1 := <-store.Compliance().ComplianceExport(cr1); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 4 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o1.Id { + t.Fatal("Wrong sort") + } + + if cposts[3].PostId != o2a.Id { + t.Fatal("Wrong sort") + } + } + + cr2 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1, Emails: u2.Email} + if r1 := <-store.Compliance().ComplianceExport(cr2); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 1 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o2a.Id { + t.Fatal("Wrong sort") + } + } + + cr3 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1, Emails: u2.Email + ", " + u1.Email} + if r1 := <-store.Compliance().ComplianceExport(cr3); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 4 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o1.Id { + t.Fatal("Wrong sort") + } + + if cposts[3].PostId != o2a.Id { + t.Fatal("Wrong sort") + } + } + + cr4 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1, Keywords: o2a.Message} + if r1 := <-store.Compliance().ComplianceExport(cr4); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 1 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o2a.Id { + t.Fatal("Wrong sort") + } + } + + cr5 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1, Keywords: o2a.Message + " " + o1.Message} + if r1 := <-store.Compliance().ComplianceExport(cr5); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 2 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o1.Id { + t.Fatal("Wrong sort") + } + } + + cr6 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1, Emails: u2.Email + ", " + u1.Email, Keywords: o2a.Message + " " + o1.Message} + if r1 := <-store.Compliance().ComplianceExport(cr6); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 2 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o1.Id { + t.Fatal("Wrong sort") + } + + if cposts[1].PostId != o2a.Id { + t.Fatal("Wrong sort") + } + } +} diff --git a/store/sql_post_store.go b/store/sql_post_store.go index 198347ff2..3346534ab 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -979,59 +979,3 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, mustH return storeChannel } - -func (s SqlPostStore) ComplianceExport(startTime int64, endTime int64) StoreChannel { - storeChannel := make(StoreChannel) - - go func() { - result := StoreResult{} - - query := - `SELECT - Teams.Name AS TeamName, - Teams.DisplayName AS TeamDisplayName, - Channels.Name AS ChannelName, - Channels.DisplayName AS ChannelDisplayName, - Users.Username AS UserUsername, - Users.Email AS UserEmail, - Users.Nickname AS UserNickname, - Posts.Id AS PostId, - Posts.CreateAt AS PostCreateAt, - Posts.UpdateAt AS PostUpdateAt, - Posts.DeleteAt AS PostDeleteAt, - Posts.RootId AS PostRootId, - Posts.ParentId AS PostParentId, - Posts.OriginalId AS PostOriginalId, - Posts.Message AS PostMessage, - Posts.Type AS PostType, - Posts.Props AS PostProps, - Posts.Hashtags AS PostHashtags, - Posts.Filenames AS PostFilenames - FROM - Teams, - Channels, - Users, - Posts - WHERE - Teams.Id = Channels.TeamId - AND Posts.ChannelId = Channels.Id - AND Posts.UserId = Users.Id - AND Posts.CreateAt > :StartTime - AND Posts.CreateAt <= :EndTime - ORDER BY Posts.CreateAt - LIMIT 30000` - - var cposts []*model.CompliancePost - - if _, err := s.GetReplica().Select(&cposts, query, map[string]interface{}{"StartTime": startTime, "EndTime": endTime}); err != nil { - result.Err = model.NewLocAppError("SqlPostStore.ComplianceExport", "store.sql_post.compliance_export.app_error", nil, err.Error()) - } else { - result.Data = cposts - } - - storeChannel <- result - close(storeChannel) - }() - - return storeChannel -} diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go index 512c27ee4..d69f7906c 100644 --- a/store/sql_post_store_test.go +++ b/store/sql_post_store_test.go @@ -895,79 +895,3 @@ func TestPostCountsByDay(t *testing.T) { } } } - -func TestComplianceExport(t *testing.T) { - Setup() - - time.Sleep(100 * time.Millisecond) - - t1 := &model.Team{} - t1.DisplayName = "DisplayName" - t1.Name = "a" + model.NewId() + "b" - t1.Email = model.NewId() + "@nowhere.com" - t1.Type = model.TEAM_OPEN - t1 = Must(store.Team().Save(t1)).(*model.Team) - - u1 := &model.User{} - u1.TeamId = t1.Id - u1.Email = model.NewId() - u1.Username = model.NewId() - u1 = Must(store.User().Save(u1)).(*model.User) - - c1 := &model.Channel{} - c1.TeamId = t1.Id - c1.DisplayName = "Channel2" - c1.Name = "a" + model.NewId() + "b" - c1.Type = model.CHANNEL_OPEN - c1 = Must(store.Channel().Save(c1)).(*model.Channel) - - o1 := &model.Post{} - o1.ChannelId = c1.Id - o1.UserId = u1.Id - o1.CreateAt = model.GetMillis() - o1.Message = "a" + model.NewId() + "b" - o1 = Must(store.Post().Save(o1)).(*model.Post) - - o1a := &model.Post{} - o1a.ChannelId = c1.Id - o1a.UserId = u1.Id - o1a.CreateAt = o1.CreateAt + 10 - o1a.Message = "a" + model.NewId() + "b" - o1a = Must(store.Post().Save(o1a)).(*model.Post) - - o2 := &model.Post{} - o2.ChannelId = c1.Id - o2.UserId = u1.Id - o2.CreateAt = o1.CreateAt + 20 - o2.Message = "a" + model.NewId() + "b" - o2 = Must(store.Post().Save(o2)).(*model.Post) - - o2a := &model.Post{} - o2a.ChannelId = c1.Id - o2a.UserId = u1.Id - o2a.CreateAt = o1.CreateAt + 30 - o2a.Message = "a" + model.NewId() + "b" - o2a = Must(store.Post().Save(o2a)).(*model.Post) - - time.Sleep(100 * time.Millisecond) - - if r1 := <-store.Post().ComplianceExport(o1.CreateAt-1, o2a.CreateAt+1); r1.Err != nil { - t.Fatal(r1.Err) - } else { - cposts := r1.Data.([]*model.CompliancePost) - t.Log(cposts) - - if len(cposts) != 4 { - t.Fatal("return wrong results length") - } - - if cposts[0].PostId != o1.Id { - t.Fatal("Wrong sort") - } - - if cposts[3].PostId != o2a.Id { - t.Fatal("Wrong sort") - } - - } -} diff --git a/store/sql_store.go b/store/sql_store.go index de23f4db3..8ff5da6f7 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -43,6 +43,7 @@ type SqlStore struct { post PostStore user UserStore audit AuditStore + compliance ComplianceStore session SessionStore oauth OAuthStore system SystemStore @@ -98,6 +99,7 @@ func NewSqlStore() Store { sqlStore.post = NewSqlPostStore(sqlStore) sqlStore.user = NewSqlUserStore(sqlStore) sqlStore.audit = NewSqlAuditStore(sqlStore) + sqlStore.compliance = NewSqlComplianceStore(sqlStore) sqlStore.session = NewSqlSessionStore(sqlStore) sqlStore.oauth = NewSqlOAuthStore(sqlStore) sqlStore.system = NewSqlSystemStore(sqlStore) @@ -116,6 +118,7 @@ func NewSqlStore() Store { sqlStore.post.(*SqlPostStore).UpgradeSchemaIfNeeded() sqlStore.user.(*SqlUserStore).UpgradeSchemaIfNeeded() sqlStore.audit.(*SqlAuditStore).UpgradeSchemaIfNeeded() + sqlStore.compliance.(*SqlComplianceStore).UpgradeSchemaIfNeeded() sqlStore.session.(*SqlSessionStore).UpgradeSchemaIfNeeded() sqlStore.oauth.(*SqlOAuthStore).UpgradeSchemaIfNeeded() sqlStore.system.(*SqlSystemStore).UpgradeSchemaIfNeeded() @@ -129,6 +132,7 @@ func NewSqlStore() Store { sqlStore.post.(*SqlPostStore).CreateIndexesIfNotExists() sqlStore.user.(*SqlUserStore).CreateIndexesIfNotExists() sqlStore.audit.(*SqlAuditStore).CreateIndexesIfNotExists() + sqlStore.compliance.(*SqlComplianceStore).CreateIndexesIfNotExists() sqlStore.session.(*SqlSessionStore).CreateIndexesIfNotExists() sqlStore.oauth.(*SqlOAuthStore).CreateIndexesIfNotExists() sqlStore.system.(*SqlSystemStore).CreateIndexesIfNotExists() @@ -591,6 +595,10 @@ func (ss SqlStore) Audit() AuditStore { return ss.audit } +func (ss SqlStore) Compliance() ComplianceStore { + return ss.compliance +} + func (ss SqlStore) OAuth() OAuthStore { return ss.oauth } diff --git a/store/store.go b/store/store.go index 7aef18203..94c426117 100644 --- a/store/store.go +++ b/store/store.go @@ -33,6 +33,7 @@ type Store interface { Post() PostStore User() UserStore Audit() AuditStore + Compliance() ComplianceStore Session() SessionStore OAuth() OAuthStore System() SystemStore @@ -105,7 +106,6 @@ type PostStore interface { AnalyticsUserCountsWithPostsByDay(teamId string) StoreChannel AnalyticsPostCountsByDay(teamId string) StoreChannel AnalyticsPostCount(teamId string, mustHaveFile bool, mustHaveHashtag bool) StoreChannel - ComplianceExport(startTime int64, endTime int64) StoreChannel } type UserStore interface { @@ -152,6 +152,14 @@ type AuditStore interface { PermanentDeleteByUser(userId string) StoreChannel } +type ComplianceStore interface { + Save(compliance *model.Compliance) StoreChannel + Update(compliance *model.Compliance) StoreChannel + Get(id string) StoreChannel + GetAll() StoreChannel + ComplianceExport(compliance *model.Compliance) StoreChannel +} + type OAuthStore interface { SaveApp(app *model.OAuthApp) StoreChannel UpdateApp(app *model.OAuthApp) StoreChannel diff --git a/utils/config.go b/utils/config.go index 63906c345..9624196be 100644 --- a/utils/config.go +++ b/utils/config.go @@ -238,5 +238,7 @@ func getClientConfig(c *model.Config) map[string]string { props["AllowCorsFrom"] = *c.ServiceSettings.AllowCorsFrom + props["EnableCompliance"] = strconv.FormatBool(*c.ComplianceSettings.Enable) + return props } diff --git a/utils/html.go b/utils/html.go index 4203160d5..e89cb12a0 100644 --- a/utils/html.go +++ b/utils/html.go @@ -23,7 +23,16 @@ type HTMLTemplate struct { } func InitHTML() { - templatesDir := FindDir("templates") + InitHTMLWithDir("templates") +} + +func InitHTMLWithDir(dir string) { + + if htmlTemplates != nil { + return + } + + templatesDir := FindDir(dir) l4g.Debug(T("api.api.init.parsing_templates.debug"), templatesDir) var err error if htmlTemplates, err = template.ParseGlob(templatesDir + "*.html"); err != nil { diff --git a/utils/i18n.go b/utils/i18n.go index e809ae883..2503cd500 100644 --- a/utils/i18n.go +++ b/utils/i18n.go @@ -16,7 +16,11 @@ var T i18n.TranslateFunc var locales map[string]string = make(map[string]string) func InitTranslations() { - i18nDirectory := FindDir("i18n") + InitTranslationsWithDir("i18n") +} + +func InitTranslationsWithDir(dir string) { + i18nDirectory := FindDir(dir) files, _ := ioutil.ReadDir(i18nDirectory) for _, f := range files { if filepath.Ext(f.Name()) == ".json" { diff --git a/utils/license.go b/utils/license.go index b1f15ad92..1dc8bf025 100644 --- a/utils/license.go +++ b/utils/license.go @@ -115,6 +115,7 @@ func getClientLicense(l *model.License) map[string]string { props["Users"] = strconv.Itoa(*l.Features.Users) props["LDAP"] = strconv.FormatBool(*l.Features.LDAP) props["GoogleSSO"] = strconv.FormatBool(*l.Features.GoogleSSO) + props["Compliance"] = strconv.FormatBool(*l.Features.Compliance) props["IssuedAt"] = strconv.FormatInt(l.IssuedAt, 10) props["StartsAt"] = strconv.FormatInt(l.StartsAt, 10) props["ExpiresAt"] = strconv.FormatInt(l.ExpiresAt, 10) diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index 4c4f21f08..66b6eb71f 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -22,6 +22,7 @@ import LegalAndSupportSettingsTab from './legal_and_support_settings.jsx'; import TeamUsersTab from './team_users.jsx'; import TeamAnalyticsTab from '../analytics/team_analytics.jsx'; import LdapSettingsTab from './ldap_settings.jsx'; +import ComplianceSettingsTab from './compliance_settings.jsx'; import LicenseSettingsTab from './license_settings.jsx'; import SystemAnalyticsTab from '../analytics/system_analytics.jsx'; @@ -156,6 +157,8 @@ export default class AdminController extends React.Component { tab = ; } else if (this.state.selected === 'ldap_settings') { tab = ; + } else if (this.state.selected === 'compliance_settings') { + tab = ; } else if (this.state.selected === 'license') { tab = ; } else if (this.state.selected === 'team_users') { diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index c2f31f569..b4288d657 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -176,6 +176,7 @@ export default class AdminSidebar extends React.Component { } let ldapSettings; + let complianceSettings; let licenseSettings; if (global.window.mm_config.BuildEnterpriseReady === 'true') { if (global.window.mm_license.IsLicensed === 'true') { @@ -193,6 +194,21 @@ export default class AdminSidebar extends React.Component { ); + + complianceSettings = ( +
  • + + + +
  • + ); } licenseSettings = ( @@ -386,6 +402,7 @@ export default class AdminSidebar extends React.Component { {ldapSettings} + {complianceSettings}
  • - + ); } return ( -
    -

    - -

    - -
    - {content} +
    + + +
    +

    + +

    + +
    + {content} +
    ); diff --git a/web/react/components/admin_console/compliance_reports.jsx b/web/react/components/admin_console/compliance_reports.jsx new file mode 100644 index 000000000..2a94b6f1d --- /dev/null +++ b/web/react/components/admin_console/compliance_reports.jsx @@ -0,0 +1,384 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import LoadingScreen from '../loading_screen.jsx'; +import * as Utils from '../../utils/utils.jsx'; +import AdminStore from '../../stores/admin_store.jsx'; +import UserStore from '../../stores/user_store.jsx'; + +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; + +import {FormattedMessage, FormattedDate, FormattedTime} from 'mm-intl'; + +export default class ComplianceReports extends React.Component { + constructor(props) { + super(props); + + this.onComplianceReportsListenerChange = this.onComplianceReportsListenerChange.bind(this); + this.reload = this.reload.bind(this); + this.runReport = this.runReport.bind(this); + this.getDateTime = this.getDateTime.bind(this); + + this.state = { + reports: AdminStore.getComplianceReports(), + serverError: null + }; + } + + componentDidMount() { + AdminStore.addComplianceReportsChangeListener(this.onComplianceReportsListenerChange); + + if (global.window.mm_license.IsLicensed !== 'true' || global.window.mm_config.EnableCompliance !== 'true') { + return; + } + + AsyncClient.getComplianceReports(); + } + + componentWillUnmount() { + AdminStore.removeComplianceReportsChangeListener(this.onComplianceReportsListenerChange); + } + + onComplianceReportsListenerChange() { + this.setState({ + reports: AdminStore.getComplianceReports() + }); + } + + reload() { + AdminStore.saveComplianceReports(null); + this.setState({ + reports: null, + serverError: null + }); + + AsyncClient.getComplianceReports(); + } + + runReport(e) { + e.preventDefault(); + $('#run-button').button('loading'); + + var job = {}; + job.desc = ReactDOM.findDOMNode(this.refs.desc).value; + job.emails = ReactDOM.findDOMNode(this.refs.emails).value; + job.keywords = ReactDOM.findDOMNode(this.refs.keywords).value; + job.start_at = Date.parse(ReactDOM.findDOMNode(this.refs.from).value); + job.end_at = Date.parse(ReactDOM.findDOMNode(this.refs.to).value); + + Client.saveComplianceReports( + job, + () => { + ReactDOM.findDOMNode(this.refs.emails).value = ''; + ReactDOM.findDOMNode(this.refs.keywords).value = ''; + ReactDOM.findDOMNode(this.refs.desc).value = ''; + ReactDOM.findDOMNode(this.refs.from).value = ''; + ReactDOM.findDOMNode(this.refs.to).value = ''; + this.reload(); + $('#run-button').button('reset'); + }, + (err) => { + this.setState({serverError: err.message}); + $('#run-button').button('reset'); + } + ); + } + + getDateTime(millis) { + const date = new Date(millis); + return ( + + + {' - '} + + + ); + } + + render() { + var content = null; + + if (global.window.mm_license.IsLicensed !== 'true' || global.window.mm_config.EnableCompliance !== 'true') { + return
    ; + } + + if (this.state.reports === null) { + content = ; + } else { + var list = []; + + for (var i = 0; i < this.state.reports.length; i++) { + const report = this.state.reports[i]; + + var params = ''; + if (report.type === 'adhoc') { + params = ( + + {' '}{this.getDateTime(report.start_at)} +
    + {' '}{this.getDateTime(report.end_at)} +
    + {' '}{report.emails} +
    + {' '}{report.keywords} +
    ); + } + + var download = ''; + if (report.status === 'finished') { + download = ( +
    + + + ); + } + + var status = report.status; + if (report.status === 'finished') { + status = ( + {report.status} + ); + } + + if (report.status === 'failed') { + status = ( + {report.status} + ); + } + + var user = report.user_id; + var profile = UserStore.getProfile(report.user_id); + if (profile) { + user = profile.email; + } + + list[i] = ( + + {download} + {this.getDateTime(report.create_at)} + {status} + {report.count} + {report.type} + {report.desc} + {user} + {params} + + ); + } + + content = ( +
    + + + + + + + + + + + + + + + {list} + +
    + + + + + + + + + + + + + +
    +
    + ); + } + + let serverError = ''; + if (this.state.serverError) { + serverError = ( +
    + +
    + ); + } + + return ( +
    +

    + +

    + + + + + + + + + + + + + + +
    + + +
    + + + + + + + + + + + + + +
    + {serverError} +
    + +
    +
    + {content} +
    +
    + ); + } +} diff --git a/web/react/components/admin_console/compliance_settings.jsx b/web/react/components/admin_console/compliance_settings.jsx new file mode 100644 index 000000000..8e6ca6340 --- /dev/null +++ b/web/react/components/admin_console/compliance_settings.jsx @@ -0,0 +1,271 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; + +import {injectIntl, intlShape, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'mm-intl'; + +var holders = defineMessages({ + saving: { + id: 'admin.compliance.saving', + defaultMessage: 'Saving Config...' + }, + directoryExample: { + id: 'admin.compliance.directoryExample', + defaultMessage: 'Ex "./data/"' + } +}); + +class ComplianceSettings extends React.Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleChange = this.handleChange.bind(this); + this.handleEnable = this.handleEnable.bind(this); + this.handleDisable = this.handleDisable.bind(this); + + this.state = { + saveNeeded: false, + serverError: null, + enable: this.props.config.ComplianceSettings.Enable + }; + } + handleChange() { + this.setState({saveNeeded: true}); + } + handleEnable() { + this.setState({saveNeeded: true, enable: true}); + } + handleDisable() { + this.setState({saveNeeded: true, enable: false}); + } + handleSubmit(e) { + e.preventDefault(); + $('#save-button').button('loading'); + + const config = this.props.config; + config.ComplianceSettings.Enable = this.refs.Enable.checked; + config.ComplianceSettings.Directory = ReactDOM.findDOMNode(this.refs.Directory).value; + config.ComplianceSettings.EnableDaily = this.refs.EnableDaily.checked; + + Client.saveConfig( + config, + () => { + AsyncClient.getConfig(); + this.setState({ + serverError: null, + saveNeeded: false + }); + $('#save-button').button('reset'); + }, + (err) => { + this.setState({ + serverError: err.message, + saveNeeded: true + }); + $('#save-button').button('reset'); + } + ); + } + render() { + const {formatMessage} = this.props.intl; + let serverError = ''; + if (this.state.serverError) { + serverError =
    ; + } + + let saveClass = 'btn'; + if (this.state.saveNeeded) { + saveClass = 'btn btn-primary'; + } + + const licenseEnabled = global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.Compliance === 'true'; + + let bannerContent; + if (!licenseEnabled) { + bannerContent = ( +
    +
    + +
    +
    + ); + } + + return ( +
    + {bannerContent} +

    + +

    +
    + +
    + +
    + + +

    + +

    +
    +
    + +
    + +
    + +

    + +

    +
    +
    + +
    + +
    + + +

    + +

    +
    +
    + +
    +
    + {serverError} + +
    +
    +
    +
    + ); + } +} +ComplianceSettings.defaultProps = { +}; + +ComplianceSettings.propTypes = { + intl: intlShape.isRequired, + config: React.PropTypes.object +}; + +export default injectIntl(ComplianceSettings); diff --git a/web/react/components/audit_table.jsx b/web/react/components/audit_table.jsx index 917093840..2465950ce 100644 --- a/web/react/components/audit_table.jsx +++ b/web/react/components/audit_table.jsx @@ -217,7 +217,12 @@ class AuditTable extends React.Component { let uContent; if (this.props.showUserId) { - uContent = {auditInfo.userId}; + var profile = UserStore.getProfile(auditInfo.userId); + if (profile) { + uContent = {profile.email}; + } else { + uContent = {auditInfo.userId}; + } } let iContent; @@ -560,6 +565,8 @@ export function formatAuditInfo(audit, formatMessage) { default: break; } + } else if (actionURL.indexOf('/admin/download_compliance_report') === 0) { + auditDesc = Utils.toTitleCase(audit.extra_info); } else { switch (actionURL) { case '/logout': diff --git a/web/react/stores/admin_store.jsx b/web/react/stores/admin_store.jsx index 9f7f6e7ff..8662723be 100644 --- a/web/react/stores/admin_store.jsx +++ b/web/react/stores/admin_store.jsx @@ -13,6 +13,7 @@ const LOG_CHANGE_EVENT = 'log_change'; const SERVER_AUDIT_CHANGE_EVENT = 'server_audit_change'; const CONFIG_CHANGE_EVENT = 'config_change'; const ALL_TEAMS_EVENT = 'all_team_change'; +const SERVER_COMPLIANCE_REPORT_CHANGE_EVENT = 'server_compliance_reports_change'; class AdminStoreClass extends EventEmitter { constructor() { @@ -22,6 +23,7 @@ class AdminStoreClass extends EventEmitter { this.audits = null; this.config = null; this.teams = null; + this.complianceReports = null; this.emitLogChange = this.emitLogChange.bind(this); this.addLogChangeListener = this.addLogChangeListener.bind(this); @@ -31,6 +33,10 @@ class AdminStoreClass extends EventEmitter { this.addAuditChangeListener = this.addAuditChangeListener.bind(this); this.removeAuditChangeListener = this.removeAuditChangeListener.bind(this); + this.emitComplianceReportsChange = this.emitComplianceReportsChange.bind(this); + this.addComplianceReportsChangeListener = this.addComplianceReportsChangeListener.bind(this); + this.removeComplianceReportsChangeListener = this.removeComplianceReportsChangeListener.bind(this); + this.emitConfigChange = this.emitConfigChange.bind(this); this.addConfigChangeListener = this.addConfigChangeListener.bind(this); this.removeConfigChangeListener = this.removeConfigChangeListener.bind(this); @@ -64,6 +70,18 @@ class AdminStoreClass extends EventEmitter { this.removeListener(SERVER_AUDIT_CHANGE_EVENT, callback); } + emitComplianceReportsChange() { + this.emit(SERVER_COMPLIANCE_REPORT_CHANGE_EVENT); + } + + addComplianceReportsChangeListener(callback) { + this.on(SERVER_COMPLIANCE_REPORT_CHANGE_EVENT, callback); + } + + removeComplianceReportsChangeListener(callback) { + this.removeListener(SERVER_COMPLIANCE_REPORT_CHANGE_EVENT, callback); + } + emitConfigChange() { this.emit(CONFIG_CHANGE_EVENT); } @@ -104,6 +122,14 @@ class AdminStoreClass extends EventEmitter { this.audits = audits; } + getComplianceReports() { + return this.complianceReports; + } + + saveComplianceReports(complianceReports) { + this.complianceReports = complianceReports; + } + getConfig() { return this.config; } @@ -147,6 +173,10 @@ AdminStoreClass.dispatchToken = AppDispatcher.register((payload) => { AdminStore.saveAudits(action.audits); AdminStore.emitAuditChange(); break; + case ActionTypes.RECEIVED_SERVER_COMPLIANCE_REPORTS: + AdminStore.saveComplianceReports(action.complianceReports); + AdminStore.emitComplianceReportsChange(); + break; case ActionTypes.RECEIVED_CONFIG: AdminStore.saveConfig(action.config); AdminStore.emitConfigChange(); diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index b9770a6e9..c565e076a 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -341,6 +341,32 @@ export function getServerAudits() { ); } +export function getComplianceReports() { + if (isCallInProgress('getComplianceReports')) { + return; + } + + callTracker.getComplianceReports = utils.getTimestamp(); + client.getComplianceReports( + (data, textStatus, xhr) => { + callTracker.getComplianceReports = 0; + + if (xhr.status === 304 || !data) { + return; + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_SERVER_COMPLIANCE_REPORTS, + complianceReports: data + }); + }, + (err) => { + callTracker.getComplianceReports = 0; + dispatchError(err, 'getComplianceReports'); + } + ); +} + export function getConfig() { if (isCallInProgress('getConfig')) { return; diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index e00f28a14..5607a4b60 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -412,6 +412,35 @@ export function getAudits(userId, success, error) { }); } +export function getComplianceReports(success, error) { + $.ajax({ + url: '/api/v1/admin/compliance_reports', + dataType: 'json', + contentType: 'application/json', + type: 'GET', + success, + error: function onError(xhr, status, err) { + var e = handleError('getComplianceReports', xhr, status, err); + error(e); + } + }); +} + +export function saveComplianceReports(job, success, error) { + $.ajax({ + url: '/api/v1/admin/save_compliance_report', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(job), + success, + error: (xhr, status, err) => { + var e = handleError('saveComplianceReports', xhr, status, err); + error(e); + } + }); +} + export function getLogs(success, error) { $.ajax({ url: '/api/v1/admin/logs', diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 3de562b7b..3d9bb7317 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -47,6 +47,7 @@ export default { RECEIVED_CONFIG: null, RECEIVED_LOGS: null, RECEIVED_SERVER_AUDITS: null, + RECEIVED_SERVER_COMPLIANCE_REPORTS: null, RECEIVED_ALL_TEAMS: null, RECEIVED_LOCALE: null, diff --git a/web/sass-files/sass/partials/_admin-console.scss b/web/sass-files/sass/partials/_admin-console.scss index 76081710f..4613ff6ee 100644 --- a/web/sass-files/sass/partials/_admin-console.scss +++ b/web/sass-files/sass/partials/_admin-console.scss @@ -125,6 +125,26 @@ background-color: white; } + .compliance__panel { + overflow: scroll; + width: 100%; + height: 400px; + border: 1px solid #ddd; + margin-top: 10px; + padding: 5px; + background-color: white; + } + + .audit__panel { + overflow: scroll; + width: 100%; + height: 400px; + border: 1px solid #ddd; + margin-top: 10px; + padding: 5px; + background-color: white; + } + .app__content { &.admin { overflow: auto; diff --git a/web/static/i18n/en.json b/web/static/i18n/en.json index 2a536925c..ba54fbfbd 100644 --- a/web/static/i18n/en.json +++ b/web/static/i18n/en.json @@ -191,6 +191,40 @@ "admin.ldap.uernameAttrDesc": "The attribute in the LDAP server that will be used to populate the username field in Mattermost. This may be the same as the ID Attribute.", "admin.ldap.usernameAttrEx": "Ex \"sAMAccountName\"", "admin.ldap.usernameAttrTitle": "Username Attribute:", + "admin.compliance.saving": "Saving Config...", + "admin.compliance.directoryExample": "Ex \"./data/\"", + "admin.compliance.noLicense": "

    Note:

    Compliance is an enterprise feature. Your current license does not support Compliance. Click here for information and pricing on enterprise licenses.

    ", + "admin.compliance.title": "Compliance Settings", + "admin.compliance.enableTitle": "Enable Compliance:", + "admin.compliance.true": "true", + "admin.compliance.false": "false", + "admin.compliance.enableDesc": "When true, Mattermost allows compliance reporting", + "admin.compliance.directoryTitle": "Compliance Directory Location:", + "admin.compliance.directoryDescription": "Directory to which compliance reports are written. If blank, will be set to ./data/.", + "admin.compliance.enableDailyTitle": "Enable Daily Report:", + "admin.compliance.enableDesc": "When true, Mattermost will generate a daily compliance report.", + "admin.compliance.save": "Save", + "admin.compliance_reports.from": "From:", + "admin.compliance_reports.to": "To:", + "admin.compliance_reports.emails": "Emails:", + "admin.compliance_reports.keywords": "Keywords:", + "admin.compliance_table.download": "Download", + "admin.compliance_table.timestamp": "Timestamp", + "admin.compliance_table.status": "Status", + "admin.compliance_table.records": "Records", + "admin.compliance_table.type": "Type", + "admin.compliance_table.desc": "Description", + "admin.compliance_table.userId": "Requested By", + "admin.compliance_table.params": "Params", + "admin.compliance_reports.title": "Compliance Reports", + "admin.compliance_reports.desc": "Job Name:", + "admin.compliance_reports.desc_placeholder": "Ex \"Audit 445 for HR\"", + "admin.compliance_reports.from_placeholder": "Ex \"2016-03-11\"", + "admin.compliance_reports.to_placeholder": "Ex \"2016-03-15\"", + "admin.compliance_reports.emails_placeholder": "Ex \"bill@example.com, bob@example.com\"", + "admin.compliance_reports.keywords_placeholder": "Ex \"shorting stock\"", + "admin.compliance_reports.run": "Run", + "admin.compliance_reports.reload": "Reload", "admin.licence.keyMigration": "If you’re migrating servers you may need to remove your license key from this server in order to install it on a new server. To start, disable all Enterprise Edition features on this server. This will enable the ability to remove the license key and downgrade this server from Enterprise Edition to Team Edition.", "admin.license.chooseFile": "Choose File", "admin.license.edition": "Edition: ", @@ -331,6 +365,7 @@ "admin.sidebar.gitlab": "GitLab Settings", "admin.sidebar.ldap": "LDAP Settings", "admin.sidebar.license": "Edition and License", + "admin.sidebar.compliance": "Compliance Settings", "admin.sidebar.loading": "Loading", "admin.sidebar.log": "Log Settings", "admin.sidebar.logs": "Logs", -- cgit v1.2.3-1-g7c22