diff options
37 files changed, 1935 insertions, 84 deletions
diff --git a/.gitignore b/.gitignore index 04e308504..4c343021e 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,5 @@ api/data/* .ctags tags +model/version.go model/version.go.bak diff --git a/api/admin.go b/api/admin.go index feb70aae3..2990691a6 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.Desc) + + 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 a0b50cbf2..1735ca293 100644 --- a/config/config.json +++ b/config/config.json @@ -129,16 +129,21 @@ }, "LdapSettings": { "Enable": false, - "LdapServer": null, + "LdapServer": "", "LdapPort": 389, - "BaseDN": null, - "BindUsername": null, - "BindPassword": null, - "FirstNameAttribute": null, - "LastNameAttribute": null, - "EmailAttribute": null, - "UsernameAttribute": null, - "IdAttribute": null, + "BaseDN": "", + "BindUsername": "", + "BindPassword": "", + "FirstNameAttribute": "", + "LastNameAttribute": "", + "EmailAttribute": "", + "UsernameAttribute": "", + "IdAttribute": "", "QueryTimeout": 60 + }, + "ComplianceSettings": { + "Enable": false, + "Directory": "./data/", + "EnableDaily": false } } diff --git a/einterfaces/compliance.go b/einterfaces/compliance.go new file mode 100644 index 000000000..2e72c67d3 --- /dev/null +++ b/einterfaces/compliance.go @@ -0,0 +1,23 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package einterfaces + +import ( + "github.com/mattermost/platform/model" +) + +type ComplianceInterface interface { + StartComplianceDailyJob() + RunComplianceJob(job *model.Compliance) *model.AppError +} + +var theComplianceInterface ComplianceInterface + +func RegisterComplianceInterface(newInterface ComplianceInterface) { + theComplianceInterface = newInterface +} + +func GetComplianceInterface() ComplianceInterface { + return theComplianceInterface +} diff --git a/i18n/en.json b/i18n/en.json index 25a435580..775e56cc4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1800,6 +1800,26 @@ "translation": "Failed to read security bulletin details" }, { + "id": "ent.compliance.run_started.info", + "translation": "Compliance export started for job '{{.JobName}}' at '{{.FilePath}}'" + }, + { + "id": "ent.compliance.run_failed.error", + "translation": "Compliance export failed for job '{{.JobName}}' at '{{.FilePath}}'" + }, + { + "id": "ent.compliance.run_limit.warning", + "translation": "Compliance export warning for job '{{.JobName}}' too many rows returned truncating to 30,000 at '{{.FilePath}}'" + }, + { + "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" }, @@ -2220,6 +2240,30 @@ "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" }, @@ -2496,6 +2540,14 @@ "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" }, @@ -2796,6 +2848,10 @@ "translation": "We couldn't get post counts" }, { + "id": "store.sql_post.compliance_export.app_error", + "translation": "We couldn't get posts for compliance export" + }, + { "id": "store.sql_post.analytics_posts_count_by_day.app_error", "translation": "We couldn't get post counts by day" }, diff --git a/mattermost.go b/mattermost.go index 45ffcc88f..c555862e9 100644 --- a/mattermost.go +++ b/mattermost.go @@ -19,6 +19,7 @@ import ( l4g "github.com/alecthomas/log4go" "github.com/mattermost/platform/api" + "github.com/mattermost/platform/einterfaces" "github.com/mattermost/platform/manualtesting" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" @@ -69,7 +70,7 @@ func main() { web.InitWeb() if model.BuildEnterpriseReady == "true" { - loadLicense() + api.LoadLicense() } if !utils.IsLicensed && len(utils.Cfg.SqlSettings.DataSourceReplicas) > 1 { @@ -91,6 +92,10 @@ func main() { setDiagnosticId() runSecurityAndDiagnosticsJobAndForget() + if einterfaces.GetComplianceInterface() != nil { + einterfaces.GetComplianceInterface().StartComplianceDailyJob() + } + // wait for kill signal before attempting to gracefully shutdown // the running service c := make(chan os.Signal) @@ -101,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 14c175fc1..68cf11414 100644 --- a/model/client.go +++ b/model/client.go @@ -474,6 +474,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 new file mode 100644 index 000000000..ce26a3660 --- /dev/null +++ b/model/compliance_post.go @@ -0,0 +1,104 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "time" +) + +type CompliancePost struct { + + // From Team + TeamName string + TeamDisplayName string + + // From Channel + ChannelName string + ChannelDisplayName string + + // From User + UserUsername string + UserEmail string + UserNickname string + + // From Post + PostId string + PostCreateAt int64 + PostUpdateAt int64 + PostDeleteAt int64 + PostRootId string + PostParentId string + PostOriginalId string + PostMessage string + PostType string + PostProps string + PostHashtags string + PostFilenames string +} + +func CompliancePostHeader() []string { + return []string{ + "TeamName", + "TeamDisplayName", + + "ChannelName", + "ChannelDisplayName", + + "UserUsername", + "UserEmail", + "UserNickname", + + "PostId", + "PostCreateAt", + "PostUpdateAt", + "PostDeleteAt", + "PostRootId", + "PostParentId", + "PostOriginalId", + "PostMessage", + "PostType", + "PostProps", + "PostHashtags", + "PostFilenames", + } +} + +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, + + me.ChannelName, + me.ChannelDisplayName, + + me.UserUsername, + me.UserEmail, + me.UserNickname, + + me.PostId, + time.Unix(0, me.PostCreateAt*int64(1000*1000)).Format(time.RFC3339), + postUpdateAt, + postDeleteAt, + + me.PostRootId, + me.PostParentId, + me.PostOriginalId, + me.PostMessage, + me.PostType, + me.PostProps, + me.PostHashtags, + me.PostFilenames, + } +} diff --git a/model/compliance_post_test.go b/model/compliance_post_test.go new file mode 100644 index 000000000..28e20ba4b --- /dev/null +++ b/model/compliance_post_test.go @@ -0,0 +1,27 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "testing" +) + +func TestCompliancePostHeader(t *testing.T) { + if CompliancePostHeader()[0] != "TeamName" { + t.Fatal() + } +} + +func TestCompliancePost(t *testing.T) { + o := CompliancePost{TeamName: "test", PostFilenames: "files", PostCreateAt: GetMillis()} + r := o.Row() + + if r[0] != "test" { + t.Fatal() + } + + if r[len(r)-1] != "files" { + t.Fatal() + } +} 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 d684c72b2..3ca241275 100644 --- a/model/config.go +++ b/model/config.go @@ -179,19 +179,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 { @@ -402,6 +409,21 @@ func (o *Config) SetDefaults() { o.ServiceSettings.WebserverMode = new(string) *o.ServiceSettings.WebserverMode = "regular" } + + 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/model/system.go b/model/system.go index b387749f6..68d542c15 100644 --- a/model/system.go +++ b/model/system.go @@ -9,10 +9,11 @@ import ( ) const ( - SYSTEM_DIAGNOSTIC_ID = "DiagnosticId" - SYSTEM_RAN_UNIT_TESTS = "RanUnitTests" - SYSTEM_LAST_SECURITY_TIME = "LastSecurityTime" - SYSTEM_ACTIVE_LICENSE_ID = "ActiveLicenseId" + SYSTEM_DIAGNOSTIC_ID = "DiagnosticId" + SYSTEM_RAN_UNIT_TESTS = "RanUnitTests" + SYSTEM_LAST_SECURITY_TIME = "LastSecurityTime" + SYSTEM_ACTIVE_LICENSE_ID = "ActiveLicenseId" + SYSTEM_LAST_COMPLIANCE_TIME = "LastComplianceTime" ) type System struct { 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..1a41fa389 --- /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: "Audit for federal subpoena case #22443", UserId: model.NewId(), Status: model.COMPLIANCE_STATUS_FAILED, StartAt: model.GetMillis() - 1, EndAt: model.GetMillis() + 1, Type: model.COMPLIANCE_TYPE_ADHOC} + Must(store.Compliance().Save(compliance1)) + time.Sleep(100 * time.Millisecond) + + compliance2 := &model.Compliance{Desc: "Audit for federal subpoena case #11458", UserId: model.NewId(), Status: model.COMPLIANCE_STATUS_RUNNING, StartAt: model.GetMillis() - 1, EndAt: model.GetMillis() + 1, Type: model.COMPLIANCE_TYPE_ADHOC} + 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 != model.COMPLIANCE_STATUS_RUNNING && compliance2.Id != compliances[0].Id { + t.Fatal() + } + + compliance2.Status = model.COMPLIANCE_STATUS_FAILED + Must(store.Compliance().Update(compliance2)) + + c = store.Compliance().GetAll() + result = <-c + compliances = result.Data.(model.Compliances) + + if compliances[0].Status != model.COMPLIANCE_STATUS_FAILED && 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_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 1738ba84e..7ec5ac3a5 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 @@ -153,6 +154,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/webapp/components/admin_console/admin_controller.jsx b/webapp/components/admin_console/admin_controller.jsx index e4a4e28fc..aea2a0197 100644 --- a/webapp/components/admin_console/admin_controller.jsx +++ b/webapp/components/admin_console/admin_controller.jsx @@ -23,6 +23,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'; @@ -159,6 +160,8 @@ export default class AdminController extends React.Component { tab = <LegalAndSupportSettingsTab config={this.state.config}/>; } else if (this.state.selected === 'ldap_settings') { tab = <LdapSettingsTab config={this.state.config}/>; + } else if (this.state.selected === 'compliance_settings') { + tab = <ComplianceSettingsTab config={this.state.config}/>; } else if (this.state.selected === 'license') { tab = <LicenseSettingsTab config={this.state.config}/>; } else if (this.state.selected === 'team_users') { diff --git a/webapp/components/admin_console/admin_sidebar.jsx b/webapp/components/admin_console/admin_sidebar.jsx index 27d4a4112..8ee75e2ef 100644 --- a/webapp/components/admin_console/admin_sidebar.jsx +++ b/webapp/components/admin_console/admin_sidebar.jsx @@ -171,6 +171,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') { @@ -188,6 +189,21 @@ export default class AdminSidebar extends React.Component { </a> </li> ); + + complianceSettings = ( + <li> + <a + href='#' + className={this.isSelected('compliance_settings')} + onClick={this.handleClick.bind(this, 'compliance_settings', null)} + > + <FormattedMessage + id='admin.sidebar.compliance' + defaultMessage='Compliance Settings' + /> + </a> + </li> + ); } licenseSettings = ( @@ -381,6 +397,7 @@ export default class AdminSidebar extends React.Component { </a> </li> {ldapSettings} + {complianceSettings} <li> <a href='#' diff --git a/webapp/components/admin_console/audits.jsx b/webapp/components/admin_console/audits.jsx index 28503d783..1f94de7da 100644 --- a/webapp/components/admin_console/audits.jsx +++ b/webapp/components/admin_console/audits.jsx @@ -3,6 +3,7 @@ import LoadingScreen from '../loading_screen.jsx'; import AuditTable from '../audit_table.jsx'; +import ComplianceReports from './compliance_reports.jsx'; import AdminStore from 'stores/admin_store.jsx'; @@ -60,36 +61,40 @@ export default class Audits extends React.Component { } else { content = ( <div style={{margin: '10px'}}> - <AuditTable - audits={this.state.audits} - showUserId={true} - showIp={true} - showSession={true} - /> + <AuditTable + audits={this.state.audits} + showUserId={true} + showIp={true} + showSession={true} + /> </div> ); } return ( - <div className='panel'> - <h3> - <FormattedMessage - id='admin.audits.title' - defaultMessage='User Activity' - /> - </h3> - <button - type='submit' - className='btn btn-primary' - onClick={this.reload} - > - <FormattedMessage - id='admin.audits.reload' - defaultMessage='Reload' - /> - </button> - <div className='log__panel'> - {content} + <div> + <ComplianceReports/> + + <div className='panel'> + <h3> + <FormattedMessage + id='admin.audits.title' + defaultMessage='User Activity' + /> + </h3> + <button + type='submit' + className='btn btn-primary' + onClick={this.reload} + > + <FormattedMessage + id='admin.audits.reload' + defaultMessage='Reload' + /> + </button> + <div className='audit__panel'> + {content} + </div> </div> </div> ); diff --git a/webapp/components/admin_console/compliance_reports.jsx b/webapp/components/admin_console/compliance_reports.jsx new file mode 100644 index 000000000..84def2bce --- /dev/null +++ b/webapp/components/admin_console/compliance_reports.jsx @@ -0,0 +1,388 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +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 'react-intl'; + +import React from 'react'; +import ReactDOM from 'react-dom'; + +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 ( + <span style={{whiteSpace: 'nowrap'}}> + <FormattedDate + value={date} + day='2-digit' + month='short' + year='numeric' + /> + {' - '} + <FormattedTime + value={date} + hour='2-digit' + minute='2-digit' + /> + </span> + ); + } + + render() { + var content = null; + + if (global.window.mm_license.IsLicensed !== 'true' || global.window.mm_config.EnableCompliance !== 'true') { + return <div/>; + } + + if (this.state.reports === null) { + content = <LoadingScreen/>; + } 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 = ( + <span> + <FormattedMessage + id='admin.compliance_reports.from' + defaultMessage='From:' + />{' '}{this.getDateTime(report.start_at)} + <br/> + <FormattedMessage + id='admin.compliance_reports.to' + defaultMessage='To:' + />{' '}{this.getDateTime(report.end_at)} + <br/> + <FormattedMessage + id='admin.compliance_reports.emails' + defaultMessage='Emails:' + />{' '}{report.emails} + <br/> + <FormattedMessage + id='admin.compliance_reports.keywords' + defaultMessage='Keywords:' + />{' '}{report.keywords} + </span>); + } + + var download = ''; + if (report.status === 'finished') { + download = ( + <a href={'/api/v1/admin/download_compliance_report/' + report.id}> + <FormattedMessage + id='admin.compliance_table.download' + defaultMessage='Download' + /> + </a> + ); + } + + var status = report.status; + if (report.status === 'finished') { + status = ( + <span style={{color: 'green'}}>{report.status}</span> + ); + } + + if (report.status === 'failed') { + status = ( + <span style={{color: 'red'}}>{report.status}</span> + ); + } + + var user = report.user_id; + var profile = UserStore.getProfile(report.user_id); + if (profile) { + user = profile.email; + } + + list[i] = ( + <tr key={report.id}> + <td style={{whiteSpace: 'nowrap'}}>{download}</td> + <td>{this.getDateTime(report.create_at)}</td> + <td>{status}</td> + <td>{report.count}</td> + <td>{report.type}</td> + <td style={{whiteSpace: 'nowrap'}}>{report.desc}</td> + <td>{user}</td> + <td style={{whiteSpace: 'nowrap'}}>{params}</td> + </tr> + ); + } + + content = ( + <div style={{margin: '10px'}}> + <table className='table'> + <thead> + <tr> + <th></th> + <th> + <FormattedMessage + id='admin.compliance_table.timestamp' + defaultMessage='Timestamp' + /> + </th> + <th> + <FormattedMessage + id='admin.compliance_table.status' + defaultMessage='Status' + /> + </th> + <th> + <FormattedMessage + id='admin.compliance_table.records' + defaultMessage='Records' + /> + </th> + <th> + <FormattedMessage + id='admin.compliance_table.type' + defaultMessage='Type' + /> + </th> + <th> + <FormattedMessage + id='admin.compliance_table.desc' + defaultMessage='Description' + /> + </th> + <th> + <FormattedMessage + id='admin.compliance_table.userId' + defaultMessage='Requested By' + /> + </th> + <th> + <FormattedMessage + id='admin.compliance_table.params' + defaultMessage='Params' + /> + </th> + </tr> + </thead> + <tbody> + {list} + </tbody> + </table> + </div> + ); + } + + let serverError = ''; + if (this.state.serverError) { + serverError = ( + <div + className='form-group has-error' + style={{marginTop: '10px'}} + > + <label className='control-label'>{this.state.serverError}</label> + </div> + ); + } + + return ( + <div className='panel'> + <h3> + <FormattedMessage + id='admin.compliance_reports.title' + defaultMessage='Compliance Reports' + /> + </h3> + + <table> + <tbody> + <tr> + <td colSpan='5' + style={{paddingBottom: '6px'}} + > + <FormattedMessage + id='admin.compliance_reports.desc' + defaultMessage='Job Name:' + /> + <input + style={{width: '425px'}} + type='text' + className='form-control' + id='desc' + ref='desc' + placeholder={Utils.localizeMessage('admin.compliance_reports.desc_placeholder', 'Ex "Audit 445 for HR"')} + /> + </td> + </tr> + <tr> + <td> + <FormattedMessage + id='admin.compliance_reports.from' + defaultMessage='From:' + /> + <input + type='text' + className='form-control' + id='from' + ref='from' + placeholder={Utils.localizeMessage('admin.compliance_reports.from_placeholder', 'Ex "2016-03-11"')} + /> + </td> + <td style={{paddingLeft: '4px'}}> + <FormattedMessage + id='admin.compliance_reports.to' + defaultMessage='To:' + /> + <input + type='text' + className='form-control' + id='to' + ref='to' + placeholder={Utils.localizeMessage('admin.compliance_reports.to_placeholder', 'Ex "2016-03-15"')} + /> + </td> + <td style={{paddingLeft: '4px'}}> + <FormattedMessage + id='admin.compliance_reports.emails' + defaultMessage='Emails:' + /> + <input + style={{width: '325px'}} + type='text' + className='form-control' + id='emails' + ref='emails' + placeholder={Utils.localizeMessage('admin.compliance_reports.emails_placeholder', 'Ex "bill@example.com, bob@example.com"')} + /> + </td> + <td style={{paddingLeft: '4px'}}> + <FormattedMessage + id='admin.compliance_reports.keywords' + defaultMessage='Keywords:' + /> + <input + style={{width: '250px'}} + type='text' + className='form-control' + id='keywords' + ref='keywords' + placeholder={Utils.localizeMessage('admin.compliance_reports.keywords_placeholder', 'Ex "shorting stock"')} + /> + </td> + <td> + <button + id='run-button' + type='submit' + className='btn btn-primary' + onClick={this.runReport} + style={{marginTop: '20px', marginLeft: '20px'}} + > + <FormattedMessage + id='admin.compliance_reports.run' + defaultMessage='Run' + /> + </button> + </td> + </tr> + </tbody> + </table> + {serverError} + <div style={{marginTop: '20px'}}> + <button + type='submit' + className='btn btn-primary' + onClick={this.reload} + > + <FormattedMessage + id='admin.compliance_reports.reload' + defaultMessage='Reload' + /> + </button> + </div> + <div className='compliance__panel'> + {content} + </div> + </div> + ); + } +} diff --git a/webapp/components/admin_console/compliance_settings.jsx b/webapp/components/admin_console/compliance_settings.jsx new file mode 100644 index 000000000..fb2ae26f9 --- /dev/null +++ b/webapp/components/admin_console/compliance_settings.jsx @@ -0,0 +1,260 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; +import * as Utils from '../../utils/utils.jsx'; + +import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; + +import React from 'react'; +import ReactDOM from 'react-dom'; + +export default 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() { + let serverError = ''; + if (this.state.serverError) { + serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>; + } + + 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 = ( + <div className='banner warning'> + <div className='banner__content'> + <FormattedHTMLMessage + id='admin.compliance.noLicense' + defaultMessage='<h4 class="banner__heading">Note:</h4><p>Compliance is an enterprise feature. Your current license does not support Compliance. Click <a href="http://mattermost.com"target="_blank">here</a> for information and pricing on enterprise licenses.</p>' + /> + </div> + </div> + ); + } + + return ( + <div className='wrapper--fixed'> + {bannerContent} + <h3> + <FormattedMessage + id='admin.compliance.title' + defaultMessage='Compliance Settings' + /> + </h3> + <form + className='form-horizontal' + role='form' + > + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='Enable' + > + <FormattedMessage + id='admin.compliance.enableTitle' + defaultMessage='Enable Compliance:' + /> + </label> + <div className='col-sm-8'> + <label className='radio-inline'> + <input + type='radio' + name='Enable' + value='true' + ref='Enable' + defaultChecked={this.props.config.ComplianceSettings.Enable} + onChange={this.handleEnable} + disabled={!licenseEnabled} + /> + <FormattedMessage + id='admin.compliance.true' + defaultMessage='true' + /> + </label> + <label className='radio-inline'> + <input + type='radio' + name='Enable' + value='false' + defaultChecked={!this.props.config.ComplianceSettings.Enable} + onChange={this.handleDisable} + /> + <FormattedMessage + id='admin.compliance.false' + defaultMessage='false' + /> + </label> + <p className='help-text'> + <FormattedMessage + id='admin.compliance.enableDesc' + defaultMessage='When true, Mattermost allows compliance reporting' + /> + </p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='Directory' + > + <FormattedMessage + id='admin.compliance.directoryTitle' + defaultMessage='Compliance Directory Location:' + /> + </label> + <div className='col-sm-8'> + <input + type='text' + className='form-control' + id='Directory' + ref='Directory' + placeholder={Utils.localizeMessage('admin.compliance.directoryExample', 'Ex "./data/"')} + defaultValue={this.props.config.ComplianceSettings.Directory} + onChange={this.handleChange} + disabled={!this.state.enable} + /> + <p className='help-text'> + <FormattedMessage + id='admin.compliance.directoryDescription' + defaultMessage='Directory to which compliance reports are written. If blank, will be set to ./data/.' + /> + </p> + </div> + </div> + + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='EnableDaily' + > + <FormattedMessage + id='admin.compliance.enableDailyTitle' + defaultMessage='Enable Daily Report:' + /> + </label> + <div className='col-sm-8'> + <label className='radio-inline'> + <input + type='radio' + name='EnableDaily' + value='true' + ref='EnableDaily' + defaultChecked={this.props.config.ComplianceSettings.EnableDaily} + onChange={this.handleChange} + disabled={!this.state.enable} + /> + <FormattedMessage + id='admin.compliance.true' + defaultMessage='true' + /> + </label> + <label className='radio-inline'> + <input + type='radio' + name='EnableDaily' + value='false' + defaultChecked={!this.props.config.ComplianceSettings.EnableDaily} + disabled={!this.state.enable} + /> + <FormattedMessage + id='admin.compliance.false' + defaultMessage='false' + /> + </label> + <p className='help-text'> + <FormattedMessage + id='admin.compliance.enableDesc' + defaultMessage='When true, Mattermost will generate a daily compliance report.' + /> + </p> + </div> + </div> + + <div className='form-group'> + <div className='col-sm-12'> + {serverError} + <button + disabled={!this.state.saveNeeded} + type='submit' + className={saveClass} + onClick={this.handleSubmit} + id='save-button' + data-loading-text={'<span class=\'glyphicon glyphicon-refresh glyphicon-refresh-animate\'></span> ' + Utils.localizeMessage('admin.compliance.saving', 'Saving Config...')} + > + <FormattedMessage + id='admin.compliance.save' + defaultMessage='Save' + /> + </button> + </div> + </div> + </form> + </div> + ); + } +} + +ComplianceSettings.propTypes = { + config: React.PropTypes.object +}; + diff --git a/webapp/components/audit_table.jsx b/webapp/components/audit_table.jsx index 73dcfccc3..abf09dfaf 100644 --- a/webapp/components/audit_table.jsx +++ b/webapp/components/audit_table.jsx @@ -219,7 +219,12 @@ class AuditTable extends React.Component { let uContent; if (this.props.showUserId) { - uContent = <td>{auditInfo.userId}</td>; + var profile = UserStore.getProfile(auditInfo.userId); + if (profile) { + uContent = <td>{profile.email}</td>; + } else { + uContent = <td>{auditInfo.userId}</td>; + } } let iContent; @@ -562,6 +567,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/webapp/i18n/en.json b/webapp/i18n/en.json index dc43cc019..9a9477557 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -195,6 +195,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": "<h4 class=\"banner__heading\">Note:</h4><p>Compliance is an enterprise feature. Your current license does not support Compliance. Click <a href=\"http://mattermost.com\" target=\"_blank\">here</a> for information and pricing on enterprise licenses.</p>", + "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, <a href=\"http://mattermost.com\" target=\"_blank\">disable all Enterprise Edition features on this server</a>. 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: ", @@ -335,6 +369,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", diff --git a/webapp/sass/routes/_admin-console.scss b/webapp/sass/routes/_admin-console.scss index 63cf8eb13..0b47e5ab6 100644 --- a/webapp/sass/routes/_admin-console.scss +++ b/webapp/sass/routes/_admin-console.scss @@ -175,6 +175,26 @@ width: 100%; } + .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 { color: #333; diff --git a/webapp/stores/admin_store.jsx b/webapp/stores/admin_store.jsx index 0a4c8c442..0f19dd484 100644 --- a/webapp/stores/admin_store.jsx +++ b/webapp/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/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx index 9a5869f9a..2392b50b9 100644 --- a/webapp/utils/async_client.jsx +++ b/webapp/utils/async_client.jsx @@ -342,6 +342,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/webapp/utils/client.jsx b/webapp/utils/client.jsx index ef6d496a2..69bda4303 100644 --- a/webapp/utils/client.jsx +++ b/webapp/utils/client.jsx @@ -413,6 +413,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/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index 29178aca6..4ee934e11 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -76,6 +76,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, |