Initial suport for pagination

This commit adds support for pagination for users (Users and Groups menu item).
pagination-support
Glenn Wilkinson 2020-10-23 18:56:00 +02:00
parent 8b8e88b077
commit 90d46eb463
8 changed files with 474 additions and 139 deletions

View File

@ -4,11 +4,13 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
ctx "github.com/gophish/gophish/context" ctx "github.com/gophish/gophish/context"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/gophish/gophish/util"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
) )
@ -24,16 +26,35 @@ func (as *Server) Groups(w http.ResponseWriter, r *http.Request) {
return return
} }
JSONResponse(w, gs, http.StatusOK) JSONResponse(w, gs, http.StatusOK)
//POST: Create a new group and return it as JSON // POST: Create a new group and return it as JSON
case r.Method == "POST": case r.Method == "POST":
g := models.Group{} g := models.Group{}
// Put the request into a group // Put the request into a group
// Check if content is CSV
// NB We can only upload one file at a time for new group creation, as three seperate POSTs are sent in quick succesion. We can't
// identify which group the 1+n files should be assigned to. Need to give this more thought, perhaps we can upload multiple files in one POST,
// or assign a temporary tracking token to link them together.
var csvmode = false
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
csvmode = true
targets, groupname, err := util.ParseCSV(r)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
g.Name = groupname
g.Targets = targets
} else {
// else decode as JSON
err := json.NewDecoder(r.Body).Decode(&g) err := json.NewDecoder(r.Body).Decode(&g)
if err != nil { if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Invalid JSON structure"}, http.StatusBadRequest) JSONResponse(w, models.Response{Success: false, Message: "Invalid JSON structure"}, http.StatusBadRequest)
return return
} }
_, err = models.GetGroupByName(g.Name, ctx.Get(r, "user_id").(int64)) }
_, err := models.GetGroupByName(g.Name, ctx.Get(r, "user_id").(int64))
if err != gorm.ErrRecordNotFound { if err != gorm.ErrRecordNotFound {
JSONResponse(w, models.Response{Success: false, Message: "Group name already in use"}, http.StatusConflict) JSONResponse(w, models.Response{Success: false, Message: "Group name already in use"}, http.StatusConflict)
return return
@ -45,6 +66,12 @@ func (as *Server) Groups(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return return
} }
// With CSV we don't return the entire target list, in line with the new pagination server side processing. To maintain backwards API capabiltiy the JSON request
// will still return the full list.
if csvmode == true {
JSONResponse(w, models.GroupSummary{Id: g.Id, Name: g.Name, ModifiedDate: g.ModifiedDate, NumTargets: int64(len(g.Targets))}, http.StatusCreated)
return
}
JSONResponse(w, g, http.StatusCreated) JSONResponse(w, g, http.StatusCreated)
} }
} }
@ -68,14 +95,51 @@ func (as *Server) GroupsSummary(w http.ResponseWriter, r *http.Request) {
func (as *Server) Group(w http.ResponseWriter, r *http.Request) { func (as *Server) Group(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
id, _ := strconv.ParseInt(vars["id"], 0, 64) id, _ := strconv.ParseInt(vars["id"], 0, 64)
g, err := models.GetGroup(id, ctx.Get(r, "user_id").(int64))
// Paramters passed by DataTables for pagination are handled below
v := r.URL.Query()
search := v.Get("search[value]")
sortcolumn := v.Get("order[0][column]")
sortdir := v.Get("order[0][dir]")
sortby := v.Get("columns[" + sortcolumn + "][data]")
order := sortby + " " + sortdir // e.g "first_name asc"
start, err := strconv.ParseInt(v.Get("start"), 0, 64)
if err != nil {
start = -1 // Default. gorm will ignore with this value.
}
length, err := strconv.ParseInt(v.Get("length"), 0, 64)
if err != nil {
length = -1 // Default. gorm will ignore with this value.
}
draw, err := strconv.ParseInt(v.Get("draw"), 0, 64)
if err != nil {
draw = -1 // If the draw value is missing we can assume this is not a DataTable request and return regular API result
}
g, err := models.GetGroup(id, ctx.Get(r, "user_id").(int64), start, length, search, order)
if err != nil { if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Group not found"}, http.StatusNotFound) JSONResponse(w, models.Response{Success: false, Message: "Group not found"}, http.StatusNotFound)
return return
} }
switch { switch {
case r.Method == "GET": case r.Method == "GET":
// If draw paratmer is -1 return regular API response, otherwise return pagination response
if draw == -1 {
JSONResponse(w, g, http.StatusOK) JSONResponse(w, g, http.StatusOK)
} else {
// Handle pagination for DataTable
gs, _ := models.GetGroupSummary(id, ctx.Get(r, "user_id").(int64)) // We need to get the total number of records of the group
dT := models.DataTable{Draw: draw, RecordsTotal: gs.NumTargets, RecordsFiltered: gs.NumTargets}
dT.Data = make([]interface{}, len(g.Targets)) // Pseudocode of 'dT.Data = g.Targets'. https://golang.org/doc/faq#convert_slice_of_interface
for i, v := range g.Targets {
dT.Data[i] = v
}
JSONResponse(w, dT, http.StatusOK)
}
case r.Method == "DELETE": case r.Method == "DELETE":
err = models.DeleteGroup(&g) err = models.DeleteGroup(&g)
if err != nil { if err != nil {
@ -86,12 +150,30 @@ func (as *Server) Group(w http.ResponseWriter, r *http.Request) {
case r.Method == "PUT": case r.Method == "PUT":
// Change this to get from URL and uid (don't bother with id in r.Body) // Change this to get from URL and uid (don't bother with id in r.Body)
g = models.Group{} g = models.Group{}
//Check if content is CSV
var csvmode = false
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
csvmode = true
targets, groupname, err := util.ParseCSV(r)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
// We need to fetch all the existing targets for this group, so as to not overwrite them below
et, _ := models.GetTargets(id, -1, -1, "", "")
g.Targets = append(targets, et...)
g.Name = groupname
g.Id = id // ID isn't supplied in the CSV file upload. Perhaps we could use the filename paramter for this? I'm not sure if this is necessary though.
} else { // else JSON
err = json.NewDecoder(r.Body).Decode(&g) err = json.NewDecoder(r.Body).Decode(&g)
if err != nil { if err != nil {
log.Errorf("error decoding group: %v", err) log.Errorf("error decoding group: %v", err)
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return return
} }
}
if g.Id != id { if g.Id != id {
JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and group_id mismatch"}, http.StatusInternalServerError) JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and group_id mismatch"}, http.StatusInternalServerError)
return return
@ -103,6 +185,12 @@ func (as *Server) Group(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return return
} }
// With CSV we don't return the entire target list, in line with the new pagination server side processing. To maintain backwards API capabiltiy the JSON request
// will still return the full list.
if csvmode == true {
JSONResponse(w, models.GroupSummary{Id: g.Id, Name: g.Name, ModifiedDate: g.ModifiedDate, NumTargets: int64(len(g.Targets))}, http.StatusCreated)
return
}
JSONResponse(w, g, http.StatusOK) JSONResponse(w, g, http.StatusOK)
} }
} }
@ -121,3 +209,81 @@ func (as *Server) GroupSummary(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, g, http.StatusOK) JSONResponse(w, g, http.StatusOK)
} }
} }
// GroupTarget handles interactions with individual targets
func (as *Server) GroupTarget(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
gid, _ := strconv.ParseInt(vars["id"], 0, 64) // group id
// Ensure the group belongs to the user
_, err := models.GetGroup(gid, ctx.Get(r, "user_id").(int64), 0, 0, "", "")
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Group not found"}, http.StatusNotFound)
return
}
t := models.Target{}
err = json.NewDecoder(r.Body).Decode(&t)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Unable to decode target"}, http.StatusInternalServerError)
return
}
switch {
case r.Method == "PUT":
// Add an individual target to a group
err = models.AddTargetToGroup(t, gid)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Unable to add target to group"}, http.StatusNotFound)
return
}
JSONResponse(w, models.Response{Success: true, Message: "Added target to group"}, http.StatusCreated)
case r.Method == "DELETE":
err := models.DeleteTarget(&t, gid, ctx.Get(r, "user_id").(int64)) // We pass the group id to update modified date, and userid to ensure user owner the target
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
JSONResponse(w, models.Response{Success: true, Message: "Deleted target"}, http.StatusCreated)
}
}
// GroupRename handles renaming of a group (without supplying all the targets, as in Group() PUT above)
func (as *Server) GroupRename(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, _ := strconv.ParseInt(vars["id"], 0, 64) // group id
g, err := models.GetGroup(id, ctx.Get(r, "user_id").(int64), 0, 0, "", "")
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Group not found"}, http.StatusNotFound)
return
}
switch {
case r.Method == "PUT":
g = models.Group{}
err = json.NewDecoder(r.Body).Decode(&g)
if err != nil {
log.Errorf("error decoding group: %v", err)
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
if g.Id != id {
JSONResponse(w, models.Response{Success: false, Message: "Error: /:id and group_id mismatch"}, http.StatusInternalServerError)
return
}
_, err := models.GetGroupByName(g.Name, ctx.Get(r, "user_id").(int64))
if err != gorm.ErrRecordNotFound {
JSONResponse(w, models.Response{Success: false, Message: "Group name already in use"}, http.StatusConflict)
return
}
g.ModifiedDate = time.Now().UTC()
g.UserId = ctx.Get(r, "user_id").(int64)
err = models.UpdateGroup(&g)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return
}
JSONResponse(w, models.Response{Success: true, Message: "Group renamed"}, http.StatusCreated)
}
}

