From 4874d23f15921008dc6cf428611436b8e8faaf4d Mon Sep 17 00:00:00 2001 From: Harvey Kandola Date: Tue, 19 Sep 2017 17:58:33 +0100 Subject: [PATCH] space categorty management --- core/database/scripts/autobuild/db_00016.sql | 4 + domain/category/endpoint.go | 285 ++++++++++++++++++ domain/category/mysql/store.go | 201 ++++++++++++ domain/permission/mysql/store.go | 21 ++ domain/permission/permission.go | 31 +- domain/space/endpoint.go | 36 ++- domain/storer.go | 18 ++ domain/template/endpoint.go | 18 +- edition/boot/mysql.go | 2 + gui/app/components/folder/category-admin.js | 91 ++++++ gui/app/models/category.js | 21 ++ .../folder/settings/category/template.hbs | 2 +- gui/app/services/category.js | 87 ++++++ gui/app/styles/view/folder/settings.scss | 37 ++- .../components/folder/category-admin.hbs | 53 ++++ model/audit/audit.go | 5 + model/category/category.go | 31 ++ .../{permissions.go => permission.go} | 4 +- server/routing/routes.go | 8 + 19 files changed, 915 insertions(+), 40 deletions(-) create mode 100644 domain/category/endpoint.go create mode 100644 domain/category/mysql/store.go create mode 100644 gui/app/components/folder/category-admin.js create mode 100644 gui/app/models/category.js create mode 100644 gui/app/services/category.js create mode 100644 gui/app/templates/components/folder/category-admin.hbs create mode 100644 model/category/category.go rename model/permission/{permissions.go => permission.go} (97%) diff --git a/core/database/scripts/autobuild/db_00016.sql b/core/database/scripts/autobuild/db_00016.sql index 5a00f7a9..f2e8ee41 100644 --- a/core/database/scripts/autobuild/db_00016.sql +++ b/core/database/scripts/autobuild/db_00016.sql @@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS `category` ( `labelid` CHAR(16) NOT NULL COLLATE utf8_bin, `category` VARCHAR(30) NOT NULL, `created` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `revised` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE INDEX `idx_category_id` (`id` ASC), INDEX `idx_category_refid` (`refid` ASC), INDEX `idx_category_orgid` (`orgid` ASC)) @@ -47,8 +48,11 @@ CREATE TABLE IF NOT EXISTS `categorymember` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `refid` CHAR(16) NOT NULL COLLATE utf8_bin, `orgid` CHAR(16) NOT NULL COLLATE utf8_bin, + `labelid` CHAR(16) NOT NULL COLLATE utf8_bin, `categoryid` CHAR(16) NOT NULL COLLATE utf8_bin, `documentid` CHAR(16) NOT NULL COLLATE utf8_bin, + `created` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `revised` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE INDEX `idx_categorymember_id` (`id` ASC), INDEX `idx_category_documentid` (`documentid`)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci diff --git a/domain/category/endpoint.go b/domain/category/endpoint.go new file mode 100644 index 00000000..ef54ab40 --- /dev/null +++ b/domain/category/endpoint.go @@ -0,0 +1,285 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +// Package category handles API calls and persistence for categories. +// Categories sub-divide spaces. +package category + +import ( + "database/sql" + "encoding/json" + "io/ioutil" + "net/http" + + "github.com/documize/community/core/env" + "github.com/documize/community/core/request" + "github.com/documize/community/core/response" + "github.com/documize/community/core/uniqueid" + "github.com/documize/community/domain" + "github.com/documize/community/domain/permission" + "github.com/documize/community/model/audit" + "github.com/documize/community/model/category" + pm "github.com/documize/community/model/permission" +) + +// Handler contains the runtime information such as logging and database. +type Handler struct { + Runtime *env.Runtime + Store *domain.Store +} + +// Add saves space category. +func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { + method := "category.add" + ctx := domain.GetRequestContext(r) + + if !ctx.Authenticated { + response.WriteForbiddenError(w) + return + } + + defer r.Body.Close() + body, err := ioutil.ReadAll(r.Body) + if err != nil { + response.WriteBadRequestError(w, method, "body") + h.Runtime.Log.Error(method, err) + return + } + + var cat category.Category + err = json.Unmarshal(body, &cat) + if err != nil { + response.WriteBadRequestError(w, method, "category") + h.Runtime.Log.Error(method, err) + return + } + + cat.RefID = uniqueid.Generate() + cat.OrgID = ctx.OrgID + + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + err = h.Store.Category.Add(ctx, cat) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + h.Store.Audit.Record(ctx, audit.EventTypeCategoryAdd) + + ctx.Transaction.Commit() + + cat, err = h.Store.Category.Get(ctx, cat.RefID) + if err != nil { + response.WriteServerError(w, method, err) + return + } + + response.WriteJSON(w, cat) +} + +// Get returns categories visible to user within a space. +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + method := "category.get" + ctx := domain.GetRequestContext(r) + + spaceID := request.Param(r, "spaceID") + if len(spaceID) == 0 { + response.WriteMissingDataError(w, method, "spaceID") + return + } + + ok := permission.HasPermission(ctx, *h.Store, spaceID, + pm.SpaceManage, pm.SpaceOwner, pm.SpaceView) + if !ok { + response.WriteForbiddenError(w) + return + } + + cat, err := h.Store.Category.GetBySpace(ctx, spaceID) + if err != nil && err != sql.ErrNoRows { + response.WriteServerError(w, method, err) + return + } + + if len(cat) == 0 { + cat = []category.Category{} + } + + response.WriteJSON(w, cat) +} + +// GetAll returns categories within a space, disregarding permissions. +// Used in admin screens, lists, functions. +func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request) { + method := "category.getAll" + ctx := domain.GetRequestContext(r) + + spaceID := request.Param(r, "spaceID") + if len(spaceID) == 0 { + response.WriteMissingDataError(w, method, "spaceID") + return + } + + cat, err := h.Store.Category.GetAllBySpace(ctx, spaceID) + if err != nil && err != sql.ErrNoRows { + response.WriteServerError(w, method, err) + return + } + + if len(cat) == 0 { + cat = []category.Category{} + } + + response.WriteJSON(w, cat) +} + +// Update saves existing space category. +func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + method := "category.update" + ctx := domain.GetRequestContext(r) + + categoryID := request.Param(r, "categoryID") + if len(categoryID) == 0 { + response.WriteMissingDataError(w, method, "categoryID") + return + } + + defer r.Body.Close() + body, err := ioutil.ReadAll(r.Body) + if err != nil { + response.WriteBadRequestError(w, method, "body") + h.Runtime.Log.Error(method, err) + return + } + + var cat category.Category + err = json.Unmarshal(body, &cat) + if err != nil { + response.WriteBadRequestError(w, method, "category") + h.Runtime.Log.Error(method, err) + return + } + + cat.OrgID = ctx.OrgID + cat.RefID = categoryID + + ok := permission.HasPermission(ctx, *h.Store, cat.LabelID, pm.SpaceManage, pm.SpaceOwner) + if !ok || !ctx.Authenticated { + response.WriteForbiddenError(w) + return + } + + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + err = h.Store.Category.Update(ctx, cat) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + h.Store.Audit.Record(ctx, audit.EventTypeCategoryUpdate) + + ctx.Transaction.Commit() + + cat, err = h.Store.Category.Get(ctx, cat.RefID) + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + response.WriteJSON(w, cat) +} + +// Delete removes category and associated member records. +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + method := "category.delete" + ctx := domain.GetRequestContext(r) + + catID := request.Param(r, "categoryID") + if len(catID) == 0 { + response.WriteMissingDataError(w, method, "categoryID") + return + } + + cat, err := h.Store.Category.Get(ctx, catID) + if err != nil { + response.WriteServerError(w, method, err) + return + } + + ok := permission.HasPermission(ctx, *h.Store, cat.LabelID, pm.SpaceManage, pm.SpaceOwner) + if !ok || !ctx.Authenticated { + response.WriteForbiddenError(w) + return + } + + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + // remove category members + _, err = h.Store.Category.RemoveCategoryMembership(ctx, cat.RefID) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + // remove category permissions + _, err = h.Store.Permission.DeleteCategoryPermissions(ctx, cat.RefID) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + // remove category + _, err = h.Store.Category.Delete(ctx, cat.RefID) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + h.Store.Audit.Record(ctx, audit.EventTypeCategoryDelete) + + ctx.Transaction.Commit() + + response.WriteEmpty(w) +} + +/* + 6. add category view permission !!! + 7. link/unlink document to category + 8. filter space documents by category -- URL param? nested route? +*/ diff --git a/domain/category/mysql/store.go b/domain/category/mysql/store.go new file mode 100644 index 00000000..5c8934d5 --- /dev/null +++ b/domain/category/mysql/store.go @@ -0,0 +1,201 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +// Package mysql handles data persistence for both category definition +// and and document/category association. +package mysql + +import ( + "database/sql" + "fmt" + "time" + + "github.com/documize/community/core/env" + "github.com/documize/community/core/streamutil" + "github.com/documize/community/domain" + "github.com/documize/community/domain/store/mysql" + "github.com/documize/community/model/category" + "github.com/pkg/errors" +) + +// Scope provides data access to MySQL. +type Scope struct { + Runtime *env.Runtime +} + +// Add inserts the given record into the category table. +func (s Scope) Add(ctx domain.RequestContext, c category.Category) (err error) { + c.Created = time.Now().UTC() + c.Revised = time.Now().UTC() + + stmt, err := ctx.Transaction.Preparex("INSERT INTO category (refid, orgid, labelid, category, created, revised) VALUES (?, ?, ?, ?, ?, ?)") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, "unable to prepare insert category") + return + } + + _, err = stmt.Exec(c.RefID, c.OrgID, c.LabelID, c.Category, c.Created, c.Revised) + if err != nil { + err = errors.Wrap(err, "unable to execute insert category") + return + } + + return +} + +// GetBySpace returns space categories for a user. +// Context is used to for user ID. +func (s Scope) GetBySpace(ctx domain.RequestContext, spaceID string) (c []category.Category, err error) { + err = s.Runtime.Db.Select(&c, ` + SELECT id, refid, orgid, labelid, category, created, revised FROM category + WHERE orgid=? AND labelid=? + AND refid IN (SELECT refid FROM permission WHERE orgid=? AND location='category' AND refid IN ( + SELECT refid from permission WHERE orgid=? AND who='user' AND whoid=? AND location='category' UNION ALL + SELECT p.refid from permission p LEFT JOIN rolemember r ON p.whoid=r.roleid WHERE p.orgid=? AND p.who='role' AND p.location='category' + AND p.action='view' AND r.userid=? + )) + ORDER BY category`, ctx.OrgID, spaceID, ctx.OrgID, ctx.OrgID, ctx.UserID, ctx.OrgID, ctx.UserID) + + if err == sql.ErrNoRows { + err = nil + } + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute select categories for space %s", spaceID)) + return + } + + return +} + +// GetAllBySpace returns all space categories. +func (s Scope) GetAllBySpace(ctx domain.RequestContext, spaceID string) (c []category.Category, err error) { + err = s.Runtime.Db.Select(&c, ` + SELECT id, refid, orgid, labelid, category, created, revised FROM category + WHERE orgid=? AND labelid=? + AND labelid IN (SELECT refid FROM permission WHERE orgid=? AND location='space' AND refid IN ( + SELECT refid from permission WHERE orgid=? AND who='user' AND whoid=? AND location='space' UNION ALL + SELECT p.refid from permission p LEFT JOIN rolemember r ON p.whoid=r.roleid WHERE p.orgid=? AND p.who='role' AND p.location='space' + AND p.action='view' AND r.userid=? + )) + ORDER BY category`, ctx.OrgID, spaceID, ctx.OrgID, ctx.OrgID, ctx.UserID, ctx.OrgID, ctx.UserID) + + if err == sql.ErrNoRows { + err = nil + } + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute select all categories for space %s", spaceID)) + return + } + + return +} + +// Update saves category name change. +func (s Scope) Update(ctx domain.RequestContext, c category.Category) (err error) { + c.Revised = time.Now().UTC() + + stmt, err := ctx.Transaction.PrepareNamed("UPDATE category SET category=:category, revised=:revised WHERE orgid=:orgid AND refid=:refid") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare update for category %s", c.RefID)) + return + } + + _, err = stmt.Exec(&c) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute update for category %s", c.RefID)) + return + } + + return +} + +// Get returns specified category +func (s Scope) Get(ctx domain.RequestContext, id string) (c category.Category, err error) { + stmt, err := s.Runtime.Db.Preparex("SELECT id, refid, orgid, labelid, category, created, revised FROM category WHERE orgid=? AND refid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare select for category %s", id)) + return + } + + err = stmt.Get(&c, ctx.OrgID, id) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to get category %s", id)) + return + } + + return +} + +// Delete removes category from the store. +func (s Scope) Delete(ctx domain.RequestContext, id string) (rows int64, err error) { + b := mysql.BaseQuery{} + return b.DeleteConstrained(ctx.Transaction, "category", ctx.OrgID, id) +} + +// AssociateDocument inserts category membership record into the category member table. +func (s Scope) AssociateDocument(ctx domain.RequestContext, m category.Member) (err error) { + m.Created = time.Now().UTC() + m.Revised = time.Now().UTC() + + stmt, err := ctx.Transaction.Preparex("INSERT INTO categorymember (refid, orgid, categoryid, labelid, documentid, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?)") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, "unable to prepare insert categorymember") + return + } + + _, err = stmt.Exec(m.RefID, m.OrgID, m.CategoryID, m.LabelID, m.DocumentID, m.Created, m.Revised) + if err != nil { + err = errors.Wrap(err, "unable to execute insert categorymember") + return + } + + return +} + +// DisassociateDocument removes document associatation from category. +func (s Scope) DisassociateDocument(ctx domain.RequestContext, categoryID, documentID string) (rows int64, err error) { + b := mysql.BaseQuery{} + + sql := fmt.Sprintf("DELETE FROM categorymember WHERE orgid='%s' AND categoryid='%s' AND documentid='%s'", + ctx.OrgID, categoryID, documentID) + + return b.DeleteWhere(ctx.Transaction, sql) +} + +// RemoveCategoryMembership removes all category associations from the store. +func (s Scope) RemoveCategoryMembership(ctx domain.RequestContext, categoryID string) (rows int64, err error) { + b := mysql.BaseQuery{} + + sql := fmt.Sprintf("DELETE FROM categorymember WHERE orgid='%s' AND categoryid='%s'", + ctx.OrgID, categoryID) + + return b.DeleteWhere(ctx.Transaction, sql) +} + +// DeleteBySpace removes all category and category associations for given space. +func (s Scope) DeleteBySpace(ctx domain.RequestContext, spaceID string) (rows int64, err error) { + b := mysql.BaseQuery{} + + s1 := fmt.Sprintf("DELETE FROM categorymember WHERE orgid='%s' AND labelid='%s'", ctx.OrgID, spaceID) + b.DeleteWhere(ctx.Transaction, s1) + + s2 := fmt.Sprintf("DELETE FROM category WHERE orgid='%s' AND labelid='%s'", ctx.OrgID, spaceID) + return b.DeleteWhere(ctx.Transaction, s2) +} diff --git a/domain/permission/mysql/store.go b/domain/permission/mysql/store.go index 22431665..2dc585f4 100644 --- a/domain/permission/mysql/store.go +++ b/domain/permission/mysql/store.go @@ -134,3 +134,24 @@ func (s Scope) DeleteUserPermissions(ctx domain.RequestContext, userID string) ( return b.DeleteWhere(ctx.Transaction, sql) } + +// DeleteCategoryPermissions removes records from permissions table for given category ID. +func (s Scope) DeleteCategoryPermissions(ctx domain.RequestContext, categoryID string) (rows int64, err error) { + b := mysql.BaseQuery{} + + sql := fmt.Sprintf("DELETE FROM permission WHERE orgid='%s' AND location='category' AND refid='%s'", ctx.OrgID, categoryID) + + return b.DeleteWhere(ctx.Transaction, sql) +} + +// DeleteSpaceCategoryPermissions removes all category permission for for given space. +func (s Scope) DeleteSpaceCategoryPermissions(ctx domain.RequestContext, spaceID string) (rows int64, err error) { + b := mysql.BaseQuery{} + + sql := fmt.Sprintf(` + DELETE FROM permission WHERE orgid='%s' AND location='category' + AND refid IN (SELECT refid FROM category WHERE orgid='%s' AND labelid='%s')`, + ctx.OrgID, ctx.OrgID, spaceID) + + return b.DeleteWhere(ctx.Transaction, sql) +} diff --git a/domain/permission/permission.go b/domain/permission/permission.go index ef880711..ca70c654 100644 --- a/domain/permission/permission.go +++ b/domain/permission/permission.go @@ -30,7 +30,7 @@ func CanViewSpaceDocument(ctx domain.RequestContext, s domain.Store, labelID str for _, role := range roles { if role.RefID == labelID && role.Location == "space" && role.Scope == "object" && - pm.HasPermission(role.Action, pm.SpaceView, pm.SpaceManage, pm.SpaceOwner) { + pm.ContainsPermission(role.Action, pm.SpaceView, pm.SpaceManage, pm.SpaceOwner) { return true } } @@ -58,7 +58,7 @@ func CanViewDocument(ctx domain.RequestContext, s domain.Store, documentID strin for _, role := range roles { if role.RefID == document.LabelID && role.Location == "space" && role.Scope == "object" && - pm.HasPermission(role.Action, pm.SpaceView, pm.SpaceManage, pm.SpaceOwner) { + pm.ContainsPermission(role.Action, pm.SpaceView, pm.SpaceManage, pm.SpaceOwner) { return true } } @@ -136,7 +136,7 @@ func CanUploadDocument(ctx domain.RequestContext, s domain.Store, spaceID string for _, role := range roles { if role.RefID == spaceID && role.Location == "space" && role.Scope == "object" && - pm.HasPermission(role.Action, pm.DocumentAdd) { + pm.ContainsPermission(role.Action, pm.DocumentAdd) { return true } } @@ -156,7 +156,7 @@ func CanViewSpace(ctx domain.RequestContext, s domain.Store, spaceID string) boo for _, role := range roles { if role.RefID == spaceID && role.Location == "space" && role.Scope == "object" && - pm.HasPermission(role.Action, pm.SpaceView, pm.SpaceManage, pm.SpaceOwner) { + pm.ContainsPermission(role.Action, pm.SpaceView, pm.SpaceManage, pm.SpaceOwner) { return true } } @@ -164,18 +164,9 @@ func CanViewSpace(ctx domain.RequestContext, s domain.Store, spaceID string) boo return false } -// HasDocumentAction returns if user can perform specified action. -func HasDocumentAction(ctx domain.RequestContext, s domain.Store, documentID string, a pm.Action) bool { - document, err := s.Document.Get(ctx, documentID) - - if err == sql.ErrNoRows { - err = nil - } - if err != nil { - return false - } - - roles, err := s.Permission.GetUserSpacePermissions(ctx, document.LabelID) +// HasPermission returns if user can perform specified actions. +func HasPermission(ctx domain.RequestContext, s domain.Store, spaceID string, actions ...pm.Action) bool { + roles, err := s.Permission.GetUserSpacePermissions(ctx, spaceID) if err == sql.ErrNoRows { err = nil @@ -185,8 +176,12 @@ func HasDocumentAction(ctx domain.RequestContext, s domain.Store, documentID str } for _, role := range roles { - if role.RefID == document.LabelID && role.Location == "space" && role.Scope == "object" && role.Action == a { - return true + if role.RefID == spaceID && role.Location == "space" && role.Scope == "object" { + for _, a := range actions { + if role.Action == a { + return true + } + } } } diff --git a/domain/space/endpoint.go b/domain/space/endpoint.go index e47a2856..c58d3a9e 100644 --- a/domain/space/endpoint.go +++ b/domain/space/endpoint.go @@ -48,7 +48,7 @@ type Handler struct { // Add creates a new space. func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { - method := "space.Add" + method := "space.add" ctx := domain.GetRequestContext(r) if !h.Runtime.Product.License.IsValid() { @@ -276,7 +276,7 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { // Get returns the requested space. func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { - method := "Get" + method := "space.get" ctx := domain.GetRequestContext(r) id := request.Param(r, "spaceID") @@ -302,7 +302,7 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { // GetAll returns spaces the user can see. func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request) { - method := "GetAll" + method := "space.getAll" ctx := domain.GetRequestContext(r) sp, err := h.Store.Space.GetAll(ctx) @@ -322,7 +322,7 @@ func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request) { // GetSpaceViewers returns the users that can see the shared spaces. func (h *Handler) GetSpaceViewers(w http.ResponseWriter, r *http.Request) { - method := "space.Viewers" + method := "space.viewers" ctx := domain.GetRequestContext(r) v, err := h.Store.Space.Viewers(ctx) @@ -341,7 +341,7 @@ func (h *Handler) GetSpaceViewers(w http.ResponseWriter, r *http.Request) { // Update processes request to save space object to the database func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { - method := "space.Update" + method := "space.update" ctx := domain.GetRequestContext(r) if !ctx.Editor { @@ -403,7 +403,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { // Remove moves documents to another space before deleting it func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) { - method := "space.Remove" + method := "space.remove" ctx := domain.GetRequestContext(r) if !h.Runtime.Product.License.IsValid() { @@ -477,7 +477,7 @@ func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) { // Delete removes space. func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { - method := "space.Delete" + method := "space.delete" ctx := domain.GetRequestContext(r) if !h.Runtime.Product.License.IsValid() { @@ -512,7 +512,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } - _, err = h.Store.Space.Delete(ctx, id) + _, err = h.Store.Permission.DeleteSpacePermissions(ctx, id) if err != nil { ctx.Transaction.Rollback() response.WriteServerError(w, method, err) @@ -520,7 +520,8 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } - _, err = h.Store.Permission.DeleteSpacePermissions(ctx, id) + // remove category permissions + _, err = h.Store.Permission.DeleteSpaceCategoryPermissions(ctx, id) if err != nil { ctx.Transaction.Rollback() response.WriteServerError(w, method, err) @@ -536,6 +537,23 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } + // remove category and members for space + _, err = h.Store.Category.DeleteBySpace(ctx, id) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + + _, err = h.Store.Space.Delete(ctx, id) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + h.Runtime.Log.Error(method, err) + return + } + h.Store.Audit.Record(ctx, audit.EventTypeSpaceDelete) ctx.Transaction.Commit() diff --git a/domain/storer.go b/domain/storer.go index 8dcb67e2..51959f5f 100644 --- a/domain/storer.go +++ b/domain/storer.go @@ -18,6 +18,7 @@ import ( "github.com/documize/community/model/attachment" "github.com/documize/community/model/audit" "github.com/documize/community/model/block" + "github.com/documize/community/model/category" "github.com/documize/community/model/doc" "github.com/documize/community/model/link" "github.com/documize/community/model/org" @@ -36,6 +37,7 @@ type Store struct { Attachment AttachmentStorer Audit AuditStorer Block BlockStorer + Category CategoryStorer Document DocumentStorer Link LinkStorer Organization OrganizationStorer @@ -59,6 +61,20 @@ type SpaceStorer interface { Delete(ctx RequestContext, id string) (rows int64, err error) } +// CategoryStorer defines required methods for category and category membership management +type CategoryStorer interface { + Add(ctx RequestContext, c category.Category) (err error) + Update(ctx RequestContext, c category.Category) (err error) + Get(ctx RequestContext, id string) (c category.Category, err error) + GetBySpace(ctx RequestContext, spaceID string) (c []category.Category, err error) + GetAllBySpace(ctx RequestContext, spaceID string) (c []category.Category, err error) + Delete(ctx RequestContext, id string) (rows int64, err error) + AssociateDocument(ctx RequestContext, m category.Member) (err error) + DisassociateDocument(ctx RequestContext, categoryID, documentID string) (rows int64, err error) + RemoveCategoryMembership(ctx RequestContext, categoryID string) (rows int64, err error) + DeleteBySpace(ctx RequestContext, spaceID string) (rows int64, err error) +} + // PermissionStorer defines required methods for space/document permission management type PermissionStorer interface { AddPermission(ctx RequestContext, r permission.Permission) (err error) @@ -68,6 +84,8 @@ type PermissionStorer interface { DeleteSpacePermissions(ctx RequestContext, spaceID string) (rows int64, err error) DeleteUserSpacePermissions(ctx RequestContext, spaceID, userID string) (rows int64, err error) DeleteUserPermissions(ctx RequestContext, userID string) (rows int64, err error) + DeleteCategoryPermissions(ctx RequestContext, categoryID string) (rows int64, err error) + DeleteSpaceCategoryPermissions(ctx RequestContext, spaceID string) (rows int64, err error) } // UserStorer defines required methods for user management diff --git a/domain/template/endpoint.go b/domain/template/endpoint.go index f1a617b5..8a6132a7 100644 --- a/domain/template/endpoint.go +++ b/domain/template/endpoint.go @@ -113,21 +113,21 @@ func (h *Handler) SaveAs(w http.ResponseWriter, r *http.Request) { return } - if !permission.HasDocumentAction(ctx, *h.Store, model.DocumentID, pm.DocumentTemplate) { - response.WriteForbiddenError(w) - return - } - - // DB transaction - ctx.Transaction, err = h.Runtime.Db.Beginx() + // Duplicate document + doc, err := h.Store.Document.Get(ctx, model.DocumentID) if err != nil { response.WriteServerError(w, method, err) h.Runtime.Log.Error(method, err) return } - // Duplicate document - doc, err := h.Store.Document.Get(ctx, model.DocumentID) + if !permission.HasPermission(ctx, *h.Store, doc.LabelID, pm.DocumentTemplate) { + response.WriteForbiddenError(w) + return + } + + // DB transaction + ctx.Transaction, err = h.Runtime.Db.Beginx() if err != nil { response.WriteServerError(w, method, err) h.Runtime.Log.Error(method, err) diff --git a/edition/boot/mysql.go b/edition/boot/mysql.go index 04afae13..5f823393 100644 --- a/edition/boot/mysql.go +++ b/edition/boot/mysql.go @@ -20,6 +20,7 @@ import ( attachment "github.com/documize/community/domain/attachment/mysql" audit "github.com/documize/community/domain/audit/mysql" block "github.com/documize/community/domain/block/mysql" + category "github.com/documize/community/domain/category/mysql" doc "github.com/documize/community/domain/document/mysql" link "github.com/documize/community/domain/link/mysql" org "github.com/documize/community/domain/organization/mysql" @@ -39,6 +40,7 @@ func StoreMySQL(r *env.Runtime, s *domain.Store) { s.Attachment = attachment.Scope{Runtime: r} s.Audit = audit.Scope{Runtime: r} s.Block = block.Scope{Runtime: r} + s.Category = category.Scope{Runtime: r} s.Document = doc.Scope{Runtime: r} s.Link = link.Scope{Runtime: r} s.Organization = org.Scope{Runtime: r} diff --git a/gui/app/components/folder/category-admin.js b/gui/app/components/folder/category-admin.js new file mode 100644 index 00000000..aaeb49a9 --- /dev/null +++ b/gui/app/components/folder/category-admin.js @@ -0,0 +1,91 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +import Ember from 'ember'; +import NotifierMixin from '../../mixins/notifier'; + +const { + inject: { service } +} = Ember; + +export default Ember.Component.extend(NotifierMixin, { + folderService: service('folder'), + categoryService: service('category'), + appMeta: service(), + store: service(), + newCategory: '', + + didReceiveAttrs() { + this.load(); + }, + + load() { + this.get('categoryService').getAll(this.get('folder.id')).then((c) => { + this.set('category', c); + }); + }, + + setEdit(id, val) { + let cats = this.get('category'); + let cat = cats.findBy('id', id); + + if (is.not.undefined(cat)) { + cat.set('editMode', val); + } + + return cat; + }, + + actions: { + onAdd() { + let cat = this.get('newCategory'); + + if (cat === '') { + $('#new-category-name').addClass('error').focus(); + return; + } + + $('#new-category-name').removeClass('error').focus(); + this.set('newCategory', ''); + + let c = { + category: cat, + folderId: this.get('folder.id') + }; + + this.get('categoryService').add(c).then(() => { + this.load(); + }); + }, + + onDelete(id) { + this.get('categoryService').delete(id).then(() => { + this.load(); + }); + }, + + onEdit(id) { + this.setEdit(id, true); + }, + + onCancel(id) { + this.setEdit(id, false); + }, + + onSave(id) { + let cat = this.setEdit(id, false); + + this.get('categoryService').save(cat).then(() => { + this.load(); + }); + } + } +}); diff --git a/gui/app/models/category.js b/gui/app/models/category.js new file mode 100644 index 00000000..1a2f7620 --- /dev/null +++ b/gui/app/models/category.js @@ -0,0 +1,21 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +import Model from 'ember-data/model'; +import attr from 'ember-data/attr'; + +export default Model.extend({ + orgId: attr('string'), + folderId: attr('string'), + category: attr('string'), + created: attr(), + revised: attr() +}); diff --git a/gui/app/pods/folder/settings/category/template.hbs b/gui/app/pods/folder/settings/category/template.hbs index 64fad17d..679ecc47 100644 --- a/gui/app/pods/folder/settings/category/template.hbs +++ b/gui/app/pods/folder/settings/category/template.hbs @@ -1 +1 @@ -{{folder/invite-user folders=model.folders folder=model.folder}} +{{folder/category-admin folders=model.folders folder=model.folder}} diff --git a/gui/app/services/category.js b/gui/app/services/category.js new file mode 100644 index 00000000..7fa700e2 --- /dev/null +++ b/gui/app/services/category.js @@ -0,0 +1,87 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +import Ember from 'ember'; +import BaseService from '../services/base'; + +const { + inject: { service } +} = Ember; + +export default BaseService.extend({ + sessionService: service('session'), + ajax: service(), + localStorage: service(), + store: service(), + + // Add category to space + add(payload) { + return this.get('ajax').post(`category`, { + contentType: 'json', + data: JSON.stringify(payload) + }).then((category) => { + let data = this.get('store').normalize('category', category); + return this.get('store').push(data); + }); + }, + + // Returns space categories viewable by user. + getUserVisible(spaceId) { + return this.get('ajax').request(`category/space/${spaceId}`, { + method: 'GET' + }).then((response) => { + let data = []; + + data = response.map((obj) => { + let data = this.get('store').normalize('category', obj); + return this.get('store').push(data); + }); + + return data; + }); + }, + + // Returns all space categories for admin user. + getAll(spaceId) { + return this.get('ajax').request(`category/space/${spaceId}?filter=all`, { + method: 'GET' + }).then((response) => { + let data = []; + + data = response.map((obj) => { + let data = this.get('store').normalize('category', obj); + return this.get('store').push(data); + }); + + return data; + }); + }, + + // Updates an existing category. + save(category) { + let id = category.get('id'); + + return this.get('ajax').request(`category/${id}`, { + method: 'PUT', + contentType: 'json', + data: JSON.stringify(category) + }).then((category) => { + let data = this.get('store').normalize('category', category); + return this.get('store').push(data); + }); + }, + + delete(categoryId) { + return this.get('ajax').request(`category/${categoryId}`, { + method: 'DELETE' + }); + } +}); diff --git a/gui/app/styles/view/folder/settings.scss b/gui/app/styles/view/folder/settings.scss index cd3d7d50..355b3fdb 100644 --- a/gui/app/styles/view/folder/settings.scss +++ b/gui/app/styles/view/folder/settings.scss @@ -34,6 +34,41 @@ } } } - } + } + + .category-table { + padding: 0; + margin: 0 0 0 20px; + width: 60%; + + > .row { + margin: 15px 0; + padding: 8px 10px; + background-color: $color-off-white; + @include border-radius(2px); + + > .category { + font-size: 1.2rem; + vertical-align: bottom; + display: inline-block; + margin-top: 8px; + } + + > .action { + display: inline-block; + } + + > .input-control { + margin: 0; + display: inline-block; + + > input { + margin: 0; + padding: 0; + font-size: 1.2rem; + } + } + } + } } diff --git a/gui/app/templates/components/folder/category-admin.hbs b/gui/app/templates/components/folder/category-admin.hbs new file mode 100644 index 00000000..17fb4e49 --- /dev/null +++ b/gui/app/templates/components/folder/category-admin.hbs @@ -0,0 +1,53 @@ +
+
+
+
Categories
+
Organize and secure document access with optional categories
+
+
+
+
+ {{#each category as |cat|}} +
+ {{#if cat.editMode}} +
+ {{focus-input id=(concat 'edit-category-' cat.id) type="text" value=cat.category class="input-inline"}} +
+ {{else}} +
{{cat.category}}
+ {{/if}} +
+ {{#if cat.editMode}} + +
+ close +
+ {{else}} +
+ edit +
+
+ delete +
+ {{#dropdown-dialog target=(concat 'delete-category-' cat.id) position="bottom right" button="Delete" color="flat-red" onAction=(action 'onDelete' cat.id)}} +

Are you sure you want to delete category {{cat.category}}?

+ {{/dropdown-dialog}} + {{/if}} +
+
+ {{else}} +
No categories defined yet
+ {{/each}} +
+
+ +
Provide a short name
+ {{focus-input id="new-category-name" type="text" value=newCategory}} +
+
+
add
+
+
+
diff --git a/model/audit/audit.go b/model/audit/audit.go index 2da4039f..6aebae44 100644 --- a/model/audit/audit.go +++ b/model/audit/audit.go @@ -67,4 +67,9 @@ const ( EventTypeSystemSMTP EventType = "changed-system-smtp" EventTypeSessionStart EventType = "started-session" EventTypeSearch EventType = "searched" + EventTypeCategoryAdd EventType = "added-category" + EventTypeCategoryDelete EventType = "removed-category" + EventTypeCategoryUpdate EventType = "updated-category" + EventTypeCategoryLink EventType = "linked-category" + EventTypeCategoryUnlink EventType = "unlinked-category" ) diff --git a/model/category/category.go b/model/category/category.go new file mode 100644 index 00000000..8557350a --- /dev/null +++ b/model/category/category.go @@ -0,0 +1,31 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package category + +import "github.com/documize/community/model" + +// Category represents a category within a space that is persisted to the database. +type Category struct { + model.BaseEntity + OrgID string `json:"orgId"` + LabelID string `json:"folderId"` + Category string `json:"category"` +} + +// Member represents 0:M association between a document and category, persisted to the database. +type Member struct { + model.BaseEntity + OrgID string `json:"orgId"` + CategoryID string `json:"categoryId"` + LabelID string `json:"folderId"` + DocumentID string `json:"documentId"` +} diff --git a/model/permission/permissions.go b/model/permission/permission.go similarity index 97% rename from model/permission/permissions.go rename to model/permission/permission.go index c67ed926..4734257a 100644 --- a/model/permission/permissions.go +++ b/model/permission/permission.go @@ -113,8 +113,8 @@ type PermissionsModel struct { Permissions []Record } -// HasPermission checks if action matches one of the required actions? -func HasPermission(action Action, actions ...Action) bool { +// ContainsPermission checks if action matches one of the required actions? +func ContainsPermission(action Action, actions ...Action) bool { for _, a := range actions { if action == a { return true diff --git a/server/routing/routes.go b/server/routing/routes.go index 1fef8d7d..795cef89 100644 --- a/server/routing/routes.go +++ b/server/routing/routes.go @@ -20,6 +20,7 @@ import ( "github.com/documize/community/domain/auth" "github.com/documize/community/domain/auth/keycloak" "github.com/documize/community/domain/block" + "github.com/documize/community/domain/category" "github.com/documize/community/domain/conversion" "github.com/documize/community/domain/document" "github.com/documize/community/domain/link" @@ -54,6 +55,7 @@ func RegisterEndpoints(rt *env.Runtime, s *domain.Store) { block := block.Handler{Runtime: rt, Store: s} section := section.Handler{Runtime: rt, Store: s} setting := setting.Handler{Runtime: rt, Store: s} + category := category.Handler{Runtime: rt, Store: s} keycloak := keycloak.Handler{Runtime: rt, Store: s} template := template.Handler{Runtime: rt, Store: s, Indexer: indexer} document := document.Handler{Runtime: rt, Store: s, Indexer: indexer} @@ -125,6 +127,12 @@ func RegisterEndpoints(rt *env.Runtime, s *domain.Store) { Add(rt, RoutePrefixPrivate, "space/{spaceID}", []string{"GET", "OPTIONS"}, nil, space.Get) Add(rt, RoutePrefixPrivate, "space/{spaceID}", []string{"PUT", "OPTIONS"}, nil, space.Update) + Add(rt, RoutePrefixPrivate, "category/space/{spaceID}", []string{"GET", "OPTIONS"}, []string{"filter", "all"}, category.GetAll) + Add(rt, RoutePrefixPrivate, "category/space/{spaceID}", []string{"GET", "OPTIONS"}, nil, category.Get) + Add(rt, RoutePrefixPrivate, "category", []string{"POST", "OPTIONS"}, nil, category.Add) + Add(rt, RoutePrefixPrivate, "category/{categoryID}", []string{"PUT", "OPTIONS"}, nil, category.Update) + Add(rt, RoutePrefixPrivate, "category/{categoryID}", []string{"DELETE", "OPTIONS"}, nil, category.Delete) + Add(rt, RoutePrefixPrivate, "users/{userID}/password", []string{"POST", "OPTIONS"}, nil, user.ChangePassword) Add(rt, RoutePrefixPrivate, "users", []string{"POST", "OPTIONS"}, nil, user.Add) Add(rt, RoutePrefixPrivate, "users/folder/{folderID}", []string{"GET", "OPTIONS"}, nil, user.GetSpaceUsers)