mirror of https://github.com/gophish/gophish
Starting to integrate landing page functionality (still not working).
Also did some minor cleanup.pull/24/head
parent
d567153d2a
commit
c318424ac0
|
@ -256,6 +256,79 @@ func API_Templates_Id(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// API_Pages handles requests for the /api/pages/ endpoint
|
||||
func API_Pages(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET":
|
||||
ps, err := models.GetPages(ctx.Get(r, "user_id").(int64))
|
||||
if checkError(err, w, "Pages not found", http.StatusNotFound) {
|
||||
return
|
||||
}
|
||||
JSONResponse(w, ps, http.StatusOK)
|
||||
//POST: Create a new page and return it as JSON
|
||||
case r.Method == "POST":
|
||||
p := models.Page{}
|
||||
// Put the request into a page
|
||||
err := json.NewDecoder(r.Body).Decode(&p)
|
||||
if checkError(err, w, "Invalid Request", http.StatusBadRequest) {
|
||||
return
|
||||
}
|
||||
_, err = models.GetPageByName(p.Name, ctx.Get(r, "user_id").(int64))
|
||||
if err != gorm.RecordNotFound {
|
||||
JSONResponse(w, models.Response{Success: false, Message: "Template name already in use"}, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
p.ModifiedDate = time.Now()
|
||||
p.UserId = ctx.Get(r, "user_id").(int64)
|
||||
err = models.PostPage(&p)
|
||||
if checkError(err, w, "Error inserting page", http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
JSONResponse(w, p, http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
func API_Pages_Id(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, _ := strconv.ParseInt(vars["id"], 0, 64)
|
||||
p, err := models.GetPage(id, ctx.Get(r, "user_id").(int64))
|
||||
if checkError(err, w, "Page not found", http.StatusNotFound) {
|
||||
Logger.Println(err)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.Method == "GET":
|
||||
JSONResponse(w, p, http.StatusOK)
|
||||
case r.Method == "DELETE":
|
||||
err = models.DeletePage(id, ctx.Get(r, "user_id").(int64))
|
||||
if checkError(err, w, "Error deleting page", http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
JSONResponse(w, models.Response{Success: true, Message: "Page Deleted Successfully"}, http.StatusOK)
|
||||
case r.Method == "PUT":
|
||||
p = models.Page{}
|
||||
err = json.NewDecoder(r.Body).Decode(&p)
|
||||
if err != nil {
|
||||
Logger.Println(err)
|
||||
}
|
||||
if p.Id != id {
|
||||
http.Error(w, "Error: /:id and template_id mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = p.Validate()
|
||||
/* if checkError(err, w, http.StatusBadRequest) {
|
||||
return
|
||||
}*/
|
||||
p.ModifiedDate = time.Now()
|
||||
p.UserId = ctx.Get(r, "user_id").(int64)
|
||||
err = models.PutPage(&p)
|
||||
if checkError(err, w, "Error updating group", http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
JSONResponse(w, p, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func API_Import_Group(w http.ResponseWriter, r *http.Request) {
|
||||
Logger.Println("Parsing CSV....")
|
||||
ts, err := util.ParseCSV(r)
|
||||
|
|
|
@ -39,6 +39,8 @@ func CreateAdminRouter() http.Handler {
|
|||
api.HandleFunc("/groups/{id:[0-9]+}", Use(API_Groups_Id, mid.RequireAPIKey))
|
||||
api.HandleFunc("/templates/", Use(API_Templates, mid.RequireAPIKey))
|
||||
api.HandleFunc("/templates/{id:[0-9]+}", Use(API_Templates_Id, mid.RequireAPIKey))
|
||||
api.HandleFunc("/pages/", Use(API_Pages, mid.RequireAPIKey))
|
||||
api.HandleFunc("/pages/{id:[0-9]+}", Use(API_Pages_Id, mid.RequireAPIKey))
|
||||
api.HandleFunc("/import/group", API_Import_Group)
|
||||
|
||||
// Setup static file serving
|
||||
|
|
|
@ -8,13 +8,18 @@ import (
|
|||
"github.com/coopernurse/gorp"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/jordan-wright/gophish/config"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
_ "github.com/mattn/go-sqlite3" // Blank import needed to import sqlite3
|
||||
)
|
||||
|
||||
// Conn is the connection to the SQLite database
|
||||
var Conn *gorp.DbMap
|
||||
var db gorm.DB
|
||||
var err error
|
||||
|
||||
// ErrUsernameTaken is thrown when a user attempts to register a username that is taken.
|
||||
var ErrUsernameTaken = errors.New("username already taken")
|
||||
|
||||
// Logger is a global logger used to show informational, warning, and error messages
|
||||
var Logger = log.New(os.Stdout, " ", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
|
||||
const (
|
||||
|
@ -35,6 +40,7 @@ type Flash struct {
|
|||
Message string
|
||||
}
|
||||
|
||||
// Response contains the attributes found in an API response
|
||||
type Response struct {
|
||||
Message string `json:"message"`
|
||||
Success bool `json:"success"`
|
||||
|
@ -62,16 +68,17 @@ func Setup() error {
|
|||
db.CreateTable(GroupTarget{})
|
||||
db.CreateTable(Template{})
|
||||
db.CreateTable(Attachment{})
|
||||
db.CreateTable(Page{})
|
||||
db.CreateTable(SMTP{})
|
||||
db.CreateTable(Event{})
|
||||
db.CreateTable(Campaign{})
|
||||
//Create the default user
|
||||
init_user := User{
|
||||
initUser := User{
|
||||
Username: "admin",
|
||||
Hash: "$2a$10$IYkPp0.QsM81lYYPrQx6W.U6oQGw7wMpozrKhKAHUBVL4mkm/EvAS", //gophish
|
||||
ApiKey: "12345678901234567890123456789012",
|
||||
}
|
||||
err = db.Save(&init_user).Error
|
||||
err = db.Save(&initUser).Error
|
||||
if err != nil {
|
||||
Logger.Println(err)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Page contains the fields used for a Page model
|
||||
type Page struct {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"-"`
|
||||
Name string `json:"name"`
|
||||
HTML string `json:"html"`
|
||||
ModifiedDate time.Time `json:"modified_date"`
|
||||
}
|
||||
|
||||
// ErrPageNameNotSpecified is thrown if the name of the landing page is blank.
|
||||
var ErrPageNameNotSpecified = errors.New("Template Name not specified")
|
||||
|
||||
// Validate ensures that a page contains the appropriate details
|
||||
func (p *Page) Validate() error {
|
||||
if p.Name == "" {
|
||||
return ErrPageNameNotSpecified
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPages returns the pages owned by the given user.
|
||||
func GetPages(uid int64) ([]Page, error) {
|
||||
ps := []Page{}
|
||||
err := db.Where("user_id=?", uid).Find(&ps).Error
|
||||
if err != nil {
|
||||
Logger.Println(err)
|
||||
return ps, err
|
||||
}
|
||||
return ps, err
|
||||
}
|
||||
|
||||
// GetPage returns the page, if it exists, specified by the given id and user_id.
|
||||
func GetPage(id int64, uid int64) (Page, error) {
|
||||
p := Page{}
|
||||
err := db.Where("user_id=? and id=?", uid, id).Find(&p).Error
|
||||
if err != nil {
|
||||
Logger.Println(err)
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// GetPageByName returns the page, if it exists, specified by the given name and user_id.
|
||||
func GetPageByName(n string, uid int64) (Page, error) {
|
||||
p := Page{}
|
||||
err := db.Where("user_id=? and name=?", uid, n).Find(&p).Error
|
||||
if err != nil {
|
||||
Logger.Println(err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// PostPage creates a new page in the database.
|
||||
func PostPage(p *Page) error {
|
||||
// Insert into the DB
|
||||
err := db.Save(p).Error
|
||||
if err != nil {
|
||||
Logger.Println(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// PutPage edits an existing Page in the database.
|
||||
// Per the PUT Method RFC, it presumes all data for a page is provided.
|
||||
func PutPage(p *Page) error {
|
||||
err := db.Debug().Where("id=?", p.Id).Save(p).Error
|
||||
if err != nil {
|
||||
Logger.Println(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeletePage deletes an existing page in the database.
|
||||
// An error is returned if a page with the given user id and page id is not found.
|
||||
func DeletePage(id int64, uid int64) error {
|
||||
err = db.Where("user_id=?", uid).Delete(Page{Id: id}).Error
|
||||
if err != nil {
|
||||
Logger.Println(err)
|
||||
}
|
||||
return err
|
||||
}
|
|
@ -29,6 +29,11 @@ app.config(function($routeProvider) {
|
|||
controller: 'TemplateCtrl'
|
||||
})
|
||||
|
||||
.when('/landing_pages', {
|
||||
templateUrl: 'js/app/partials/landing_pages.html',
|
||||
controller: 'LandingPageCtrl'
|
||||
})
|
||||
|
||||
.when('/settings', {
|
||||
templateUrl: 'js/app/partials/settings.html',
|
||||
controller: 'SettingsCtrl'
|
||||
|
|
|
@ -684,6 +684,109 @@ var TemplateModalCtrl = function($scope, $upload, $modalInstance) {
|
|||
}
|
||||
};
|
||||
|
||||
app.controller('LandingPageCtrl', function($scope, $modal, LandingPageService, ngTableParams) {
|
||||
$scope.errorFlash = function(message) {
|
||||
$scope.flashes = [];
|
||||
$scope.flashes.push({
|
||||
"type": "danger",
|
||||
"message": message,
|
||||
"icon": "fa-exclamation-circle"
|
||||
})
|
||||
}
|
||||
|
||||
$scope.successFlash = function(message) {
|
||||
$scope.flashes = [];
|
||||
$scope.flashes.push({
|
||||
"type": "success",
|
||||
"message": message,
|
||||
"icon": "fa-check-circle"
|
||||
})
|
||||
}
|
||||
|
||||
$scope.mainTableParams = new ngTableParams({
|
||||
page: 1, // show first page
|
||||
count: 10, // count per page
|
||||
sorting: {
|
||||
name: 'asc' // initial sorting
|
||||
}
|
||||
}, {
|
||||
total: 0, // length of data
|
||||
getData: function($defer, params) {
|
||||
LandingPageService.query(function(pages) {
|
||||
$scope.pages = pages
|
||||
params.total(pages.length)
|
||||
$defer.resolve(pages.slice((params.page() - 1) * params.count(), params.page() * params.count()));
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
$scope.editPage = function(page) {
|
||||
if (page === 'new') {
|
||||
$scope.newPage = true;
|
||||
$scope.page = {
|
||||
name: '',
|
||||
html: '',
|
||||
};
|
||||
|
||||
} else {
|
||||
$scope.newPage = false;
|
||||
$scope.page = page;
|
||||
}
|
||||
var modalInstance = $modal.open({
|
||||
templateUrl: '/js/app/partials/modals/LandingPageModal.html',
|
||||
controller: LandingPageModalCtrl,
|
||||
scope: $scope
|
||||
});
|
||||
|
||||
modalInstance.result.then(function(selectedItem) {
|
||||
$scope.selected = selectedItem;
|
||||
}, function() {
|
||||
console.log('closed')
|
||||
});
|
||||
};
|
||||
|
||||
$scope.savePage = function(page) {
|
||||
var newPage = new LandingPageService(page);
|
||||
if ($scope.newPage) {
|
||||
newPage.$save({}, function() {
|
||||
$scope.pages.push(newPage);
|
||||
$scope.mainTableParams.reload()
|
||||
});
|
||||
} else {
|
||||
newPage.$update({
|
||||
id: newPage.id
|
||||
})
|
||||
}
|
||||
$scope.page = {
|
||||
name: '',
|
||||
html: '',
|
||||
};
|
||||
}
|
||||
$scope.deletePage = function(page) {
|
||||
var deletePage = new LandingPageService(page);
|
||||
deletePage.$delete({
|
||||
id: deletePage.id
|
||||
}, function(response) {
|
||||
if (response.success) {
|
||||
$scope.successFlash(response.message)
|
||||
} else {
|
||||
$scope.errorFlash(response.message)
|
||||
}
|
||||
$scope.mainTableParams.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var LandingPageModalCtrl = function($scope, $modalInstance) {
|
||||
$scope.cancel = function() {
|
||||
$modalInstance.dismiss('cancel');
|
||||
};
|
||||
$scope.ok = function(page) {
|
||||
$modalInstance.dismiss('')
|
||||
$scope.savePage(page)
|
||||
};
|
||||
};
|
||||
|
||||
app.controller('SettingsCtrl', function($scope, $http, $window) {
|
||||
$scope.flashes = [];
|
||||
$scope.user = user;
|
||||
|
|
|
@ -27,3 +27,13 @@ app.factory('TemplateService', function($resource) {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.factory('LandingPageService', function($resource) {
|
||||
return $resource('/api/pages/:id?api_key=' + user.api_key, {
|
||||
id : "@id"
|
||||
}, {
|
||||
update: {
|
||||
method: 'PUT'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,7 +8,9 @@
|
|||
</li>
|
||||
<li><a href="#/users">Users & Groups</a>
|
||||
</li>
|
||||
<li><a href="#/templates">Templates</a>
|
||||
<li><a href="#/templates">Email Templates</a>
|
||||
</li>
|
||||
<li><a href="#/landing_pages">Landing Pages</a>
|
||||
</li>
|
||||
<li><a href="#/settings">Settings</a>
|
||||
</li>
|
||||
|
|
|
@ -8,7 +8,9 @@
|
|||
</li>
|
||||
<li><a href="#/users">Users & Groups</a>
|
||||
</li>
|
||||
<li><a href="#/templates">Templates</a>
|
||||
<li><a href="#/templates">Email Templates</a>
|
||||
</li>
|
||||
<li><a href="#/landing_pages">Landing Pages</a>
|
||||
</li>
|
||||
<li><a href="#/settings">Settings</a>
|
||||
</li>
|
||||
|
|
|
@ -8,7 +8,9 @@
|
|||
</li>
|
||||
<li><a href="#/users">Users & Groups</a>
|
||||
</li>
|
||||
<li><a href="#/templates">Templates</a>
|
||||
<li><a href="#/templates">Email Templates</a>
|
||||
</li>
|
||||
<li><a href="#/landing_pages">Landing Pages</a>
|
||||
</li>
|
||||
<li><a href="#/settings">Settings</a>
|
||||
</li>
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-sm-3 col-md-2 sidebar">
|
||||
<ul class="nav nav-sidebar">
|
||||
<li><a href="#">Dashboard</a>
|
||||
</li>
|
||||
<li><a href="#/campaigns">Campaigns</a>
|
||||
</li>
|
||||
<li><a href="#/users">Users & Groups</a>
|
||||
</li>
|
||||
<li><a href="#/templates">Email Templates</a>
|
||||
</li>
|
||||
<li class="active"><a href="#/landing_pages">Landing Pages</a>
|
||||
</li>
|
||||
<li><a href="#/settings">Settings</a>
|
||||
</li>
|
||||
<li><a href="/api/">API Documentation</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main" ng-controller="LandingPageCtrl">
|
||||
<h1 class="page-header">
|
||||
Landing Pages
|
||||
</h1>
|
||||
<div class="row">
|
||||
<div ng-repeat="flash in flashes" style="text-align:center" class="alert alert-{{flash.type}}">
|
||||
<i class="fa {{flash.icon}}"></i> {{flash.message}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="button" class="btn btn-primary" ng-click="editPage('new')" data-toggle="modal" data-target="#newLandingPageModal"><i class="fa fa-plus"></i> New Page</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<table ng-table="mainTableParams" class="table table-hover table-striped table-bordered">
|
||||
<tbody>
|
||||
<tr ng-repeat="page in $data" class="editable-row">
|
||||
<td data-title="'Name'" sortable="'name'" class="col-sm-1">{{page.name}}</td>
|
||||
<td data-title="'Modified Date'" class="col-sm-1">{{page.modified_date | date:'medium'}}</td>
|
||||
<div class="btn-group" style="float: right;">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle edit-button" data-toggle="dropdown">
|
||||
<span class="caret" style="border-top-color:#FFFFFF"></span>
|
||||
<span class="sr-only">Toggle Dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" style="left:auto; right:0;" role="menu">
|
||||
<li><a ng-click="editPage(page)">Edit</a>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li><a ng-click="deletePage(page)">Delete</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,36 @@
|
|||
<!-- New Template Modal -->
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" ng-click="cancel()">×</button>
|
||||
<h4 class="modal-title" ng-hide="newPage" id="pageModalLabel">Edit Page: {{page.name}}</h4>
|
||||
<h4 class="modal-title" ng-show="newPage" id="pageModalLabel">New Page</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="control-label" for="name">Name:</label>
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" ng-model="page.name" placeholder="Page name" id="name" autofocus/>
|
||||
</div>
|
||||
<!-- Nav tabs -->
|
||||
<fieldset disabled>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-danger btn-disabled"><i class="fa fa-download"></i> Clone Site (Coming Soon!)</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<tabset>
|
||||
<tab heading="HTML">
|
||||
<textarea rows="10" class="form-control" ng-model="page.html" placeholder="HTML"></textarea>
|
||||
</tab>
|
||||
<tab heading="Preview">
|
||||
<div ng-model="page.html" contenteditable></div>
|
||||
</tab>
|
||||
</tabset>
|
||||
<br />
|
||||
<fieldset disabled>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-danger btn-disabled"><i class="fa fa-external-link-square"></i> Preview in New Window (Coming Soon!)</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" ng-click="cancel()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" ng-click="ok(template)" data-dismiss="modal">Save Page</button>
|
||||
</div>
|
|
@ -8,7 +8,9 @@
|
|||
</li>
|
||||
<li><a href="#/users">Users & Groups</a>
|
||||
</li>
|
||||
<li><a href="#/templates">Templates</a>
|
||||
<li><a href="#/templates">Email Templates</a>
|
||||
</li>
|
||||
<li><a href="#/landing_pages">Landing Pages</a>
|
||||
</li>
|
||||
<li class="active"><a href="#/settings">Settings</a>
|
||||
</li>
|
||||
|
|
|
@ -8,7 +8,9 @@
|
|||
</li>
|
||||
<li><a href="#/users">Users & Groups</a>
|
||||
</li>
|
||||
<li class="active"><a href="#/templates">Templates</a>
|
||||
<li class="active"><a href="#/templates">Email Templates</a>
|
||||
</li>
|
||||
<li><a href="#/landing_pages">Landing Pages</a>
|
||||
</li>
|
||||
<li><a href="#/settings">Settings</a>
|
||||
</li>
|
||||
|
@ -20,7 +22,7 @@
|
|||
</div>
|
||||
<div class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main" ng-controller="TemplateCtrl">
|
||||
<h1 class="page-header">
|
||||
Templates
|
||||
Email Templates
|
||||
</h1>
|
||||
<div class="row">
|
||||
<button type="button" class="btn btn-primary" ng-click="editTemplate('new')"><i class="fa fa-plus"></i> New Template</button>
|
||||
|
|
|
@ -8,7 +8,9 @@
|
|||
</li>
|
||||
<li class="active"><a href="#/users">Users & Groups</a>
|
||||
</li>
|
||||
<li><a href="#/templates">Templates</a>
|
||||
<li><a href="#/templates">Email Templates</a>
|
||||
</li>
|
||||
<li><a href="#/landing_pages">Landing Pages</a>
|
||||
</li>
|
||||
<li><a href="#/settings">Settings</a>
|
||||
</li>
|
||||
|
|
|
@ -55,7 +55,9 @@
|
|||
</li>
|
||||
<li><a href="#/users">Users & Groups</a>
|
||||
</li>
|
||||
<li><a href="#/templates">Templates</a>
|
||||
<li><a href="#/templates">Email Templates</a>
|
||||
</li>
|
||||
<li><a href="#/landing_pages">Landing Pages</a>
|
||||
</li>
|
||||
<li><a href="#/settings">Settings</a>
|
||||
</li>
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
</li>
|
||||
<li><a href="#/templates">Templates</a>
|
||||
</li>
|
||||
<li><a href="#/landing_pages">Landing Pages</a>
|
||||
</li>
|
||||
<li><a href="#/settings">Settings</a>
|
||||
</li>
|
||||
<li><a href="/api/">API Documentation</a>
|
||||
|
|
|
@ -60,7 +60,6 @@ func processCampaign(c *models.Campaign) {
|
|||
Logger.Println(err)
|
||||
}
|
||||
e.HTML = html_buff.Bytes()
|
||||
//buff.Reset()
|
||||
tmpl, err = template.New("text_template").Parse(c.Template.Text)
|
||||
if err != nil {
|
||||
Logger.Println(err)
|
||||
|
@ -70,7 +69,6 @@ func processCampaign(c *models.Campaign) {
|
|||
Logger.Println(err)
|
||||
}
|
||||
e.Text = text_buff.Bytes()
|
||||
//buff.Reset()
|
||||
Logger.Println("Creating email using template")
|
||||
e.To = []string{t.Email}
|
||||
err = e.Send(c.SMTP.Host, auth)
|
||||
|
|
Loading…
Reference in New Issue