From d046da81a5aa31302745577dfd34f4bb456efc94 Mon Sep 17 00:00:00 2001 From: Glenn Wilkinson Date: Thu, 9 Jul 2020 09:19:31 +0100 Subject: [PATCH] Initial work on reported emails --- controllers/api/reported.go | 134 ++++++++++ controllers/api/server.go | 4 + controllers/route.go | 35 +++ .../20200514000000_0.9.0_reported_emails.sql | 10 + .../20200514000000_0.9.0_reported_emails.sql | 10 + imap/monitor.go | 29 ++- models/reported.go | 136 ++++++++++ static/js/src/app/gophish.js | 21 ++ static/js/src/app/reported.js | 243 ++++++++++++++++++ templates/nav.html | 3 + templates/reported.html | 111 ++++++++ 11 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 controllers/api/reported.go create mode 100644 db/db_mysql/migrations/20200514000000_0.9.0_reported_emails.sql create mode 100644 db/db_sqlite3/migrations/20200514000000_0.9.0_reported_emails.sql create mode 100644 models/reported.go create mode 100644 static/js/src/app/reported.js create mode 100644 templates/reported.html diff --git a/controllers/api/reported.go b/controllers/api/reported.go new file mode 100644 index 00000000..8874d85d --- /dev/null +++ b/controllers/api/reported.go @@ -0,0 +1,134 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "strconv" + "strings" + + ctx "github.com/gophish/gophish/context" + "github.com/gophish/gophish/models" + "github.com/gorilla/mux" +) + +/* +// ReportedEmailsSave handles requests for the /api/reportedemails/save endpoint +func (as *Server) ReportedEmailsSave(w http.ResponseWriter, r *http.Request) { + + if r.Method == "POST" { + em := models.ReportedEmail{} + err := json.NewDecoder(r.Body).Decode(&em) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Invalid email data."}, http.StatusBadRequest) + return + } + + err = models.SaveReportedEmail(&em) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + JSONResponse(w, models.Response{Success: true, Message: "Successfully saved reported email."}, http.StatusCreated) + + } + +}*/ + +// ReportedEmailAttachment handles requests for the /api/reported/attachments endpoint +func (as *Server) ReportedEmailAttachment(w http.ResponseWriter, r *http.Request) { + + vars := mux.Vars(r) + id, _ := strconv.ParseInt(vars["id"], 0, 64) + + att, err := models.GetReportedEmailAttachment(ctx.Get(r, "user_id").(int64), id) + + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + //JSONResponse(w, ems, http.StatusOK) + data, err := base64.StdEncoding.DecodeString(att.Content) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", att.Header) + w.WriteHeader(http.StatusOK) + w.Write(data) + +} + +// ReportedEmails handles requests for the /api/reported endpoint +func (as *Server) ReportedEmails(w http.ResponseWriter, r *http.Request) { + + vars := mux.Vars(r) + emailid := int64(-1) + offset := int64(-1) + limit := int64(-1) + + if _, ok := vars["id"]; ok { + emailid, _ = strconv.ParseInt(vars["id"], 0, 64) + } + + if _, ok := vars["range"]; ok { + r := strings.Split(vars["range"], ",") + offset, _ = strconv.ParseInt(r[0], 0, 64) + limit, _ = strconv.ParseInt(r[1], 0, 64) + } + + switch { + // GET: Return all emails + case r.Method == "GET": + + ems, err := models.GetReportedEmails(ctx.Get(r, "user_id").(int64), emailid, limit, offset) + + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + JSONResponse(w, ems, http.StatusOK) + + // PUT: Update an email + case r.Method == "PUT": + // Get existing email by id + ems, err := models.GetReportedEmail(ctx.Get(r, "user_id").(int64), emailid) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + if len(ems) > 0 { + em := ems[0] + err := json.NewDecoder(r.Body).Decode(&em) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Invalid data"}, http.StatusBadRequest) + return + } + err = models.SaveReportedEmail(em) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Failed to update email"}, http.StatusBadRequest) + return + } + + JSONResponse(w, models.Response{Success: true, Message: "Email record udpated"}, http.StatusCreated) + } else { + JSONResponse(w, models.Response{Success: false, Message: "Unable to locate email"}, http.StatusCreated) + } + case r.Method == "DELETE": + ems, err := models.GetReportedEmail(ctx.Get(r, "user_id").(int64), emailid) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) + return + } + if len(ems) > 0 { + err := models.DeleteReportedEmail(emailid) + if err != nil { + JSONResponse(w, models.Response{Success: false, Message: "Failed to delete email"}, http.StatusBadRequest) + return + } + JSONResponse(w, models.Response{Success: true, Message: "Email deleted"}, http.StatusCreated) + } else { + JSONResponse(w, models.Response{Success: false, Message: "Unable to locate email"}, http.StatusCreated) + } + } +} diff --git a/controllers/api/server.go b/controllers/api/server.go index 9fcc9ee3..77c33d9b 100644 --- a/controllers/api/server.go +++ b/controllers/api/server.go @@ -76,6 +76,10 @@ func (as *Server) registerRoutes() { router.HandleFunc("/webhooks/", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem))) router.HandleFunc("/webhooks/{id:[0-9]+}/validate", mid.Use(as.ValidateWebhook, mid.RequirePermission(models.PermissionModifySystem))) router.HandleFunc("/webhooks/{id:[0-9]+}", mid.Use(as.Webhook, mid.RequirePermission(models.PermissionModifySystem))) + router.HandleFunc("/reported/", as.ReportedEmails) // Return all reported emails + router.HandleFunc("/reported/{id:[0-9]+}", as.ReportedEmails) // Fetch an individual email e.g /reported/3 + router.HandleFunc("/reported/{range:[0-9]+,[0-9]+}", as.ReportedEmails) // Fetch a range of emails e.g. /reported/0,10 + router.HandleFunc("/reported/attachment/{id:[0-9]+}", as.ReportedEmailAttachment) // Download attachment as.handler = router } diff --git a/controllers/route.go b/controllers/route.go index 576e7dd3..d3975739 100644 --- a/controllers/route.go +++ b/controllers/route.go @@ -4,9 +4,11 @@ import ( "compress/gzip" "context" "crypto/tls" + "encoding/base64" "html/template" "net/http" "net/url" + "strconv" "time" "github.com/NYTimes/gziphandler" @@ -111,6 +113,9 @@ func (as *AdminServer) registerRoutes() { router.HandleFunc("/settings", mid.Use(as.Settings, mid.RequireLogin)) router.HandleFunc("/users", mid.Use(as.UserManagement, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin)) router.HandleFunc("/webhooks", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin)) + router.HandleFunc("/reported", mid.Use(as.Reported, mid.RequireLogin)) + router.HandleFunc("/reported/attachment/{id:[0-9]+}", mid.Use(as.ReportedEmailAttachment, mid.RequireLogin)) + // Create the API routes api := api.NewServer(api.WithWorker(as.worker)) router.PathPrefix("/api/").Handler(api) @@ -246,6 +251,36 @@ func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) { getTemplate(w, "webhooks").ExecuteTemplate(w, "base", params) } +// Reported handles the display of user reported emails that aren't Gophish campaigns +func (as *AdminServer) Reported(w http.ResponseWriter, r *http.Request) { + params := newTemplateParams(r) + params.Title = "Reported Emails" + getTemplate(w, "reported").ExecuteTemplate(w, "base", params) +} + +// ReportedEmailAttachment retrieves an attachment by id +func (as *AdminServer) ReportedEmailAttachment(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + user := ctx.Get(r, "user").(models.User) + attID, _ := strconv.ParseInt(vars["id"], 0, 64) + + att, err := models.GetReportedEmailAttachment(user.Id, attID) + if err != nil { + log.Error(err) + w.Write([]byte("Unable to query attachment")) + } else { + + data, err := base64.StdEncoding.DecodeString(att.Content) + if err != nil { + w.Write([]byte("Unable to load attachment")) + } else { + w.Header().Set("Content-Type", att.Header) + w.WriteHeader(http.StatusOK) + w.Write(data) + } + } +} + // Login handles the authentication flow for a user. If credentials are valid, // a session is created func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) { diff --git a/db/db_mysql/migrations/20200514000000_0.9.0_reported_emails.sql b/db/db_mysql/migrations/20200514000000_0.9.0_reported_emails.sql new file mode 100644 index 00000000..ea897ecf --- /dev/null +++ b/db/db_mysql/migrations/20200514000000_0.9.0_reported_emails.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- SQL in section 'Up' is executed when this migration is applied +CREATE TABLE IF NOT EXISTS `reported` (id bigint primary key autoincrement, user_id integer ,reported_by_name varchar(255), reported_by_email varchar(255), reported_time datetime, reported_html varchar(255), reported_text varchar(255), reported_subject varchar(255),imap_uid varchar(255), status varchar(255), notes varchar(255)); + +CREATE TABLE IF NOT EXISTS `reported_attachments` (id bigint primary key autoincrement, rid bigint, filename varchar(255), header varchar(255), size bigint, content varchar(255)); + +-- +goose Down +-- SQL section 'Down' is executed when this migration is rolled back +DROP TABLE `reported`; +DROP TABLE `reported_attachments`; diff --git a/db/db_sqlite3/migrations/20200514000000_0.9.0_reported_emails.sql b/db/db_sqlite3/migrations/20200514000000_0.9.0_reported_emails.sql new file mode 100644 index 00000000..3da30dce --- /dev/null +++ b/db/db_sqlite3/migrations/20200514000000_0.9.0_reported_emails.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- SQL in section 'Up' is executed when this migration is applied +CREATE TABLE IF NOT EXISTS "reported" ("id" integer primary key autoincrement, "user_id" integer ,"reported_by_name" varchar(255), "reported_by_enaio" varchar(255) "reported_time" datetime, "reported_html" varchar(255), "reported_text" varchar(255), "reported_subject" varchar(255),"imap_uid" varchar(255), "status" varchar(255), "notes" varchar(255)); + +CREATE TABLE IF NOT EXISTS "reported_attachments" ("id" integer primary key autoincrement, "rid" integer, "filename" varchar(255), "header" varchar(255), "size" integer, "content" varchar(255)); + +-- +goose Down +-- SQL section 'Down' is executed when this migration is rolled back +DROP TABLE "reported"; +DROP TABLE "reported_attachments"; diff --git a/imap/monitor.go b/imap/monitor.go index e9b58484..9f9b60b8 100644 --- a/imap/monitor.go +++ b/imap/monitor.go @@ -9,6 +9,8 @@ package imap import ( "bytes" "context" + "encoding/base64" + "net/mail" "regexp" "strconv" "strings" @@ -156,8 +158,33 @@ func checkForNewEmails(im models.IMAP) { log.Errorf("Error searching email for rids from user '%s': %s", m.Email.From, err.Error()) } else { if len(rids) < 1 { - // In the future this should be an alert in Gophish log.Infof("User '%s' reported email with subject '%s'. This is not a GoPhish campaign; you should investigate it.", m.Email.From, m.Email.Subject) + + // Save reported email to the database + atts := []*models.ReportedAttachment{} + for _, a := range m.Attachments { + na := &models.ReportedAttachment{Filename: a.Filename, Header: a.Header.Get("Content-Type"), Size: len(a.Content), Content: base64.StdEncoding.EncodeToString(a.Content)} + atts = append(atts, na) + } + + e, err := mail.ParseAddress(m.Email.From) + if err != nil { + log.Error(err) + } + + em := &models.ReportedEmail{ + UserId: im.UserId, + ReportedByName: e.Name, + ReportedByEmail: e.Address, + ReportedHTML: string(m.HTML), + ReportedText: string(m.Text), + ReportedSubject: string(m.Subject), + IMAPUID: -1, // https://github.com/emersion/go-imap/issues/353 + ReportedTime: time.Now().UTC(), + Attachments: atts, + Status: "Unknown"} + + models.SaveReportedEmail(em) } for rid := range rids { log.Infof("User '%s' reported email with rid %s", m.Email.From, rid) diff --git a/models/reported.go b/models/reported.go new file mode 100644 index 00000000..09e693cf --- /dev/null +++ b/models/reported.go @@ -0,0 +1,136 @@ +package models + +import ( + "time" + + log "github.com/gophish/gophish/logger" +) + +// ReportedEmail contains the attributes for non-campaign emails reported by users +type ReportedEmail struct { + //Id int64 `json:"id" gorm:"column:id; primary_key:yes;AUTO_INCREMENT"` + Id int64 `json:"id"` + UserId int64 `json:"user_id"` // ID of the user account + ReportedByName string `json:"reported_by_name"` // Email of the user reporting the email + ReportedByEmail string `json:"reported_by_email"` // Email of the user reporting the email + ReportedTime time.Time `json:"reported_time"` // When the email was reported + ReportedHTML string `json:"reported_html"` + ReportedText string `json:"reported_text"` + ReportedSubject string `json:"reported_subject"` + + /*EmailFrom string `json:"email_from"` + EmailTo string `json:"email_to"` + EmailCC string `json:"email_cc"` + EmailSubject string `json:"email_subject"` + EmailTime string `json:"email_time"` + EmailText string `json:"email_text"` + EmailHTML string `json:"email_html"` + EmailHeaders string `json:"email_headers"` + EmailBlob string `json:"email_blob"`*/ + + IMAPUID int64 `json:"imap_uid"` + Status string `json:"status"` + Notes string `json:"notes"` // Free form notes for operator to give additional info + + Attachments []*ReportedAttachment `json:"attachments" gorm:"foreignkey:Rid"` +} + +//Todo: Add Enabled boolean, and attachments option + +// ReportedEmailAttachment contains email attachments +type ReportedAttachment struct { + Rid int64 `json:"-"` // Foreign key + Id int64 `json:"id"` + Filename string `json:"filename"` + Header string `json:"header"` + Size int `json:"size"` // File size in bytes + Content string `json:"content,omitempty"` +} + +// TableName specifies the database tablename for Gorm to use +func (em ReportedEmail) TableName() string { + return "reported" +} + +// GetReportedEmailAttachment gets an attachment +func GetReportedEmailAttachment(uid, id int64) (ReportedAttachment, error) { + + att := ReportedAttachment{} + + err := db.Debug().Table("reported_attachments").Select("reported_attachments.filename, reported_attachments.header, reported_attachments.content").Joins("left join reported on reported.id = reported_attachments.rid").Where("reported.user_id=? AND reported_attachments.id=?", uid, id).Take(&att).Error + + return att, err +} + +// GetReportedEmails gets reported emails +func GetReportedEmails(uid, emailid, limit, offset int64) ([]*ReportedEmail, error) { + + ems := []*ReportedEmail{} + var err error + + // We have three conditions; fetch all email, fetch one email by id, or fetch a subset of emails by limit and offset + if emailid == -1 { + if offset == -1 { + err = db.Debug().Preload("Attachments").Where("user_id=?", uid).Find(&ems).Error + } else { + err = db.Preload("Attachments").Where("user_id=?", uid).Order("ReportedTime", true).Offset(offset).Limit(limit).Find(&ems).Error + } + } else { + err = db.Preload("Attachments").Where("user_id=? AND id=?", uid, emailid).Find(&ems).Error + } + + if err != nil { + log.Error(err) + } + + // Remove attachmetns and HTML/plaintext content for bulk requests. TODO: Don't retrieve these in the first place. Maybe with joins. + if emailid == -1 { + for _, e := range ems { + e.ReportedHTML = "" + e.ReportedText = "" + if len(e.Attachments) > 0 { // Remove attachment content, but leave other details (filename, size, header) + for _, a := range e.Attachments { + a.Content = "" + } + } + } + } + + // Reverse order so newest emails are first. Could not figure out how to add ORDER to the Preload() in the queries + for i, j := 0, len(ems)-1; i < j; i, j = i+1, j-1 { + ems[i], ems[j] = ems[j], ems[i] + } + return ems, err +} + +// GetReportedEmail gets a single reported emails +func GetReportedEmail(uid, emailid int64) ([]*ReportedEmail, error) { + + ems := []*ReportedEmail{} + err := db.Preload("Attachments").Where("user_id=? AND id=?", uid, emailid).Find(&ems).Error + if err != nil { + log.Error(err) + } + + return ems, err +} + +// SaveReportedEmail updates IMAP settings for a user in the database. +func SaveReportedEmail(em *ReportedEmail) error { + + // Insert into the DB + err := db.Save(em).Error + if err != nil { + log.Error("Unable to save to database: ", err.Error()) + } + return err +} + +// DeleteReportedEmail deletes +func DeleteReportedEmail(id int64) error { + err := db.Where("id=?", id).Delete(&ReportedEmail{}).Error + if err != nil { + log.Error(err) + } + return err +} diff --git a/static/js/src/app/gophish.js b/static/js/src/app/gophish.js index cb17d1da..1999dd9d 100644 --- a/static/js/src/app/gophish.js +++ b/static/js/src/app/gophish.js @@ -277,6 +277,27 @@ var api = { return query("/webhooks/" + id + "/validate", "POST", {}, true) }, }, + // report handles (non-campaign) reported emails + reported: { + get: function() { + return query("/reported/", "GET", {}, !1) + }, + getone: function(id) { + return query("/reported/" + id, "GET", {}, true) + }, + //post: function(email) { + // return query("/reported/" + email.id, "POST", email, true) + //}, + put: function (email) { + return query("/reported/" + email.id, "PUT", email, true) + }, + delete: function(id) { + return query("/reported/" + id, "DELETE", {}, false) + }, + //update: function(e) { + // return query("/reported/update", "POST", e, true) + //} + }, // import handles all of the "import" functions in the api import_email: function (req) { return query("/import/email", "POST", req, false) diff --git a/static/js/src/app/reported.js b/static/js/src/app/reported.js new file mode 100644 index 00000000..f08d0160 --- /dev/null +++ b/static/js/src/app/reported.js @@ -0,0 +1,243 @@ +let emails = [] +let notes = {} + +let statusBtnClass = { + "Unknown" : "btn btn-warning dropdown-toggle statusbtn", + "Safe" : "btn btn-success dropdown-toggle statusbtn", + "Harmful" : "btn btn-danger dropdown-toggle statusbtn" + } + +const load = () => { + $("#reportedTable").hide() + $("#loading").show() + api.reported.get() + .success((em) => { + emails = em + $("#loading").hide() + $("#reportedTable").show() + let reportedTable = $("#reportedTable").DataTable({ + destroy: true, + "aaSorting": [], // Disable default sort + columnDefs: [{ + orderable: false, + targets: "no-sort" + }] + }); + reportedTable.clear(); + $.each(emails, (i, email) => { + + statusBtn = '' + + notes[email.id] = email.notes + + attHTML = + "" + + subj = escapeHtml(email.reported_subject) + if (subj.length > 24) { + subj = subj.substring(0, 24) + "..." + } + + reportedTable.row.add([ + " " + escapeHtml(email.reported_by_email) + "", + //escapeHtml(email.reported_subject.substring(0, 24) + "..."), + subj, + moment.utc(email.reported_time).fromNow(), + attHTML, + statusBtn, + "
\ +
" + ]).draw() + }) + }) + .error(() => { + errorFlash("Error fetching reported emails") + }) +} + +$(document).ready(function () { + + load() + window.setInterval(function(){ //Refresh every 10 seconds + load() + }, 10000); + }); + +function updateStatus(emailID, newstatus){ + + // Update button + btn = $("#btnstatus-" + emailID) + btn.attr('class', statusBtnClass[newstatus]) + btn.text(newstatus) + btn.val(newstatus) + + //Update server side value + email = { + id: parseInt(emailID), + status: newstatus + } + + api.reported.put(email) + .error(function (data) { + Swal.fire({ + type: 'error', + title: data.responseJSON.message + }) + }) + + } + +function loadNotes(emailID){ + + email = { + id: parseInt(emailID), + } + + $("#notes").val(notes[emailID]); + $("#notes-emailid").val(emailID); + $("#modal\\.flashes").empty() + $('#modal').modal('show'); + +} + +function deleteEmail(id) { + + Swal.fire({ + title: "Are you sure?", + text: "This will delete the email from here, but not from your mail server.", + type: "warning", + animation: false, + showCancelButton: true, + confirmButtonText: "Delete", + confirmButtonColor: "#428bca", + reverseButtons: true, + allowOutsideClick: false, + preConfirm: function () { + return new Promise((resolve, reject) => { + api.reported.delete(id) + .success((msg) => { + resolve() + }) + .error((data) => { + reject(data.responseJSON.message) + }) + }) + .catch(error => { + Swal.showValidationMessage(error) + }) + } + }).then(function (result) { + load() + }) +} + + +function viewEmail(id){ + + $("#modal-email\\.flashes").empty() + $('#modal-email').modal('show'); + + api.reported.getone(id) + .success((em) => { + + if (em.length > 0 ) { // Should always be one, but safe to check. + rtext = em[0].reported_text.replace(/(?:\r\n|\r|\n)/g, '
'); + rhtml = em[0].reported_html + + //$("#email-plaintext").attr('value', btoa(rtext)) + //$("#email-html").attr('value', btoa(rhtml)) + $("#email-plaintext").data("value", rtext) + $("#email-html").data("value", rhtml) + + + $("#email-body").attr("srcdoc", rtext); // Load plaintext by default + } else { + modalError("Error loading email") + } + + }) + .error(function (data) { + modalError("Error loading email: " + data.responseJSON.message) + }) + + +}; + +function viewplaintext() { + rtext = $("#email-plaintext").data("value") + $("#email-body").attr("srcdoc", rtext); +} + +function viewhtml() { + rhtml = $("#email-html").data("value") + $("#email-body").attr("srcdoc", rhtml); +} + +$("#modalSubmit").unbind('click').click(() => { + + emailID = $("#notes-emailid").attr('value') + newnotes = $("#notes").val() + notes[emailID] = newnotes + + email = { + "id": parseInt(emailID), + "notes": notes[emailID] + } + + api.reported.put(email) + .success(function (data) { + $("#modal").modal('hide') + }) + .error(function (data) { + modalError("Error saving notes: " + data.responseJSON.message) + }) + +}) + +// Convert attachment byte file size to human readable format +function humanFileSize(bytes, si=true, dp=0) { + const thresh = si ? 1000 : 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + ' B'; + } + + const units = si + ? ['kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + let u = -1; + const r = 10**dp; + + do { + bytes /= thresh; + ++u; + } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); + + + return bytes.toFixed(dp) + ' ' + units[u]; + } diff --git a/templates/nav.html b/templates/nav.html index d32b88e3..36eb50d9 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -20,6 +20,9 @@
  • Sending Profiles
  • +
  • + Reported Emails +
  • Account Settings
  • diff --git a/templates/reported.html b/templates/reported.html new file mode 100644 index 00000000..59de6dbf --- /dev/null +++ b/templates/reported.html @@ -0,0 +1,111 @@ +{{define "body"}} + + + +
    +
    +

    + {{.Title}} +

    +
    +
    +
    + +
    + + + +
    + + + + + + + + + + + + + + +
    +
    + + + + + + + + + +{{end}} {{define "scripts"}} + + + +{{end}} \ No newline at end of file