From 7dcf30f27708dfe3ce7f53b1fa93d06a646e30a6 Mon Sep 17 00:00:00 2001 From: Jordan Wright Date: Sun, 2 Sep 2018 11:17:52 -0500 Subject: [PATCH] Add Support for Timed Campaigns (#1184) This builds on the work from @c-f in #1090 to fully add support for "timed" campaigns, in which the emails are spaced apart as opposed to all being sent at once. --- .travis.yml | 1 - .../20180830215615_0.7.0_send_by_date.sql | 6 + .../20180830215615_0.7.0_send_by_date.sql | 6 + mailer/mailer.go | 1 - models/campaign.go | 46 +++- models/campaign_test.go | 81 +++++++ models/maillog.go | 4 +- models/maillog_test.go | 2 +- models/models_test.go | 2 + models/result_test.go | 20 ++ static/js/dist/app/campaign_results.min.js | 2 +- static/js/dist/app/campaigns.min.js | 2 +- static/js/src/app/campaign_results.js | 5 +- static/js/src/app/campaigns.js | 16 +- templates/campaigns.html | 200 ++++++++++-------- worker/worker.go | 10 +- 16 files changed, 304 insertions(+), 100 deletions(-) create mode 100644 db/db_mysql/migrations/20180830215615_0.7.0_send_by_date.sql create mode 100644 db/db_sqlite3/migrations/20180830215615_0.7.0_send_by_date.sql create mode 100644 models/campaign_test.go diff --git a/.travis.yml b/.travis.yml index c79c5179..99d630f9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,6 @@ language: go sudo: false go: - - 1.8 - 1.9 - tip diff --git a/db/db_mysql/migrations/20180830215615_0.7.0_send_by_date.sql b/db/db_mysql/migrations/20180830215615_0.7.0_send_by_date.sql new file mode 100644 index 00000000..100c77a8 --- /dev/null +++ b/db/db_mysql/migrations/20180830215615_0.7.0_send_by_date.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- SQL in this section is executed when the migration is applied. +ALTER TABLE campaigns ADD COLUMN send_by_date DATETIME; + +-- +goose Down +-- SQL in this section is executed when the migration is rolled back. diff --git a/db/db_sqlite3/migrations/20180830215615_0.7.0_send_by_date.sql b/db/db_sqlite3/migrations/20180830215615_0.7.0_send_by_date.sql new file mode 100644 index 00000000..100c77a8 --- /dev/null +++ b/db/db_sqlite3/migrations/20180830215615_0.7.0_send_by_date.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- SQL in this section is executed when the migration is applied. +ALTER TABLE campaigns ADD COLUMN send_by_date DATETIME; + +-- +goose Down +-- SQL in this section is executed when the migration is rolled back. diff --git a/mailer/mailer.go b/mailer/mailer.go index 06a34125..4a2a7410 100644 --- a/mailer/mailer.go +++ b/mailer/mailer.go @@ -83,7 +83,6 @@ func (mw *MailWorker) Start(ctx context.Context) { return case ms := <-mw.Queue: go func(ctx context.Context, ms []Mail) { - log.Infof("Mailer got %d mail to send", len(ms)) dialer, err := ms[0].GetDialer() if err != nil { errorMail(err, ms) diff --git a/models/campaign.go b/models/campaign.go index 90d2c873..80e86b19 100644 --- a/models/campaign.go +++ b/models/campaign.go @@ -17,6 +17,7 @@ type Campaign struct { Name string `json:"name" sql:"not null"` CreatedDate time.Time `json:"created_date"` LaunchDate time.Time `json:"launch_date"` + SendByDate time.Time `json:"send_by_date"` CompletedDate time.Time `json:"completed_date"` TemplateId int64 `json:"-"` Template Template `json:"template"` @@ -52,6 +53,7 @@ type CampaignSummary struct { Id int64 `json:"id"` CreatedDate time.Time `json:"created_date"` LaunchDate time.Time `json:"launch_date"` + SendByDate time.Time `json:"send_by_date"` CompletedDate time.Time `json:"completed_date"` Status string `json:"status"` Name string `json:"name"` @@ -120,6 +122,10 @@ var ErrPageNotFound = errors.New("Page not found") // ErrSMTPNotFound indicates a sending profile specified by the user does not exist in the database var ErrSMTPNotFound = errors.New("Sending profile not found") +// ErrInvalidSendByDate indicates that the user specified a send by date that occurs before the +// launch date +var ErrInvalidSendByDate = errors.New("The launch date must be before the \"send emails by\" date") + // RecipientParameter is the URL parameter that points to the result ID for a recipient. const RecipientParameter = "rid" @@ -136,6 +142,8 @@ func (c *Campaign) Validate() error { return ErrPageNotSpecified case c.SMTP.Name == "": return ErrSMTPNotSpecified + case !c.SendByDate.IsZero() && !c.LaunchDate.IsZero() && c.SendByDate.Before(c.LaunchDate): + return ErrInvalidSendByDate } return nil } @@ -218,6 +226,27 @@ func (c *Campaign) getFromAddress() string { return c.SMTP.FromAddress } +// generateSendDate creates a sendDate +func (c *Campaign) generateSendDate(idx int, totalRecipients int) time.Time { + // If no send date is specified, just return the launch date + if c.SendByDate.IsZero() || c.SendByDate.Equal(c.LaunchDate) { + return c.LaunchDate + } + // Otherwise, we can calculate the range of minutes to send emails + // (since we only poll once per minute) + totalMinutes := c.SendByDate.Sub(c.LaunchDate).Minutes() + + // Next, we can determine how many minutes should elapse between emails + minutesPerEmail := totalMinutes / float64(totalRecipients) + + // Then, we can calculate the offset for this particular email + offset := int(minutesPerEmail * float64(idx)) + + // Finally, we can just add this offset to the launch date to determine + // when the email should be sent + return c.LaunchDate.Add(time.Duration(offset) * time.Minute) +} + // getCampaignStats returns a CampaignStats object for the campaign with the given campaign ID. // It also backfills numbers as appropriate with a running total, so that the values are aggregated. func getCampaignStats(cid int64) (CampaignStats, error) { @@ -387,10 +416,16 @@ func PostCampaign(c *Campaign, uid int64) error { } else { c.LaunchDate = c.LaunchDate.UTC() } + if !c.SendByDate.IsZero() { + c.SendByDate = c.SendByDate.UTC() + } if c.LaunchDate.Before(c.CreatedDate) || c.LaunchDate.Equal(c.CreatedDate) { c.Status = CAMPAIGN_IN_PROGRESS } // Check to make sure all the groups already exist + // Also, later we'll need to know the total number of recipients (counting + // duplicates is ok for now), so we'll do that here to save a loop. + totalRecipients := 0 for i, g := range c.Groups { c.Groups[i], err = GetGroupByName(g.Name, uid) if err == gorm.ErrRecordNotFound { @@ -402,6 +437,7 @@ func PostCampaign(c *Campaign, uid int64) error { log.Error(err) return err } + totalRecipients += len(c.Groups[i].Targets) } // Check to make sure the template exists t, err := GetTemplateByName(c.Template.Name, uid) @@ -454,6 +490,7 @@ func PostCampaign(c *Campaign, uid int64) error { } // Insert all the results resultMap := make(map[string]bool) + recipientIndex := 0 for _, g := range c.Groups { // Insert a result for each target in the group for _, t := range g.Targets { @@ -463,6 +500,7 @@ func PostCampaign(c *Campaign, uid int64) error { continue } resultMap[t.Email] = true + sendDate := c.generateSendDate(recipientIndex, totalRecipients) r := &Result{ BaseRecipient: BaseRecipient{ Email: t.Email, @@ -473,11 +511,11 @@ func PostCampaign(c *Campaign, uid int64) error { Status: STATUS_SCHEDULED, CampaignId: c.Id, UserId: c.UserId, - SendDate: c.LaunchDate, + SendDate: sendDate, Reported: false, ModifiedDate: c.CreatedDate, } - if c.Status == CAMPAIGN_IN_PROGRESS { + if r.SendDate.Before(c.CreatedDate) || r.SendDate.Equal(c.CreatedDate) { r.Status = STATUS_SENDING } err = r.GenerateId() @@ -492,11 +530,13 @@ func PostCampaign(c *Campaign, uid int64) error { }).Error(err) } c.Results = append(c.Results, *r) - err = GenerateMailLog(c, r) + log.Infof("Creating maillog for %s to send at %s\n", r.Email, sendDate) + err = GenerateMailLog(c, r, sendDate) if err != nil { log.Error(err) continue } + recipientIndex++ } } err = db.Save(c).Error diff --git a/models/campaign_test.go b/models/campaign_test.go new file mode 100644 index 00000000..7cb3e2d3 --- /dev/null +++ b/models/campaign_test.go @@ -0,0 +1,81 @@ +package models + +import ( + "time" + + check "gopkg.in/check.v1" +) + +func (s *ModelsSuite) TestGenerateSendDate(c *check.C) { + campaign := s.createCampaignDependencies(c) + // Test that if no launch date is provided, the campaign's creation date + // is used. + err := PostCampaign(&campaign, campaign.UserId) + c.Assert(err, check.Equals, nil) + c.Assert(campaign.LaunchDate, check.Equals, campaign.CreatedDate) + + ms, err := GetMailLogsByCampaign(campaign.Id) + c.Assert(err, check.Equals, nil) + for _, m := range ms { + c.Assert(m.SendDate, check.Equals, campaign.CreatedDate) + } + + // Test that if no send date is provided, all the emails are sent at the + // campaign's launch date + campaign = s.createCampaignDependencies(c) + campaign.LaunchDate = time.Now().UTC() + err = PostCampaign(&campaign, campaign.UserId) + c.Assert(err, check.Equals, nil) + + ms, err = GetMailLogsByCampaign(campaign.Id) + c.Assert(err, check.Equals, nil) + for _, m := range ms { + c.Assert(m.SendDate, check.Equals, campaign.LaunchDate) + } + + // Finally, test that if a send date is provided, the emails are staggered + // correctly. + campaign = s.createCampaignDependencies(c) + campaign.LaunchDate = time.Now().UTC() + campaign.SendByDate = campaign.LaunchDate.Add(2 * time.Minute) + err = PostCampaign(&campaign, campaign.UserId) + c.Assert(err, check.Equals, nil) + + ms, err = GetMailLogsByCampaign(campaign.Id) + c.Assert(err, check.Equals, nil) + sendingOffset := 2 / float64(len(ms)) + for i, m := range ms { + expectedOffset := int(sendingOffset * float64(i)) + expectedDate := campaign.LaunchDate.Add(time.Duration(expectedOffset) * time.Minute) + c.Assert(m.SendDate, check.Equals, expectedDate) + } +} + +func (s *ModelsSuite) TestCampaignDateValidation(c *check.C) { + campaign := s.createCampaignDependencies(c) + // If both are zero, then the campaign should start immediately with no + // send by date + err := campaign.Validate() + c.Assert(err, check.Equals, nil) + + // If the launch date is specified, then the send date is optional + campaign = s.createCampaignDependencies(c) + campaign.LaunchDate = time.Now().UTC() + err = campaign.Validate() + c.Assert(err, check.Equals, nil) + + // If the send date is greater than the launch date, then there's no + //problem + campaign = s.createCampaignDependencies(c) + campaign.LaunchDate = time.Now().UTC() + campaign.SendByDate = campaign.LaunchDate.Add(1 * time.Minute) + err = campaign.Validate() + c.Assert(err, check.Equals, nil) + + // If the send date is less than the launch date, then there's an issue + campaign = s.createCampaignDependencies(c) + campaign.LaunchDate = time.Now().UTC() + campaign.SendByDate = campaign.LaunchDate.Add(-1 * time.Minute) + err = campaign.Validate() + c.Assert(err, check.Equals, ErrInvalidSendByDate) +} diff --git a/models/maillog.go b/models/maillog.go index 80312902..3b56a163 100644 --- a/models/maillog.go +++ b/models/maillog.go @@ -38,12 +38,12 @@ type MailLog struct { // GenerateMailLog creates a new maillog for the given campaign and // result. It sets the initial send date to match the campaign's launch date. -func GenerateMailLog(c *Campaign, r *Result) error { +func GenerateMailLog(c *Campaign, r *Result, sendDate time.Time) error { m := &MailLog{ UserId: c.UserId, CampaignId: c.Id, RId: r.RId, - SendDate: c.LaunchDate, + SendDate: sendDate, } err = db.Save(m).Error return err diff --git a/models/maillog_test.go b/models/maillog_test.go index a866cd3a..c3c73a9c 100644 --- a/models/maillog_test.go +++ b/models/maillog_test.go @@ -193,7 +193,7 @@ func (s *ModelsSuite) TestGenerateMailLog(ch *check.C) { result := Result{ RId: "abc1234", } - err := GenerateMailLog(&campaign, &result) + err := GenerateMailLog(&campaign, &result, campaign.LaunchDate) ch.Assert(err, check.Equals, nil) m := MailLog{} diff --git a/models/models_test.go b/models/models_test.go index a4c1ed4c..8374911c 100644 --- a/models/models_test.go +++ b/models/models_test.go @@ -47,6 +47,8 @@ func (s *ModelsSuite) createCampaignDependencies(ch *check.C, optional ...string group.Targets = []Target{ Target{BaseRecipient: BaseRecipient{Email: "test1@example.com", FirstName: "First", LastName: "Example"}}, Target{BaseRecipient: BaseRecipient{Email: "test2@example.com", FirstName: "Second", LastName: "Example"}}, + Target{BaseRecipient: BaseRecipient{Email: "test3@example.com", FirstName: "Second", LastName: "Example"}}, + Target{BaseRecipient: BaseRecipient{Email: "test4@example.com", FirstName: "Second", LastName: "Example"}}, } group.UserId = 1 ch.Assert(PostGroup(&group), check.Equals, nil) diff --git a/models/result_test.go b/models/result_test.go index 5b81b9bf..54585d40 100644 --- a/models/result_test.go +++ b/models/result_test.go @@ -1,6 +1,7 @@ package models import ( + "fmt" "net/mail" "regexp" "time" @@ -58,6 +59,25 @@ func (s *ModelsSuite) TestResultScheduledStatus(ch *check.C) { } } +func (s *ModelsSuite) TestResultVariableStatus(ch *check.C) { + c := s.createCampaignDependencies(ch) + c.LaunchDate = time.Now().UTC() + c.SendByDate = c.LaunchDate.Add(2 * time.Minute) + ch.Assert(PostCampaign(&c, c.UserId), check.Equals, nil) + + // The campaign has a window smaller than our group size, so we expect some + // emails to be sent immediately, while others will be scheduled + for _, r := range c.Results { + if r.SendDate.Before(c.CreatedDate) || r.SendDate.Equal(c.CreatedDate) { + fmt.Println("SENDING") + ch.Assert(r.Status, check.Equals, STATUS_SENDING) + } else { + fmt.Println("SCHEDULED") + ch.Assert(r.Status, check.Equals, STATUS_SCHEDULED) + } + } +} + func (s *ModelsSuite) TestDuplicateResults(ch *check.C) { group := Group{Name: "Test Group"} group.Targets = []Target{ diff --git a/static/js/dist/app/campaign_results.min.js b/static/js/dist/app/campaign_results.min.js index 265d9c70..c4767f80 100644 --- a/static/js/dist/app/campaign_results.min.js +++ b/static/js/dist/app/campaign_results.min.js @@ -1 +1 @@ -function dismiss(){$("#modal\\.flashes").empty(),$("#modal").modal("hide"),$("#resultsTable").dataTable().DataTable().clear().draw()}function deleteCampaign(){swal({title:"Are you sure?",text:"This will delete the campaign. This can't be undone!",type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete Campaign",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,showLoaderOnConfirm:!0,preConfirm:function(){return new Promise(function(e,t){api.campaignId.delete(campaign.id).success(function(t){e()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(){swal("Campaign Deleted!","This campaign has been deleted!","success"),$('button:contains("OK")').on("click",function(){location.href="/campaigns"})})}function completeCampaign(){swal({title:"Are you sure?",text:"Gophish will stop processing events for this campaign",type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Complete Campaign",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,showLoaderOnConfirm:!0,preConfirm:function(){return new Promise(function(e,t){api.campaignId.complete(campaign.id).success(function(t){e()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(){swal("Campaign Completed!","This campaign has been completed!","success"),$("#complete_button")[0].disabled=!0,$("#complete_button").text("Completed!"),doPoll=!1})}function exportAsCSV(e){exportHTML=$("#exportButton").html();var t=null,a=campaign.name+" - "+capitalize(e)+".csv";switch(e){case"results":t=campaign.results;break;case"events":t=campaign.timeline}if(t){$("#exportButton").html('');var s=Papa.unparse(t,{}),i=new Blob([s],{type:"text/csv;charset=utf-8;"});if(navigator.msSaveBlob)navigator.msSaveBlob(i,a);else{var l=window.URL.createObjectURL(i),n=document.createElement("a");n.href=l,n.setAttribute("download",a),document.body.appendChild(n),n.click(),document.body.removeChild(n)}$("#exportButton").html(exportHTML)}}function replay(e){function t(){form.attr({action:url}),form.appendTo("body").submit().remove()}request=campaign.timeline[e],details=JSON.parse(request.details),url=null,form=$("
").attr({method:"POST",target:"_blank"}),$.each(Object.keys(details.payload),function(e,t){return"rid"==t||("__original_url"==t?(url=details.payload[t],!0):void $("").attr({name:t}).val(details.payload[t]).appendTo(form))}),swal({title:"Where do you want the credentials submitted to?",input:"text",showCancelButton:!0,inputPlaceholder:"http://example.com/login",inputValue:url||"",inputValidator:function(e){return new Promise(function(t,a){e?t():a("Invalid URL.")})}}).then(function(e){url=e,t()})}function renderTimeline(e){return record={first_name:e[2],last_name:e[3],email:e[4],position:e[5],status:e[6],send_date:e[7],reported:e[8]},results='
Timeline for '+escapeHtml(record.first_name)+" "+escapeHtml(record.last_name)+'
Email: '+escapeHtml(record.email)+'
',$.each(campaign.timeline,function(e,t){t.email&&t.email!=record.email||(results+='
',results+='
'+escapeHtml(t.message)+' '+moment.utc(t.time).local().format("MMMM Do YYYY h:mm:ss a")+"",t.details&&(details=JSON.parse(t.details),"Clicked Link"!=t.message&&"Submitted Data"!=t.message||(deviceView=renderDevice(details),deviceView&&(results+=deviceView)),"Submitted Data"==t.message&&(results+='
',results+='
View Details
'),details.payload&&(results+='
',results+=' ',results+=" ",$.each(Object.keys(details.payload),function(e,t){if("rid"==t)return!0;results+=" ",results+=" ",results+=" ",results+=" "}),results+="
ParameterValue(s)
"+escapeHtml(t)+""+escapeHtml(details.payload[t])+"
",results+="
"),details.error&&(results+='
View Details
',results+='
',results+='Error '+details.error,results+="
")),results+="
")}),"Scheduled"!=record.status&&"Retrying"!=record.status||(results+='
',results+='
Scheduled to send at '+record.send_date+""),results+="
",results}function createStatusLabel(e,t){var a=statuses[e].label||"label-default",s=''+e+"";if("Scheduled"==e||"Retrying"==e){s=''+e+""}return s}function poll(){api.campaignId.results(campaign.id).success(function(e){campaign=e;var t=[];$.each(campaign.timeline,function(e,a){var s=moment.utc(a.time).local();t.push({email:a.email,x:s.valueOf(),y:1})});var t=[];$.each(campaign.timeline,function(e,a){var s=moment.utc(a.time).local();t.push({email:a.email,message:a.message,x:s.valueOf(),y:1,marker:{fillColor:statuses[a.message].color}})}),$("#timeline_chart").highcharts().series[0].update({data:t});var a={};Object.keys(statusMapping).forEach(function(e){a[e]=0}),$.each(campaign.results,function(e,t){a[t.status]++,t.reported&&a["Email Reported"]++;for(var s=progressListing.indexOf(t.status),e=0;e":""},targets:[7]}]}),resultsTable.clear();var a={},s=[];Object.keys(statusMapping).forEach(function(e){a[e]=0}),$.each(campaign.results,function(e,t){resultsTable.row.add([t.id,'',escapeHtml(t.first_name)||"",escapeHtml(t.last_name)||"",escapeHtml(t.email)||"",escapeHtml(t.position)||"",t.status,t.reported,moment(t.send_date).format("MMMM Do YYYY, h:mm:ss a")]),a[t.status]++,t.reported&&a["Email Reported"]++;for(var s=progressListing.indexOf(t.status),e=0;e',s="laptop";t.device.type&&("tablet"!=t.device.type&&"mobile"!=t.device.type||(s=t.device.type));var i="";t.device.vendor&&"microsoft"==(i=t.device.vendor.toLowerCase())&&(i="windows");var l="Unknown";t.os.name&&(l=t.os.name,"Mac OS"==l?i="apple":"Windows"==l&&(i="windows"),t.device.vendor&&t.device.model&&(l=t.device.vendor+" "+t.device.model)),t.os.version&&(l=l+" (OS Version: "+t.os.version+")"),deviceString='
'+escapeHtml(l)+"
",a+=deviceString;var n="Unknown",r="info-circle",o="";return t.browser&&t.browser.name&&(n=t.browser.name,n=n.replace("Mobile ",""),n&&"ie"==(r=n.toLowerCase())&&(r="internet-explorer"),o="(Version: "+t.browser.version+")"),a+='
'+n+" "+o+"
",a+="
"},renderTimelineChart=function(e){return Highcharts.chart("timeline_chart",{chart:{zoomType:"x",type:"line",height:"200px"},title:{text:"Campaign Timeline"},xAxis:{type:"datetime",dateTimeLabelFormats:{second:"%l:%M:%S",minute:"%l:%M",hour:"%l:%M",day:"%b %d, %Y",week:"%b %d, %Y",month:"%b %Y"}},yAxis:{min:0,max:2,visible:!1,tickInterval:1,labels:{enabled:!1},title:{text:""}},tooltip:{formatter:function(){return Highcharts.dateFormat("%A, %b %d %l:%M:%S %P",new Date(this.x))+"
Event: "+this.point.message+"
Email: "+this.point.email+""}},legend:{enabled:!1},plotOptions:{series:{marker:{enabled:!0,symbol:"circle",radius:3},cursor:"pointer"},line:{states:{hover:{lineWidth:1}}}},credits:{enabled:!1},series:[{data:e.data,dashStyle:"shortdash",color:"#cccccc",lineWidth:1,turboThreshold:0}]})},renderPieChart=function(e){return Highcharts.chart(e.elemId,{chart:{type:"pie",events:{load:function(){var t=this,a=t.renderer,s=t.series[0],i=t.plotLeft+s.center[0],l=t.plotTop+s.center[1];this.innerText=a.text(e.data[0].y,i,l).attr({"text-anchor":"middle","font-size":"24px","font-weight":"bold",fill:e.colors[0],"font-family":"Helvetica,Arial,sans-serif"}).add()},render:function(){this.innerText.attr({text:e.data[0].y})}}},title:{text:e.title},plotOptions:{pie:{innerSize:"80%",dataLabels:{enabled:!1}}},credits:{enabled:!1},tooltip:{formatter:function(){return void 0!=this.key&&''+this.point.name+": "+this.y+"
"}},series:[{data:e.data,colors:e.colors}]})},updateMap=function(e){map&&(bubbles=[],$.each(campaign.results,function(e,t){if(0==t.latitude&&0==t.longitude)return!0;newIP=!0,$.each(bubbles,function(e,a){if(a.ip==t.ip)return bubbles[e].radius+=1,newIP=!1,!1}),newIP&&bubbles.push({latitude:t.latitude,longitude:t.longitude,name:t.ip,fillKey:"point",radius:2})}),map.bubbles(bubbles))},setRefresh;$(document).ready(function(){Highcharts.setOptions({global:{useUTC:!1}}),load(),setRefresh=setTimeout(refresh,6e4)}); \ No newline at end of file +function dismiss(){$("#modal\\.flashes").empty(),$("#modal").modal("hide"),$("#resultsTable").dataTable().DataTable().clear().draw()}function deleteCampaign(){swal({title:"Are you sure?",text:"This will delete the campaign. This can't be undone!",type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete Campaign",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,showLoaderOnConfirm:!0,preConfirm:function(){return new Promise(function(e,t){api.campaignId.delete(campaign.id).success(function(t){e()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(){swal("Campaign Deleted!","This campaign has been deleted!","success"),$('button:contains("OK")').on("click",function(){location.href="/campaigns"})})}function completeCampaign(){swal({title:"Are you sure?",text:"Gophish will stop processing events for this campaign",type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Complete Campaign",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,showLoaderOnConfirm:!0,preConfirm:function(){return new Promise(function(e,t){api.campaignId.complete(campaign.id).success(function(t){e()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(){swal("Campaign Completed!","This campaign has been completed!","success"),$("#complete_button")[0].disabled=!0,$("#complete_button").text("Completed!"),doPoll=!1})}function exportAsCSV(e){exportHTML=$("#exportButton").html();var t=null,a=campaign.name+" - "+capitalize(e)+".csv";switch(e){case"results":t=campaign.results;break;case"events":t=campaign.timeline}if(t){$("#exportButton").html('');var s=Papa.unparse(t,{}),i=new Blob([s],{type:"text/csv;charset=utf-8;"});if(navigator.msSaveBlob)navigator.msSaveBlob(i,a);else{var l=window.URL.createObjectURL(i),n=document.createElement("a");n.href=l,n.setAttribute("download",a),document.body.appendChild(n),n.click(),document.body.removeChild(n)}$("#exportButton").html(exportHTML)}}function replay(e){function t(){form.attr({action:url}),form.appendTo("body").submit().remove()}request=campaign.timeline[e],details=JSON.parse(request.details),url=null,form=$("").attr({method:"POST",target:"_blank"}),$.each(Object.keys(details.payload),function(e,t){return"rid"==t||("__original_url"==t?(url=details.payload[t],!0):void $("").attr({name:t}).val(details.payload[t]).appendTo(form))}),swal({title:"Where do you want the credentials submitted to?",input:"text",showCancelButton:!0,inputPlaceholder:"http://example.com/login",inputValue:url||"",inputValidator:function(e){return new Promise(function(t,a){e?t():a("Invalid URL.")})}}).then(function(e){url=e,t()})}function renderTimeline(e){return console.log(e),record={first_name:e[2],last_name:e[3],email:e[4],position:e[5],status:e[6],reported:e[7],send_date:e[8]},results='
Timeline for '+escapeHtml(record.first_name)+" "+escapeHtml(record.last_name)+'
Email: '+escapeHtml(record.email)+'
',$.each(campaign.timeline,function(e,t){t.email&&t.email!=record.email||(results+='
',results+='
'+escapeHtml(t.message)+' '+moment.utc(t.time).local().format("MMMM Do YYYY h:mm:ss a")+"",t.details&&(details=JSON.parse(t.details),"Clicked Link"!=t.message&&"Submitted Data"!=t.message||(deviceView=renderDevice(details),deviceView&&(results+=deviceView)),"Submitted Data"==t.message&&(results+='
',results+='
View Details
'),details.payload&&(results+='
',results+=' ',results+=" ",$.each(Object.keys(details.payload),function(e,t){if("rid"==t)return!0;results+=" ",results+=" ",results+=" ",results+=" "}),results+="
ParameterValue(s)
"+escapeHtml(t)+""+escapeHtml(details.payload[t])+"
",results+="
"),details.error&&(results+='
View Details
',results+='
',results+='Error '+details.error,results+="
")),results+="
")}),"Scheduled"!=record.status&&"Retrying"!=record.status||(results+='
',results+='
Scheduled to send at '+record.send_date+""),results+="
",results}function createStatusLabel(e,t){var a=statuses[e].label||"label-default",s=''+e+"";if("Scheduled"==e||"Retrying"==e){s=''+e+""}return s}function poll(){api.campaignId.results(campaign.id).success(function(e){campaign=e;var t=[];$.each(campaign.timeline,function(e,a){var s=moment.utc(a.time).local();t.push({email:a.email,x:s.valueOf(),y:1})});var t=[];$.each(campaign.timeline,function(e,a){var s=moment.utc(a.time).local();t.push({email:a.email,message:a.message,x:s.valueOf(),y:1,marker:{fillColor:statuses[a.message].color}})}),$("#timeline_chart").highcharts().series[0].update({data:t});var a={};Object.keys(statusMapping).forEach(function(e){a[e]=0}),$.each(campaign.results,function(e,t){a[t.status]++,t.reported&&a["Email Reported"]++;for(var s=progressListing.indexOf(t.status),e=0;e":""},targets:[7]}]}),resultsTable.clear();var a={},s=[];Object.keys(statusMapping).forEach(function(e){a[e]=0}),$.each(campaign.results,function(e,t){resultsTable.row.add([t.id,'',escapeHtml(t.first_name)||"",escapeHtml(t.last_name)||"",escapeHtml(t.email)||"",escapeHtml(t.position)||"",t.status,t.reported,moment(t.send_date).format("MMMM Do YYYY, h:mm:ss a")]),a[t.status]++,t.reported&&a["Email Reported"]++;for(var s=progressListing.indexOf(t.status),e=0;e',s="laptop";t.device.type&&("tablet"!=t.device.type&&"mobile"!=t.device.type||(s=t.device.type));var i="";t.device.vendor&&"microsoft"==(i=t.device.vendor.toLowerCase())&&(i="windows");var l="Unknown";t.os.name&&(l=t.os.name,"Mac OS"==l?i="apple":"Windows"==l&&(i="windows"),t.device.vendor&&t.device.model&&(l=t.device.vendor+" "+t.device.model)),t.os.version&&(l=l+" (OS Version: "+t.os.version+")"),deviceString='
'+escapeHtml(l)+"
",a+=deviceString;var n="Unknown",r="info-circle",o="";return t.browser&&t.browser.name&&(n=t.browser.name,n=n.replace("Mobile ",""),n&&"ie"==(r=n.toLowerCase())&&(r="internet-explorer"),o="(Version: "+t.browser.version+")"),a+='
'+n+" "+o+"
",a+="
"},renderTimelineChart=function(e){return Highcharts.chart("timeline_chart",{chart:{zoomType:"x",type:"line",height:"200px"},title:{text:"Campaign Timeline"},xAxis:{type:"datetime",dateTimeLabelFormats:{second:"%l:%M:%S",minute:"%l:%M",hour:"%l:%M",day:"%b %d, %Y",week:"%b %d, %Y",month:"%b %Y"}},yAxis:{min:0,max:2,visible:!1,tickInterval:1,labels:{enabled:!1},title:{text:""}},tooltip:{formatter:function(){return Highcharts.dateFormat("%A, %b %d %l:%M:%S %P",new Date(this.x))+"
Event: "+this.point.message+"
Email: "+this.point.email+""}},legend:{enabled:!1},plotOptions:{series:{marker:{enabled:!0,symbol:"circle",radius:3},cursor:"pointer"},line:{states:{hover:{lineWidth:1}}}},credits:{enabled:!1},series:[{data:e.data,dashStyle:"shortdash",color:"#cccccc",lineWidth:1,turboThreshold:0}]})},renderPieChart=function(e){return Highcharts.chart(e.elemId,{chart:{type:"pie",events:{load:function(){var t=this,a=t.renderer,s=t.series[0],i=t.plotLeft+s.center[0],l=t.plotTop+s.center[1];this.innerText=a.text(e.data[0].y,i,l).attr({"text-anchor":"middle","font-size":"24px","font-weight":"bold",fill:e.colors[0],"font-family":"Helvetica,Arial,sans-serif"}).add()},render:function(){this.innerText.attr({text:e.data[0].y})}}},title:{text:e.title},plotOptions:{pie:{innerSize:"80%",dataLabels:{enabled:!1}}},credits:{enabled:!1},tooltip:{formatter:function(){return void 0!=this.key&&''+this.point.name+": "+this.y+"
"}},series:[{data:e.data,colors:e.colors}]})},updateMap=function(e){map&&(bubbles=[],$.each(campaign.results,function(e,t){if(0==t.latitude&&0==t.longitude)return!0;newIP=!0,$.each(bubbles,function(e,a){if(a.ip==t.ip)return bubbles[e].radius+=1,newIP=!1,!1}),newIP&&bubbles.push({latitude:t.latitude,longitude:t.longitude,name:t.ip,fillKey:"point",radius:2})}),map.bubbles(bubbles))},setRefresh;$(document).ready(function(){Highcharts.setOptions({global:{useUTC:!1}}),load(),setRefresh=setTimeout(refresh,6e4)}); \ No newline at end of file diff --git a/static/js/dist/app/campaigns.min.js b/static/js/dist/app/campaigns.min.js index 1142a2c2..898cdf01 100644 --- a/static/js/dist/app/campaigns.min.js +++ b/static/js/dist/app/campaigns.min.js @@ -1 +1 @@ -function launch(){swal({title:"Are you sure?",text:"This will schedule the campaign to be launched.",type:"question",animation:!1,showCancelButton:!0,confirmButtonText:"Launch",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,showLoaderOnConfirm:!0,preConfirm:function(){return new Promise(function(e,a){groups=[],$("#users").select2("data").forEach(function(e){groups.push({name:e.text})}),campaign={name:$("#name").val(),template:{name:$("#template").select2("data")[0].text},url:$("#url").val(),page:{name:$("#page").select2("data")[0].text},smtp:{name:$("#profile").select2("data")[0].text},launch_date:moment($("#launch_date").val(),"MM/DD/YYYY hh:mm a").utc().format(),groups:groups},console.log("Launching campaign at time: "+campaign.launch_date),api.campaigns.post(campaign).success(function(a){e(),campaign=a}).error(function(e){$("#modal\\.flashes").empty().append('
'+e.responseJSON.message+"
"),swal.close()})})}}).then(function(){swal("Campaign Scheduled!","This campaign has been scheduled for launch!","success"),$('button:contains("OK")').on("click",function(){window.location="/campaigns/"+campaign.id.toString()})})}function sendTestEmail(){var e={template:{name:$("#template").select2("data")[0].text},first_name:$("input[name=to_first_name]").val(),last_name:$("input[name=to_last_name]").val(),email:$("input[name=to_email]").val(),position:$("input[name=to_position]").val(),url:$("#url").val(),page:{name:$("#page").select2("data")[0].text},smtp:{name:$("#profile").select2("data")[0].text}};btnHtml=$("#sendTestModalSubmit").html(),$("#sendTestModalSubmit").html(' Sending'),api.send_test_email(e).success(function(e){$("#sendTestEmailModal\\.flashes").empty().append('
Email Sent!
'),$("#sendTestModalSubmit").html(btnHtml)}).error(function(e){$("#sendTestEmailModal\\.flashes").empty().append('
'+e.responseJSON.message+"
"),$("#sendTestModalSubmit").html(btnHtml)})}function dismiss(){$("#modal\\.flashes").empty(),$("#name").val(""),$("#template").val("").change(),$("#page").val("").change(),$("#url").val(""),$("#profile").val("").change(),$("#users").val("").change(),$("#modal").modal("hide")}function deleteCampaign(e){swal({title:"Are you sure?",text:"This will delete the campaign. This can't be undone!",type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete "+campaigns[e].name,confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,preConfirm:function(){return new Promise(function(a,t){api.campaignId.delete(campaigns[e].id).success(function(e){a()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(){swal("Campaign Deleted!","This campaign has been deleted!","success"),$('button:contains("OK")').on("click",function(){location.reload()})})}function setupOptions(){api.groups.get().success(function(e){if(0==e.length)return modalError("No groups found!"),!1;var a=$.map(e,function(e){return e.text=e.name,e});$("#users.form-control").select2({placeholder:"Select Groups",data:a})}),api.templates.get().success(function(e){if(0==e.length)return modalError("No templates found!"),!1;var a=$.map(e,function(e){return e.text=e.name,e});$("#template.form-control").select2({placeholder:"Select a Template",data:a})}),api.pages.get().success(function(e){if(0==e.length)return modalError("No pages found!"),!1;var a=$.map(e,function(e){return e.text=e.name,e});$("#page.form-control").select2({placeholder:"Select a Landing Page",data:a})}),api.SMTP.get().success(function(e){if(0==e.length)return modalError("No profiles found!"),!1;var a=$.map(e,function(e){return e.text=e.name,e});$("#profile.form-control").select2({placeholder:"Select a Sending Profile",data:a})})}function edit(e){setupOptions()}function copy(e){setupOptions(),api.campaignId.get(campaigns[e].id).success(function(e){$("#name").val("Copy of "+e.name),e.template.id?$("#template").select2("val",e.template.id.toString()):$("#template").select2({placeholder:e.template.name}),e.page.id?$("#page").select2("val",e.page.id.toString()):$("#page").select2({placeholder:e.page.name}),e.smtp.id?$("#profile").select2("val",e.smtp.id.toString()):$("#profile").select2({placeholder:e.smtp.name}),$("#url").val(e.url)}).error(function(e){$("#modal\\.flashes").empty().append('
'+e.responseJSON.message+"
")})}var labels={"In progress":"label-primary",Queued:"label-info",Completed:"label-success","Emails Sent":"label-success",Error:"label-danger"},campaigns=[],campaign={};$(document).ready(function(){$("#launch_date").datetimepicker({widgetPositioning:{vertical:"bottom"},showTodayButton:!0,defaultDate:moment()}),$(".modal").on("hidden.bs.modal",function(e){$(this).removeClass("fv-modal-stack"),$("body").data("fv_open_modals",$("body").data("fv_open_modals")-1)}),$(".modal").on("shown.bs.modal",function(e){void 0===$("body").data("fv_open_modals")&&$("body").data("fv_open_modals",0),$(this).hasClass("fv-modal-stack")||($(this).addClass("fv-modal-stack"),$("body").data("fv_open_modals",$("body").data("fv_open_modals")+1),$(this).css("z-index",1040+10*$("body").data("fv_open_modals")),$(".modal-backdrop").not(".fv-modal-stack").css("z-index",1039+10*$("body").data("fv_open_modals")),$(".modal-backdrop").not("fv-modal-stack").addClass("fv-modal-stack"))}),$(document).on("hidden.bs.modal",".modal",function(){$(".modal:visible").length&&$(document.body).addClass("modal-open")}),$("#modal").on("hidden.bs.modal",function(e){dismiss()}),api.campaigns.summary().success(function(e){campaigns=e.campaigns,$("#loading").hide(),campaigns.length>0?($("#campaignTable").show(),campaignTable=$("#campaignTable").DataTable({columnDefs:[{orderable:!1,targets:"no-sort"}],order:[[1,"desc"]]}),$.each(campaigns,function(e,a){console.log(a),console.log(a.created_date),label=labels[a.status]||"label-default";var t;if(moment(a.launch_date).isAfter(moment())){t="Scheduled to start: "+moment(a.launch_date).format("MMMM Do YYYY, h:mm:ss a");var n=t+"

Number of recipients: "+a.stats.total}else{t="Launch Date: "+moment(a.launch_date).format("MMMM Do YYYY, h:mm:ss a");var n=t+"

Number of recipients: "+a.stats.total+"

Emails opened: "+a.stats.opened+"

Emails clicked: "+a.stats.clicked+"

Submitted Credentials: "+a.stats.submitted_data+"

Errors : "+a.stats.error+"Reported : "+a.stats.reported}campaignTable.row.add([escapeHtml(a.name),moment(a.created_date).format("MMMM Do YYYY, h:mm:ss a"),''+a.status+"",""]).draw(),$('[data-toggle="tooltip"]').tooltip()})):$("#emptyMessage").show()}).error(function(){$("#loading").hide(),errorFlash("Error fetching campaigns")}),$.fn.select2.defaults.set("width","100%"),$.fn.select2.defaults.set("dropdownParent",$("#modal_body")),$.fn.select2.defaults.set("theme","bootstrap"),$.fn.select2.defaults.set("sorter",function(e){return e.sort(function(e,a){return e.text.toLowerCase()>a.text.toLowerCase()?1:e.text.toLowerCase() '+e.responseJSON.message+"
"),swal.close()})})}}).then(function(){swal("Campaign Scheduled!","This campaign has been scheduled for launch!","success"),$('button:contains("OK")').on("click",function(){window.location="/campaigns/"+campaign.id.toString()})})}function sendTestEmail(){var e={template:{name:$("#template").select2("data")[0].text},first_name:$("input[name=to_first_name]").val(),last_name:$("input[name=to_last_name]").val(),email:$("input[name=to_email]").val(),position:$("input[name=to_position]").val(),url:$("#url").val(),page:{name:$("#page").select2("data")[0].text},smtp:{name:$("#profile").select2("data")[0].text}};btnHtml=$("#sendTestModalSubmit").html(),$("#sendTestModalSubmit").html(' Sending'),api.send_test_email(e).success(function(e){$("#sendTestEmailModal\\.flashes").empty().append('
Email Sent!
'),$("#sendTestModalSubmit").html(btnHtml)}).error(function(e){$("#sendTestEmailModal\\.flashes").empty().append('
'+e.responseJSON.message+"
"),$("#sendTestModalSubmit").html(btnHtml)})}function dismiss(){$("#modal\\.flashes").empty(),$("#name").val(""),$("#template").val("").change(),$("#page").val("").change(),$("#url").val(""),$("#profile").val("").change(),$("#users").val("").change(),$("#modal").modal("hide")}function deleteCampaign(e){swal({title:"Are you sure?",text:"This will delete the campaign. This can't be undone!",type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete "+campaigns[e].name,confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,preConfirm:function(){return new Promise(function(a,t){api.campaignId.delete(campaigns[e].id).success(function(e){a()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(){swal("Campaign Deleted!","This campaign has been deleted!","success"),$('button:contains("OK")').on("click",function(){location.reload()})})}function setupOptions(){api.groups.get().success(function(e){if(0==e.length)return modalError("No groups found!"),!1;var a=$.map(e,function(e){return e.text=e.name,e});$("#users.form-control").select2({placeholder:"Select Groups",data:a})}),api.templates.get().success(function(e){if(0==e.length)return modalError("No templates found!"),!1;var a=$.map(e,function(e){return e.text=e.name,e});$("#template.form-control").select2({placeholder:"Select a Template",data:a})}),api.pages.get().success(function(e){if(0==e.length)return modalError("No pages found!"),!1;var a=$.map(e,function(e){return e.text=e.name,e});$("#page.form-control").select2({placeholder:"Select a Landing Page",data:a})}),api.SMTP.get().success(function(e){if(0==e.length)return modalError("No profiles found!"),!1;var a=$.map(e,function(e){return e.text=e.name,e});$("#profile.form-control").select2({placeholder:"Select a Sending Profile",data:a})})}function edit(e){setupOptions()}function copy(e){setupOptions(),api.campaignId.get(campaigns[e].id).success(function(e){$("#name").val("Copy of "+e.name),e.template.id?$("#template").select2("val",e.template.id.toString()):$("#template").select2({placeholder:e.template.name}),e.page.id?$("#page").select2("val",e.page.id.toString()):$("#page").select2({placeholder:e.page.name}),e.smtp.id?$("#profile").select2("val",e.smtp.id.toString()):$("#profile").select2({placeholder:e.smtp.name}),$("#url").val(e.url)}).error(function(e){$("#modal\\.flashes").empty().append('
'+e.responseJSON.message+"
")})}var labels={"In progress":"label-primary",Queued:"label-info",Completed:"label-success","Emails Sent":"label-success",Error:"label-danger"},campaigns=[],campaign={};$(document).ready(function(){$("#launch_date").datetimepicker({widgetPositioning:{vertical:"bottom"},showTodayButton:!0,defaultDate:moment()}),$("#send_by_date").datetimepicker({widgetPositioning:{vertical:"bottom"},showTodayButton:!0,useCurrent:!1}),$(".modal").on("hidden.bs.modal",function(e){$(this).removeClass("fv-modal-stack"),$("body").data("fv_open_modals",$("body").data("fv_open_modals")-1)}),$(".modal").on("shown.bs.modal",function(e){void 0===$("body").data("fv_open_modals")&&$("body").data("fv_open_modals",0),$(this).hasClass("fv-modal-stack")||($(this).addClass("fv-modal-stack"),$("body").data("fv_open_modals",$("body").data("fv_open_modals")+1),$(this).css("z-index",1040+10*$("body").data("fv_open_modals")),$(".modal-backdrop").not(".fv-modal-stack").css("z-index",1039+10*$("body").data("fv_open_modals")),$(".modal-backdrop").not("fv-modal-stack").addClass("fv-modal-stack"))}),$(document).on("hidden.bs.modal",".modal",function(){$(".modal:visible").length&&$(document.body).addClass("modal-open")}),$("#modal").on("hidden.bs.modal",function(e){dismiss()}),api.campaigns.summary().success(function(e){campaigns=e.campaigns,$("#loading").hide(),campaigns.length>0?($("#campaignTable").show(),campaignTable=$("#campaignTable").DataTable({columnDefs:[{orderable:!1,targets:"no-sort"}],order:[[1,"desc"]]}),$.each(campaigns,function(e,a){console.log(a),console.log(a.created_date),label=labels[a.status]||"label-default";var t;if(moment(a.launch_date).isAfter(moment())){t="Scheduled to start: "+moment(a.launch_date).format("MMMM Do YYYY, h:mm:ss a");var n=t+"

Number of recipients: "+a.stats.total}else{t="Launch Date: "+moment(a.launch_date).format("MMMM Do YYYY, h:mm:ss a");var n=t+"

Number of recipients: "+a.stats.total+"

Emails opened: "+a.stats.opened+"

Emails clicked: "+a.stats.clicked+"

Submitted Credentials: "+a.stats.submitted_data+"

Errors : "+a.stats.error+"Reported : "+a.stats.reported}campaignTable.row.add([escapeHtml(a.name),moment(a.created_date).format("MMMM Do YYYY, h:mm:ss a"),''+a.status+"",""]).draw(),$('[data-toggle="tooltip"]').tooltip()})):$("#emptyMessage").show()}).error(function(){$("#loading").hide(),errorFlash("Error fetching campaigns")}),$.fn.select2.defaults.set("width","100%"),$.fn.select2.defaults.set("dropdownParent",$("#modal_body")),$.fn.select2.defaults.set("theme","bootstrap"),$.fn.select2.defaults.set("sorter",function(e){return e.sort(function(e,a){return e.text.toLowerCase()>a.text.toLowerCase()?1:e.text.toLowerCase()Timeline for ' + escapeHtml(record.first_name) + ' ' + escapeHtml(record.last_name) + diff --git a/static/js/src/app/campaigns.js b/static/js/src/app/campaigns.js index 28b0189b..ef929385 100644 --- a/static/js/src/app/campaigns.js +++ b/static/js/src/app/campaigns.js @@ -33,6 +33,10 @@ function launch() { }); }) // Validate our fields + var send_by_date = $("#send_by_date").val() + if (send_by_date != "") { + send_by_date = moment(send_by_date, "MM/DD/YYYY hh:mm a").utc().format() + } campaign = { name: $("#name").val(), template: { @@ -46,7 +50,8 @@ function launch() { name: $("#profile").select2("data")[0].text }, launch_date: moment($("#launch_date").val(), "MM/DD/YYYY hh:mm a").utc().format(), - groups: groups + send_by_date: send_by_date || null, + groups: groups, } console.log("Launching campaign at time: " + campaign.launch_date) // Submit the campaign @@ -267,6 +272,13 @@ $(document).ready(function () { "showTodayButton": true, "defaultDate": moment() }) + $("#send_by_date").datetimepicker({ + "widgetPositioning": { + "vertical": "bottom" + }, + "showTodayButton": true, + "useCurrent": false + }) // Setup multiple modals // Code based on http://miles-by-motorcycle.com/static/bootstrap-modal/index.html $('.modal').on('hidden.bs.modal', function (event) { @@ -366,4 +378,4 @@ $(document).ready(function () { return 0; }); }) -}) +}) \ No newline at end of file diff --git a/templates/campaigns.html b/templates/campaigns.html index db253904..11b885a9 100644 --- a/templates/campaigns.html +++ b/templates/campaigns.html @@ -3,24 +3,35 @@
- +
 
@@ -62,87 +74,105 @@