View File

@ -41,7 +41,7 @@ type emailResponse struct {
// ImportGroup imports a CSV of group members // ImportGroup imports a CSV of group members
func (as *Server) ImportGroup(w http.ResponseWriter, r *http.Request) { func (as *Server) ImportGroup(w http.ResponseWriter, r *http.Request) {
ts, err := util.ParseCSV(r) ts, _, err := util.ParseCSV(r)
if err != nil { if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Error parsing CSV"}, http.StatusInternalServerError) JSONResponse(w, models.Response{Success: false, Message: "Error parsing CSV"}, http.StatusInternalServerError)
return return

View File

@ -71,6 +71,9 @@ func (as *Server) registerRoutes() {
router.HandleFunc("/groups/summary", as.GroupsSummary) router.HandleFunc("/groups/summary", as.GroupsSummary)
router.HandleFunc("/groups/{id:[0-9]+}", as.Group) router.HandleFunc("/groups/{id:[0-9]+}", as.Group)
router.HandleFunc("/groups/{id:[0-9]+}/summary", as.GroupSummary) router.HandleFunc("/groups/{id:[0-9]+}/summary", as.GroupSummary)
router.HandleFunc("/groups/{id:[0-9]+}/rename", as.GroupRename)
router.HandleFunc("/groups/{id:[0-9]+}/target/add", as.GroupTarget)
router.HandleFunc("/groups/{id:[0-9]+}/target/delete", as.GroupTarget)
router.HandleFunc("/templates/", as.Templates) router.HandleFunc("/templates/", as.Templates)
router.HandleFunc("/templates/{id:[0-9]+}", as.Template) router.HandleFunc("/templates/{id:[0-9]+}", as.Template)
router.HandleFunc("/pages/", as.Pages) router.HandleFunc("/pages/", as.Pages)

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/mail" "net/mail"
"strings"
"time" "time"
log "github.com/gophish/gophish/logger" log "github.com/gophish/gophish/logger"
@ -46,7 +47,7 @@ type GroupTarget struct {
// Target contains the fields needed for individual targets specified by the user // Target contains the fields needed for individual targets specified by the user
// Groups contain 1..* Targets, but 1 Target may belong to 1..* Groups // Groups contain 1..* Targets, but 1 Target may belong to 1..* Groups
type Target struct { type Target struct {
Id int64 `json:"-"` Id int64 `json:"id"`
BaseRecipient BaseRecipient
} }
@ -59,6 +60,15 @@ type BaseRecipient struct {
Position string `json:"position"` Position string `json:"position"`
} }
// DataTable is used to return a JSON object suitable for consumption by DataTables
// when using pagination
type DataTable struct {
Draw int64 `json:"draw"`
RecordsTotal int64 `json:"recordsTotal"`
RecordsFiltered int64 `json:"recordsFiltered"`
Data []interface{} `json:"data"`
}
// FormatAddress returns the email address to use in the "To" header of the email // FormatAddress returns the email address to use in the "To" header of the email
func (r *BaseRecipient) FormatAddress() string { func (r *BaseRecipient) FormatAddress() string {
addr := r.Email addr := r.Email
@ -114,7 +124,7 @@ func GetGroups(uid int64) ([]Group, error) {
return gs, err return gs, err
} }
for i := range gs { for i := range gs {
gs[i].Targets, err = GetTargets(gs[i].Id) gs[i].Targets, err = GetTargets(gs[i].Id, -1, -1, "", "")
if err != nil { if err != nil {
log.Error(err) log.Error(err)
} }
@ -144,14 +154,15 @@ func GetGroupSummaries(uid int64) (GroupSummaries, error) {
} }
// GetGroup returns the group, if it exists, specified by the given id and user_id. // GetGroup returns the group, if it exists, specified by the given id and user_id.
func GetGroup(id int64, uid int64) (Group, error) { // Filter on number of results and starting point with 'start' and 'length' for pagination.
func GetGroup(id int64, uid int64, start int64, length int64, search string, order string) (Group, error) {
g := Group{} g := Group{}
err := db.Where("user_id=? and id=?", uid, id).Find(&g).Error err := db.Where("user_id=? and id=?", uid, id).Find(&g).Error
if err != nil { if err != nil {
log.Error(err) log.Error(err)
return g, err return g, err
} }
g.Targets, err = GetTargets(g.Id) g.Targets, err = GetTargets(g.Id, start, length, search, order)
if err != nil { if err != nil {
log.Error(err) log.Error(err)
} }
@ -183,7 +194,7 @@ func GetGroupByName(n string, uid int64) (Group, error) {
log.Error(err) log.Error(err)
return g, err return g, err
} }
g.Targets, err = GetTargets(g.Id) g.Targets, err = GetTargets(g.Id, -1, -1, "", "")
if err != nil { if err != nil {
log.Error(err) log.Error(err)
} }
@ -226,7 +237,7 @@ func PutGroup(g *Group) error {
return err return err
} }
// Fetch group's existing targets from database. // Fetch group's existing targets from database.
ts, err := GetTargets(g.Id) ts, err := GetTargets(g.Id, -1, -1, "", "")
if err != nil { if err != nil {
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
"group_id": g.Id, "group_id": g.Id,
@ -312,6 +323,63 @@ func DeleteGroup(g *Group) error {
return err return err
} }
// DeleteTarget deletes a single target from a group given by target ID
func DeleteTarget(t *Target, gid int64, uid int64) error {
targetOwner, err := GetTargetOwner(t.Id)
if err != nil {
return err
}
if targetOwner != uid {
return errors.New("No such target id (wrong owner)")
}
err = db.Delete(t).Error
if err != nil {
return err
}
err = db.Where("target_id=?", t.Id).Delete(&GroupTarget{}).Error
if err != nil {
return err
}
// Update group modification date
err = db.Debug().Model(&Group{}).Where("id=?", gid).Update("ModifiedDate", time.Now().UTC()).Error
return err
}
// UpdateGroup updates a given group (without updating the targets)
// Note: I thought about putting this in the Group() function, but we'd have to skip the validation and have a boolean
// indicating we just want to rename the group.
func UpdateGroup(g *Group) error {
if g.Name == "" {
return ErrGroupNameNotSpecified
}
err := db.Save(g).Error
return err
}
// AddTargetToGroup adds a single given target to a group by group ID
func AddTargetToGroup(nt Target, gid int64) error {
// Check if target already exists in group
tmpt, err := GetTargetByEmail(gid, nt.Email)
if err != nil {
return err
}
// If target exists in group, update it.
if len(tmpt) > 0 {
nt.Id = tmpt[0].Id
err = UpdateTarget(db, nt)
} else {
err = insertTargetIntoGroup(db, nt, gid)
}
if err != nil {
return err
}
err = db.Model(&Group{}).Where("id=?", gid).Update("ModifiedDate", time.Now().UTC()).Error
return err
}
func insertTargetIntoGroup(tx *gorm.DB, t Target, gid int64) error { func insertTargetIntoGroup(tx *gorm.DB, t Target, gid int64) error {
if _, err := mail.ParseAddress(t.Email); err != nil { if _, err := mail.ParseAddress(t.Email); err != nil {
log.WithFields(logrus.Fields{ log.WithFields(logrus.Fields{
@ -357,8 +425,40 @@ func UpdateTarget(tx *gorm.DB, target Target) error {
} }
// GetTargets performs a many-to-many select to get all the Targets for a Group // GetTargets performs a many-to-many select to get all the Targets for a Group
func GetTargets(gid int64) ([]Target, error) { // Start, length, and search can be supplied, or -1, -1, "" to ignore
func GetTargets(gid int64, start int64, length int64, search string, order string) ([]Target, error) {
ts := []Target{} ts := []Target{}
err := db.Table("targets").Select("targets.id, targets.email, targets.first_name, targets.last_name, targets.position").Joins("left join group_targets gt ON targets.id = gt.target_id").Where("gt.group_id=?", gid).Scan(&ts).Error var err error
order = strings.TrimSpace(order)
search = strings.TrimSpace(search)
if order == "" {
order = "targets.first_name asc"
} else {
order = "targets." + order
}
// TODO: Rather than having two queries create a partial query and include the search options. Haven't been able to figure out how yet.
if search != "" {
search = "%" + search + "%"
err = db.Order(order).Table("targets").Select("targets.id, targets.email, targets.first_name, targets.last_name, targets.position").Joins("left join group_targets gt ON targets.id = gt.target_id").Where("gt.group_id=?", gid).Where("targets.first_name LIKE ? OR targets.last_name LIKE ? OR targets.email LIKE ? or targets.position LIKE ?", search, search, search, search, search).Offset(start).Limit(length).Scan(&ts).Error
} else {
err = db.Order(order).Table("targets").Select("targets.id, targets.email, targets.first_name, targets.last_name, targets.position").Joins("left join group_targets gt ON targets.id = gt.target_id").Where("gt.group_id=?", gid).Offset(start).Limit(length).Scan(&ts).Error
}
return ts, err return ts, err
} }
// GetTargetByEmail gets a single target from a group by email address and group id
func GetTargetByEmail(gid int64, email string) ([]Target, error) {
ts := []Target{}
err := db.Table("targets").Select("targets.id, targets.email, targets.first_name, targets.last_name, targets.position").Joins("left join group_targets gt ON targets.id = gt.target_id").Where("gt.group_id=?", gid).Where("targets.email=?", email).First(&ts).Error
return ts, err
}
// GetTargetOwner returns the user id owner of a given target id
func GetTargetOwner(tid int64) (int64, error) {
g := Group{}
err := db.Table("groups").Select("groups.user_id").Joins("left join group_targets on group_targets.group_id = groups.id").Where("group_targets.target_id = ?", tid).Scan(&g).Error
return g.UserId, err
}

View File

@ -137,6 +137,26 @@ var api = {
// delete() - Deletes a group at DELETE /groups/:id // delete() - Deletes a group at DELETE /groups/:id
delete: function (id) { delete: function (id) {
return query("/groups/" + id, "DELETE", {}, false) return query("/groups/" + id, "DELETE", {}, false)
},
// summary() - Queries the API for GET /groups/:id/summary
summary: function (id) {
return query("/groups/" + id + "/summary", "GET", {}, true)
},
// rename() - Renames a group
rename: function(id, group) {
return query("/groups/" + id + "/rename", "PUT", group, false)
},
// add() - Adds a single target to a group
addtarget: function(id, target) {
return query("/groups/" + id + "/target/add", "PUT", target, false)
},
// delete() - Deletes a single targert by id at DELETE /groups/:id
deletetarget: function (id, target) {
return query("/groups/" + id + "/target/delete", "DELETE", target, false)
},
// post() - Posts a CSV group to POST /groups/:id/csv
post: function (id, data) {
return query("/groups/" + id + "/csv", "POST", data, false)
} }
}, },
// templates contains the endpoints for /templates // templates contains the endpoints for /templates

View File

@ -1,99 +1,82 @@
var groups = [] var groups = []
// Save attempts to POST or PUT to /groups/
function save(id) {
var targets = []
$.each($("#targetsTable").DataTable().rows().data(), function (i, target) {
targets.push({
first_name: unescapeHtml(target[0]),
last_name: unescapeHtml(target[1]),
email: unescapeHtml(target[2]),
position: unescapeHtml(target[3])
})
})
var group = {
name: $("#name").val(),
targets: targets
}
// Submit the group
if (id != -1) {
// If we're just editing an existing group,
// we need to PUT /groups/:id
group.id = id
api.groupId.put(group)
.success(function (data) {
successFlash("Group updated successfully!")
load()
dismiss()
$("#modal").modal('hide')
})
.error(function (data) {
modalError(data.responseJSON.message)
})
} else {
// Else, if this is a new group, POST it
// to /groups
api.groups.post(group)
.success(function (data) {
successFlash("Group added successfully!")
load()
dismiss()
$("#modal").modal('hide')
})
.error(function (data) {
modalError(data.responseJSON.message)
})
}
}
function dismiss() { function dismiss() {
$("#targetsTable").dataTable().DataTable().clear().draw() $("#targetsTable").dataTable().DataTable().clear().draw()
$("#name").val("") $("#name").val("")
$("#modal\\.flashes").empty() $("#modal\\.flashes").empty()
} }
function edit(id) { function instantiateDataTable(id) {
targets = $("#targetsTable").dataTable({ $('#targetsTable').dataTable( {
destroy: true, // Destroy any other instantiated table - http://datatables.net/manual/tech-notes/3#destroy destroy: true, // Destroy any other instantiated table - http://datatables.net/manual/tech-notes/3#destroy
select: true,
columnDefs: [{ columnDefs: [{
orderable: false, orderable: false,
targets: "no-sort" targets: "no-sort"
}] }],
}) "processing": true,
$("#modalSubmit").unbind('click').click(function () { "serverSide": true,
save(id) "ajax": {
}) url: "/api/groups/" + id,
if (id == -1) { 'beforeSend': function (request) {
var group = {} request.setRequestHeader("Authorization", "Bearer " + user.api_key);
} else {
api.groupId.get(id)
.success(function (group) {
$("#name").val(group.name)
targetRows = []
$.each(group.targets, function (i, record) {
targetRows.push([
escapeHtml(record.first_name),
escapeHtml(record.last_name),
escapeHtml(record.email),
escapeHtml(record.position),
'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'
])
});
targets.DataTable().rows.add(targetRows).draw()
})
.error(function () {
errorFlash("Error fetching group")
})
} }
},
columns: [
{ data: 'first_name', render: escapeHtml},
{ data: 'last_name', render: escapeHtml },
{ data: 'email', render: escapeHtml },
{ data: 'position', render: escapeHtml },
{ data: null,
render: function ( data, type, row ) {
return '<span style="cursor:pointer;"><i class="fa fa-trash-o" id="' + data.id + '"></i></span>';
}
}]
} );
}
function edit(id) {
$("#groupid").val(id)
if (id == -1 ){ // New group
$("#targetsTable_wrapper").hide()
$("#targetsTable").hide()
} else {
api.groupId.summary(id)
.success(function (group) {
$("#name").val(escapeHtml(group.name))
})
.error(function (data) {
modalError("Error fetching group name")
})
instantiateDataTable(id)
}
// Handle file uploads // Handle file uploads
csvurl = "/api/groups/" // New group
method = "POST"
if (id != -1) {
csvurl = "/api/groups/" + id // Update existing group
method = "PUT"
}
$("#csvupload").fileupload({ $("#csvupload").fileupload({
url: "/api/import/group", url: csvurl,
method: method,
dataType: "json", dataType: "json",
beforeSend: function (xhr) { beforeSend: function (xhr) {
xhr.setRequestHeader('Authorization', 'Bearer ' + user.api_key); xhr.setRequestHeader('Authorization', 'Bearer ' + user.api_key);
}, },
add: function (e, data) { add: function (e, data) {
$("#modal\\.flashes").empty() $("#modal\\.flashes").empty()
name = $("#name").val()
data.paramName = escapeHtml(name) // Send group name
if (name == "") {
modalError("No group name supplied")
$("#name").focus();
return false;
}
var acceptFileTypes = /(csv|txt)$/i; var acceptFileTypes = /(csv|txt)$/i;
var filename = data.originalFiles[0]['name'] var filename = data.originalFiles[0]['name']
if (filename && !acceptFileTypes.test(filename.split(".").pop())) { if (filename && !acceptFileTypes.test(filename.split(".").pop())) {
@ -103,15 +86,30 @@ function edit(id) {
data.submit(); data.submit();
}, },
done: function (e, data) { done: function (e, data) {
$.each(data.result, function (i, record) { if (!('id' in data.result)) {
addTarget( modalError("Failed to upload CSV file")
record.first_name, } else {
record.last_name, $("#targetsTable_wrapper").show()
record.email, $("#targetsTable").show()
record.position); edit(data.result.id)
}); load()
targets.DataTable().draw();
} }
}
})
}
function saveGroupName() {
id = parseInt($("#groupid").val())
if (id == -1) { return }
name = $("#name").val() // Check for length etc + handle escapes
data = {"id": id, "name":name}
api.groupId.rename(id, data)
.success(function (msg) {
load()
})
.error(function (data) {
modalError(data.responseJSON.message)
}) })
} }
@ -140,7 +138,6 @@ var downloadCSVTemplate = function () {
} }
} }
var deleteGroup = function (id) { var deleteGroup = function (id) {
var group = groups.find(function (x) { var group = groups.find(function (x) {
return x.id === id return x.id === id
@ -184,33 +181,46 @@ var deleteGroup = function (id) {
} }
function addTarget(firstNameInput, lastNameInput, emailInput, positionInput) { function addTarget(firstNameInput, lastNameInput, emailInput, positionInput) {
// Create new data row.
var email = escapeHtml(emailInput).toLowerCase();
var newRow = [
escapeHtml(firstNameInput),
escapeHtml(lastNameInput),
email,
escapeHtml(positionInput),
'<span style="cursor:pointer;"><i class="fa fa-trash-o"></i></span>'
];
// Check table to see if email already exists. $("#modal\\.flashes").empty()
var targetsTable = targets.DataTable(); groupId = $("#groupid").val()
var existingRowIndex = targetsTable target = {
.column(2, { "email": emailInput.toLowerCase(),
order: "index" "first_name": firstNameInput,
}) // Email column has index of 2 "last_name": lastNameInput,
.data() "position": positionInput
.indexOf(email); }
// Update or add new row as necessary.
if (existingRowIndex >= 0) { if (groupId == -1){ // Create new group with target
targetsTable groupname = $("#name").val()
.row(existingRowIndex, { groupname = escapeHtml(groupname)
order: "index" group = {"name": groupname, targets: [target]}
})
.data(newRow); api.groups.post(group)
.success(function (data) {
//ajax fertch and show table
if (!('id' in data)) {
modalError("Failed to add target")
} else { } else {
targetsTable.row.add(newRow); instantiateDataTable(data.id)
$('#targetsTable').DataTable().draw('page');
$('#targetsTable_wrapper').show()
$('#targetsTable').show()
$("#groupid").val(data.id)
}
})
.error(function (data) {
modalError(data.responseJSON.message)
})
} else { // Add single target to existing group
api.groupId.addtarget(groupId, target)
.success(function (data){
load()
})
.error(function (data) {
modalError(data.responseJSON.message)
})
} }
} }
@ -239,7 +249,7 @@ function load() {
escapeHtml(group.name), escapeHtml(group.name),
escapeHtml(group.num_targets), escapeHtml(group.num_targets),
moment(group.modified_date).format('MMMM Do YYYY, h:mm:ss a'), moment(group.modified_date).format('MMMM Do YYYY, h:mm:ss a'),
"<div class='pull-right'><button class='btn btn-primary' data-toggle='modal' data-backdrop='static' data-target='#modal' onclick='edit(" + group.id + ")'>\ "<div class='pull-right'><button class='btn btn-primary' data-toggle='modal' data-backdrop='static' data-target='#modal' onclick='edit(" + group.id +")'>\
<i class='fa fa-pencil'></i>\ <i class='fa fa-pencil'></i>\
</button>\ </button>\
<button class='btn btn-danger' onclick='deleteGroup(" + group.id + ")'>\ <button class='btn btn-danger' onclick='deleteGroup(" + group.id + ")'>\
@ -262,6 +272,14 @@ $(document).ready(function () {
// Setup the event listeners // Setup the event listeners
// Handle manual additions // Handle manual additions
$("#targetForm").submit(function () { $("#targetForm").submit(function () {
// Validate group name is present
if ($("#name").val() == "") {
modalError("No group name supplied")
$("#name").focus();
return false;
}
// Validate the form data // Validate the form data
var targetForm = document.getElementById("targetForm") var targetForm = document.getElementById("targetForm")
if (!targetForm.checkValidity()) { if (!targetForm.checkValidity()) {
@ -273,19 +291,35 @@ $(document).ready(function () {
$("#lastName").val(), $("#lastName").val(),
$("#email").val(), $("#email").val(),
$("#position").val()); $("#position").val());
targets.DataTable().draw();
$('#targetsTable').DataTable().draw();
// Reset user input. // Reset user input.
$("#targetForm>div>input").val(''); $("#targetForm>div>input").val('');
$("#firstName").focus(); $("#firstName").focus();
return false; return false;
}); });
// Handle Deletion // Handle Deletion
$("#targetsTable").on("click", "span>i.fa-trash-o", function () { $("#targetsTable").on("click", "span>i.fa-trash-o", function () {
targets.DataTable() // We allow emtpy groups with this new pagination model. TODO: Do we need to revisit this?
targetId=parseInt(this.id)
groupId=$("#groupid").val()
target = {"id" : targetId}
api.groupId.deletetarget(groupId, target)
.success(function (msg) {
load()
})
.error(function (data) {
modalError("Failed to delete user. Please try again later.")
})
$('#targetsTable').DataTable()
.row($(this).parents('tr')) .row($(this).parents('tr'))
.remove() .remove()
.draw(); .draw('page');
}); });
$("#modal").on("hide.bs.modal", function () { $("#modal").on("hide.bs.modal", function () {
dismiss(); dismiss();

View File

@ -49,14 +49,20 @@
<div class="row" id="modal.flashes"></div> <div class="row" id="modal.flashes"></div>
<label class="control-label" for="name">Name:</label> <label class="control-label" for="name">Name:</label>
<div class="form-group"> <div class="form-group">
<div class="input-group">
<input type="text" class="form-control" ng-model="group.name" placeholder="Group name" id="name" <input type="text" class="form-control" ng-model="group.name" placeholder="Group name" id="name"
autofocus /> autofocus />
<span class="input-group-btn">
<button class="btn btn-default" onclick="saveGroupName()" type="button">Save</button>
</span>
</div>
<input id="groupid" type="hidden"/>
</div> </div>
<div class="form-group"> <div class="form-group">
<span class="btn btn-danger btn-file" data-toggle="tooltip" data-placement="right" <span class="btn btn-danger btn-file" data-toggle="tooltip" data-placement="right"
title="Supports CSV files" id="fileUpload"> title="Supports CSV files" id="fileUpload">
<i class="fa fa-plus"></i> Bulk Import Users <i class="fa fa-plus"></i> Bulk Import Users
<input type="file" id="csvupload" multiple> <input type="file" id="csvupload" name=""> <!-- Removed 'multiple' keyword due to new CSV upload functionality. Fails with new group creation. See Groups function in api/group.go -->
</span> </span>
<span id="csv-template" class="text-muted small"> <span id="csv-template" class="text-muted small">
<i class="fa fa-file-excel-o"></i> Download CSV Template</span> <i class="fa fa-file-excel-o"></i> Download CSV Template</span>
@ -83,6 +89,7 @@
</div> </div>
<br /> <br />
<table id="targetsTable" class="table table-hover table-striped table-condensed"> <table id="targetsTable" class="table table-hover table-striped table-condensed">
<thead> <thead>
<tr> <tr>
<th>First Name</th> <th>First Name</th>
@ -90,17 +97,20 @@
<th>Email</th> <th>Email</th>
<th>Position</th> <th>Position</th>
<th class="no-sort"></th> <th class="no-sort"></th>
<tbody> </tr>
</tbody> </thead>
</table> </table>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="modalSubmit">Save changes</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{{end}} {{define "scripts"}} {{end}} {{define "scripts"}}
<script>
</script>
<script src="/js/dist/app/groups.min.js"></script> <script src="/js/dist/app/groups.min.js"></script>
{{end}} {{end}}

View File

@ -44,11 +44,12 @@ func ParseMail(r *http.Request) (email.Email, error) {
} }
// ParseCSV contains the logic to parse the user provided csv file containing Target entries // ParseCSV contains the logic to parse the user provided csv file containing Target entries
func ParseCSV(r *http.Request) ([]models.Target, error) { func ParseCSV(r *http.Request) ([]models.Target, string, error) {
mr, err := r.MultipartReader() mr, err := r.MultipartReader()
ts := []models.Target{} ts := []models.Target{}
name := ""
if err != nil { if err != nil {
return ts, err return ts, name, err
} }
for { for {
part, err := mr.NextPart() part, err := mr.NextPart()
@ -60,6 +61,7 @@ func ParseCSV(r *http.Request) ([]models.Target, error) {
continue continue
} }
defer part.Close() defer part.Close()
name = part.FormName()
reader := csv.NewReader(part) reader := csv.NewReader(part)
reader.TrimLeadingSpace = true reader.TrimLeadingSpace = true
record, err := reader.Read() record, err := reader.Read()
@ -121,7 +123,7 @@ func ParseCSV(r *http.Request) ([]models.Target, error) {
ts = append(ts, t) ts = append(ts, t)
} }
} }
return ts, nil return ts, name, nil
} }
// CheckAndCreateSSL is a helper to setup self-signed certificates for the administrative interface. // CheckAndCreateSSL is a helper to setup self-signed certificates for the administrative interface.