Initial open source release

This commit is contained in:
Ronni Skansing
2025-08-21 16:14:09 +02:00
commit 11cf01f08e
488 changed files with 97180 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// AllowDenyColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var AllowDenyColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.ALLOW_DENY_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.ALLOW_DENY_TABLE, "updated_at"),
"hosting_website": repository.TableColumn(database.ALLOW_DENY_TABLE, "host_website"),
"redirects": repository.TableColumn(database.ALLOW_DENY_TABLE, "redirect_url"),
}
// AllowDeny is a controller
type AllowDeny struct {
Common
AllowDenyService *service.AllowDeny
}
// Create creates a new AllowDeny
func (c *AllowDeny) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.AllowDeny
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save
id, err := c.AllowDenyService.Create(g, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetAll gets AllowDenies
func (c *AllowDeny) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
companyID := companyIDFromRequestQuery(g)
// get
allowDenies, err := c.AllowDenyService.GetAll(
g,
session,
companyID,
&repository.AllowDenyOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
allowDenies,
)
}
// GetAllOverview gets AllowDenies
func (c *AllowDeny) GetAllOverview(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
companyID := companyIDFromRequestQuery(g)
allowDenies, err := c.AllowDenyService.GetAll(
g,
session,
companyID,
&repository.AllowDenyOption{
Fields: []string{
"id",
"created_at",
"updated_at",
"company_id",
"name",
"allowed",
},
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
allowDenies,
)
}
// GetByID gets an AllowDeny by ID
func (c *AllowDeny) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get
allowDeny, err := c.AllowDenyService.GetByID(
g,
session,
id,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
allowDeny,
)
}
// UpdateByID updates an AllowDeny
func (c *AllowDeny) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.AllowDeny
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update
err := c.AllowDenyService.Update(g, session, id, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
nil,
)
}
// DeleteByID deletes an AllowDeny
func (c *AllowDeny) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.AllowDenyService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
nil,
)
}
+196
View File
@@ -0,0 +1,196 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// APISenderColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var APISenderColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.API_SENDER_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.API_SENDER_TABLE, "updated_at"),
"name": repository.TableColumn(database.API_SENDER_TABLE, "name"),
}
// APISender is a API sender controller
type APISender struct {
Common
APISenderService *service.APISender
}
// Create creates a new api sender
func (a *APISender) Create(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
var req model.APISender
if ok := a.handleParseRequest(g, &req); !ok {
return
}
id, err := a.APISenderService.Create(g, session, &req)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{"id": id.String()})
}
// GetAll gets all api senders
func (a *APISender) GetAll(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(APISenderColumnsMap)
apiSenders, err := a.APISenderService.GetAll(
g.Request.Context(),
session,
companyID,
repository.APISenderOption{
QueryArgs: queryArgs,
},
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, apiSenders)
}
// GetAllOverview gets all api senders with limited data
func (a *APISender) GetAllOverview(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(APISenderColumnsMap)
apiSenders, err := a.APISenderService.GetAllOverview(
g.Request.Context(),
session,
companyID,
repository.APISenderOption{
QueryArgs: queryArgs,
},
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, apiSenders)
}
// GetByID gets a api sender by ID
func (a *APISender) GetByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse reqeuest
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get api sender
apiSender, err := a.APISenderService.GetByID(
g,
session,
id,
&repository.APISenderOption{},
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, apiSender)
}
// Update updates a api sender
func (a *APISender) UpdateByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
var req model.APISender
if ok := a.handleParseRequest(g, &req); !ok {
return
}
err := a.APISenderService.UpdateByID(
g,
session,
id,
&req,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// DeletebyID deletes a api sender by ID
func (a *APISender) DeleteByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
err := a.APISenderService.DeleteByID(
g.Request.Context(),
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// SendTest sends a api request test and outputs the api sender and response
func (a *APISender) SendTest(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
data, err := a.APISenderService.SendTest(
g.Request.Context(),
session,
id,
)
// output the error
if err != nil {
a.Response.BadRequestMessage(g, err.Error())
return
}
a.Response.OK(g, data)
}
+487
View File
@@ -0,0 +1,487 @@
package controller
import (
"encoding/base64"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"github.com/go-errors/errors"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
)
// AssetOrderByMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var AssetsColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.ASSET_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.ASSET_TABLE, "updated_at"),
"name": repository.TableColumn(database.ASSET_TABLE, "name"),
"description": repository.TableColumn(database.ASSET_TABLE, "description"),
"path": repository.TableColumn(database.ASSET_TABLE, "path"),
}
// Asset is an static Asset controller
type Asset struct {
Common
StaticAssetPath string
DomainService *service.Domain
OptionService *service.Option
AssetService *service.Asset
}
// GetContentByID get the content and mime type of an asset
func (a *Asset) GetContentByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
_ = handleServerError(g, a.Response, err)
return
}
if !isAuthorized {
a.Response.Unauthorized(g)
return
}
// get domain
domain, err := vo.NewString255(g.Param("domain"))
if err != nil {
a.Logger.Errorw("invalid domain",
"domain", domain,
)
a.Response.ValidationFailed(g, "Domain", err)
return
}
// if the target is the global folder, use the global folder
if domain.String() == data.ASSET_GLOBAL_FOLDER {
// TODO this shold require special permissions or be prefixed with a special path
// such as the company name or something that is prefixed
_ = data.ASSET_GLOBAL_FOLDER
}
staticPath, err := securejoin.SecureJoin(a.StaticAssetPath, domain.String())
if err != nil {
a.Logger.Debugw("insecure path",
"path", a.StaticAssetPath,
"domain", domain.String(),
"error", err,
)
return
}
// get the file path
pathDecoded, err := url.QueryUnescape(g.Param("path"))
if err != nil {
a.Logger.Debugw("failed to decode path",
"error", err,
)
a.Response.BadRequest(g)
return
}
filePath, err := securejoin.SecureJoin(staticPath, pathDecoded)
if err != nil {
a.Logger.Debugw("insecure path",
"path", pathDecoded,
"error", err,
)
a.Response.BadRequest(g)
return
}
// check if the file exists
a.Logger.Debugw("checking if asset exists",
"path", filePath,
)
_, err = os.Stat(filePath)
if errors.Is(err, fs.ErrNotExist) {
a.Logger.Debugw("asset not found",
"path", filePath,
)
a.Response.NotFound(g)
return
}
if err != nil {
a.Logger.Errorw("failed to get asset path info",
"path", filePath,
"error", err,
)
a.Response.ServerError(g)
return
}
// serve the file
// #nosec
content, err := os.ReadFile(filePath)
if err != nil {
a.Logger.Errorw("failed to read asset",
"path", filePath,
"error", err,
)
a.Response.ServerError(g)
return
}
fileExt := filepath.Ext(filePath)
mimeType := ""
switch fileExt {
case ".html":
mimeType = "text/html"
case ".htm":
mimeType = "text/html"
case ".xhtml":
mimeType = "application/xhtml+xml"
default:
mimeType = http.DetectContentType(content)
}
encodedContent := base64.StdEncoding.EncodeToString(content)
a.Response.OK(g, gin.H{
"mimeType": mimeType,
"file": encodedContent,
})
}
// GetAllForContext gets all static assets for a domain
// and has a special case 'shared' to get all global assets
func (a *Asset) GetAllForContext(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
_ = handleServerError(g, a.Response, err)
return
}
if !isAuthorized {
a.Response.Unauthorized(g)
return
}
// parse request
var domainID *uuid.UUID
companyID := companyIDFromRequestQuery(g)
domainParam := g.Param("domain")
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
// set default sort by
queryArgs.RemapOrderBy(AssetsColumnsMap)
queryArgs.DefaultSortByUpdatedAt()
a.Logger.Debugw("getting assets for domain",
"domain", domainParam,
"companyID", companyID,
)
// if there is no domain then it is a global asset request
// else the domain name is the asset scope
if len(domainParam) > 0 {
domainName, err := vo.NewString255(domainParam)
if err != nil {
a.Logger.Errorw("invalid domain",
"domain", domainName,
)
a.Response.ValidationFailed(g, "Domain", err)
return
}
// get the domains id and also check if the user has permission to retrieve it
domain, err := a.DomainService.GetByName(
g.Request.Context(),
session,
domainName,
&repository.DomainOption{},
)
if ok := a.handleErrors(g, err); !ok {
return
}
did := domain.ID.MustGet()
domainID = &did
}
// get assets
a.Logger.Debugw("getting assets for domain by ID",
"domainID", domainID,
)
assets, err := a.AssetService.GetAll(
g,
session,
domainID,
companyID,
queryArgs,
)
// handle responses
a.handleErrors(g, err)
a.Response.OK(g, assets)
}
// Create uploads an static asset
func (a *Asset) Create(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// this is a form data request, so we must handle all fields manually as is it not parsed from the struct
multipartData, err := g.MultipartForm()
if err != nil {
a.Logger.Errorw("failed to get multipart form",
"error", err,
)
a.Response.BadRequest(g)
return
}
if len(multipartData.File["files"]) == 0 {
a.Logger.Debug("no files to upload")
a.Response.BadRequestMessage(g, "No files selected")
return
}
contextParam := g.PostForm("domain")
// if no domain is set, use the global folder
var domain *model.Domain
// if a domain is supplied we look for its assets
if len(contextParam) > 0 {
// check that the domain exists
name, err := vo.NewString255(contextParam)
if err != nil {
a.Logger.Errorw("invalid domain name",
"error", err,
)
a.Response.ValidationFailed(g, "Domain", err)
return
}
d, err := a.DomainService.GetByName(
g,
session,
name,
&repository.DomainOption{},
)
if ok := a.handleErrors(g, err); !ok {
return
}
domain = d
a.Logger.Debugw("uploading assets to domain",
"domain", contextParam,
)
} else {
a.Logger.Debug("uploading shared assets")
}
// map files to assets
assets := []*model.Asset{}
for _, file := range multipartData.File["files"] {
// check max file size
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
if ok := a.handleErrors(g, err); !ok {
return
}
ok, err := utils.CompareFileSizeFromString(file.Size, maxFile.Value.String())
if err != nil {
a.Logger.Errorw("failed to compare file size",
"error", err,
)
}
if !ok {
a.Logger.Debugw("file too large",
"filename", file.Filename,
"size", file.Size,
"maxSize", maxFile.Value.String(),
)
a.Response.ValidationFailed(
g,
"File",
fmt.Errorf("file '%s' is too large", utils.ReadableFileName(file.Filename)),
)
return
}
// TODO multi user validate that the company id is the same as the session company id or that the session is a super admin
// TODO can the creation of the ID be moved to the repo
var domainID string
if domain != nil {
did := domain.ID.MustGet()
domainID = did.String()
}
name, err := vo.NewOptionalString127(g.Request.PostFormValue("name"))
if err != nil {
a.Logger.Debugw("failed to parse name",
"error", err,
)
a.Response.ValidationFailed(g, "Name", err)
return
}
description, err := vo.NewOptionalString255(g.Request.PostFormValue("description"))
if err != nil {
a.Logger.Debugw("failed to parse description",
"error", err,
)
a.Response.ValidationFailed(g, "Description", err)
return
}
path, err := vo.NewRelativeFilePath(g.Request.PostFormValue("path"))
if err != nil {
a.Logger.Debugw("failed to parse path",
"error", err,
)
a.Response.ValidationFailed(g, "Path", err)
return
}
companyID := nullable.NewNullNullable[uuid.UUID]()
companyIDParam := g.PostForm("companyID")
if len(companyIDParam) > 0 {
cid, err := uuid.Parse(companyIDParam)
if err != nil {
a.Logger.Debugw("failed to parse company id",
"error", err,
)
a.Response.ValidationFailed(g, "CompanyID", err)
}
companyID.Set(cid)
} else {
companyID.SetNull()
}
assetName := nullable.NewNullableWithValue(*name)
assetDescription := nullable.NewNullableWithValue(*description)
assetPath := nullable.NewNullableWithValue(*path)
assetDomainID := nullable.NewNullNullable[uuid.UUID]()
if len(domainID) > 0 {
did, err := uuid.Parse(domainID)
if err != nil {
a.Logger.Debugw("failed to parse domain id",
"error", err,
)
a.Response.ValidationFailed(g, "DomainID", err)
return
}
assetDomainID.Set(did)
// if the asset belongs to a domain it must not be 'global' context
if !companyID.IsSpecified() {
a.Logger.Debugw(
"cant add a shared asset to a company owned domain",
"domainID", domainID,
"domainOwnerCompanyID", companyID,
)
a.Response.ValidationFailed(
g,
"domainID",
errors.New("cant add a shared asset to a company owned domain"),
)
return
}
}
asset := model.Asset{
Name: assetName,
Description: assetDescription,
Path: assetPath,
File: *file,
DomainID: assetDomainID,
CompanyID: companyID,
}
if domain != nil {
asset.DomainName = domain.Name
}
assets = append(assets, &asset)
}
// store the files on disk and in database
ids, err := a.AssetService.Create(g, session, assets)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{
"ids": ids,
"files_uploaded": len(assets),
})
}
// GetByID gets an static asset by id
func (a *Asset) GetByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get the asset
ctx := g.Request.Context()
asset, err := a.AssetService.GetByID(ctx, session, id)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, asset)
}
// UpdateByID updates an static asset by id
func (a *Asset) UpdateByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
var req model.Asset
if ok := a.handleParseRequest(g, &req); !ok {
return
}
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// update the asset
ctx := g.Request.Context()
err := a.AssetService.UpdateByID(
ctx,
session,
id,
req.Name,
req.Description,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// RemoveByID removes an static asset
// if the asset is a directory, it will be removed recursively
func (a *Asset) RemoveByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// remove the asset
ctx := g.Request.Context()
err := a.AssetService.DeleteByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
+404
View File
@@ -0,0 +1,404 @@
package controller
import (
"encoding/base64"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
)
// AttachmentColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var AttachmentColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.ATTACHMENT_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.ATTACHMENT_TABLE, "updated_at"),
"name": repository.TableColumn(database.ATTACHMENT_TABLE, "name"),
"description": repository.TableColumn(database.ATTACHMENT_TABLE, "description"),
"embedded content": repository.TableColumn(database.ATTACHMENT_TABLE, "embeddedContent"),
"filename": repository.TableColumn(database.ATTACHMENT_TABLE, "filename"),
}
// Attachment is an static Attachment controller
type Attachment struct {
Common
StaticAttachmentPath string
TemplateService *service.Template
AttachmentService *service.Attachment
OptionService *service.Option
CompanyService *service.Company
}
// GetContentByID returns the content and mime type of an attachment
func (a *Attachment) GetContentByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get the attachment
ctx := g.Request.Context()
attachment, err := a.AttachmentService.GetByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
p := attachment.Path.MustGet().String()
// serve the file
// #nosec
content, err := os.ReadFile(p)
if err != nil {
a.Logger.Errorw("failed to read file",
"path", p,
"error", err,
)
a.Response.ServerError(g)
return
}
fileExt := filepath.Ext(p)
mimeType := ""
switch fileExt {
case ".html":
mimeType = "text/html"
case ".htm":
mimeType = "text/html"
case ".xhtml":
mimeType = "application/xhtml+xml"
default:
mimeType = http.DetectContentType(content)
}
// get by id is only used for admin viewing of an attachemnt, so all
// embedded content must contain example data
if attachment.EmbeddedContent.MustGet() {
// build email
domain := &model.Domain{
Name: nullable.NewNullableWithValue(
*vo.NewString255Must("example.test"),
),
}
recipient := model.NewRecipientExample()
campaignRecipient := model.CampaignRecipient{
ID: nullable.NewNullableWithValue(
uuid.New(),
),
Recipient: recipient,
}
email := model.NewEmailExample()
// hacky
email.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(content)),
)
apiSender := model.NewAPISenderExample()
b, err := a.TemplateService.CreateMailBody(
"id",
"/foo",
domain,
&campaignRecipient,
email,
apiSender,
)
if err != nil {
a.Logger.Errorw("failed to appy template to attachment",
"error", err,
)
a.Response.ServerError(g)
return
}
content = []byte(b)
}
a.Response.OK(g, gin.H{
"mimeType": mimeType,
"file": base64.StdEncoding.EncodeToString(content),
})
}
// GetAllForContext gets all attachments for a domain
// and has a special case 'shared' to get all global attachments
func (a *Attachment) GetAllForContext(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
a.Logger.Errorw("failed to check permissions",
"error", err,
)
a.Response.ServerError(g)
return
}
if !isAuthorized {
// TODO audit log
_ = handleAuthorizationError(g, a.Response, errs.ErrAuthorizationFailed)
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// if there is no companyID then it is a global attachment request
// else the company context name is the attachment scope
if companyID != nil {
// get the company id and to check if the user has permission to retrieve it
_, err := a.CompanyService.GetByID(
g.Request.Context(),
session,
companyID,
)
if ok := a.handleErrors(g, err); !ok {
return
}
}
queryArgs, ok := a.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(AttachmentColumnsMap)
// get attachments
a.Logger.Debugw("getting attachments for company ID",
"companyID", companyID,
)
attachments, err := a.AttachmentService.GetAll(
g,
session,
companyID,
queryArgs,
)
// handle responses
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, attachments)
}
// Create uploads an attachment
func (a *Attachment) Create(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
multipartData, err := g.MultipartForm()
if err != nil {
a.Logger.Errorw("failed to get multipart form",
"error", err,
)
a.Response.BadRequest(g)
return
}
if len(multipartData.File["files"]) == 0 {
a.Logger.Debug("no files to upload")
a.Response.BadRequestMessage(g, "No files selected")
return
}
companyID := nullable.NewNullNullable[uuid.UUID]()
companyIDParam := g.PostForm("companyID")
if len(companyIDParam) > 0 {
cid, err := uuid.Parse(companyIDParam)
if err != nil {
a.Logger.Debugw("failed to parse company id",
"error", err,
)
a.Response.ValidationFailed(g, "companyID", err)
return
}
companyID.Set(cid)
}
nameParam, err := vo.NewOptionalString127(g.PostForm("name"))
if err != nil {
a.Logger.Debugw("failed to parse name",
"name", g.PostForm("name"),
"error", err,
)
a.Response.ValidationFailed(g, "name", err)
return
}
name := nullable.NewNullableWithValue(*nameParam)
descriptionParam, err := vo.NewOptionalString255(g.PostForm("description"))
if err != nil {
a.Logger.Debugw("failed to parse description",
"error", err,
)
a.Response.ValidationFailed(g, "description", err)
return
}
description := nullable.NewNullableWithValue(*descriptionParam)
embeddedContent := nullable.NewNullableWithValue(false)
embeddedContentString := g.PostForm("embeddedContent")
if strings.ToLower(embeddedContentString) == "true" {
embeddedContent.Set(true)
}
attachments := []*model.Attachment{}
for _, file := range multipartData.File["files"] {
// TODO multi user validate that the company id is the same as the session company id or that the session is a super admin
// check max file size
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
if ok := a.handleErrors(g, err); !ok {
return
}
ok, err := utils.CompareFileSizeFromString(file.Size, maxFile.Value.String())
if err != nil {
a.Logger.Errorw("failed to compare file size",
"error", err,
)
}
if !ok {
a.Logger.Debugw("file too large",
"filename", file.Filename,
"size", file.Size,
"maxSize", maxFile.Value.String(),
)
a.Response.ValidationFailed(
g,
"File",
fmt.Errorf("'%s' is too large", utils.ReadableFileName(file.Filename)),
)
return
}
fileNameParam, err := vo.NewFileName(file.Filename)
if err != nil {
a.Logger.Debugw("failed to parse filename",
"error", err,
)
a.Response.ValidationFailed(g, "filename", err)
return
}
fileName := nullable.NewNullableWithValue(*fileNameParam)
attachment := model.Attachment{
CompanyID: companyID,
Name: name,
Description: description,
EmbeddedContent: embeddedContent,
File: file,
FileName: fileName,
}
if err := attachment.Validate(); err != nil {
a.Logger.Debugw("failed to validate attachment",
"attachmentName", name,
"error", err,
)
a.Response.ValidationFailed(g, "attachment", err)
return
}
attachments = append(attachments, &attachment)
}
// store the files on disk and in database
createdIDs, err := a.AttachmentService.Create(
g,
session,
attachments,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{
"ids": createdIDs,
"files_uploaded": len(attachments),
})
}
// GetByID gets an static attachment by id
func (a *Attachment) GetByID(g *gin.Context) {
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// get the attachment
ctx := g.Request.Context()
attachment, err := a.AttachmentService.GetByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, attachment)
}
// UpdateByID updates an static attachment by id
func (a *Attachment) UpdateByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req model.Attachment
if ok := a.handleParseRequest(g, &req); !ok {
return
}
// update the attachment
ctx := g.Request.Context()
err := a.AttachmentService.UpdateByID(
ctx,
session,
id,
&req,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
// RemoveByID removes an static attachment
// if the attachment is a directory, it will be removed recursively
func (a *Attachment) RemoveByID(g *gin.Context) {
// handle session
session, _, ok := a.handleSession(g)
if !ok {
return
}
// parse request
id, ok := a.handleParseIDParam(g)
if !ok {
return
}
// remove the attachment
ctx := g.Request.Context()
err := a.AttachmentService.DeleteByID(
ctx,
session,
id,
)
if ok := a.handleErrors(g, err); !ok {
return
}
a.Response.OK(g, gin.H{})
}
+927
View File
@@ -0,0 +1,927 @@
package controller
import (
"bytes"
"encoding/csv"
"time"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/build"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/embedded"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
)
// allowedCampaignColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var allowedCampaignColumns = map[string]string{
"created_at": repository.TableColumn(database.CAMPAIGN_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.CAMPAIGN_TABLE, "updated_at"),
"closed_at": repository.TableColumn(database.CAMPAIGN_TABLE, "closed_at"),
"close_at": repository.TableColumn(database.CAMPAIGN_TABLE, "close_at"),
"anonymized_at": repository.TableColumn(database.CAMPAIGN_TABLE, "anonymized_at"),
"is_test": repository.TableColumn(database.CAMPAIGN_TABLE, "is_test"),
"send_start_at": repository.TableColumn(database.CAMPAIGN_TABLE, "send_start_at"),
"send_end_at": repository.TableColumn(database.CAMPAIGN_TABLE, "send_end_at"),
"template": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "name"),
"name": repository.TableColumn(database.CAMPAIGN_TABLE, "name"),
}
// campaignEventColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var campaignEventColumns = map[string]string{
"created_at": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "updated_at"),
"details": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "data"),
"ip": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "ip_address"),
"user-agent": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "user_agent"),
"email": repository.TableColumn(database.RECIPIENT_TABLE, "email"),
"first_name": repository.TableColumn(database.RECIPIENT_TABLE, "first_name"),
"last_name": repository.TableColumn(database.RECIPIENT_TABLE, "last_name"),
"event": repository.TableColumn(database.EVENT_TABLE, "name"),
}
// allowedCampaignRecipientColumns is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var allowedCampaignRecipientColumns = map[string]string{
"created_at": "campaign_recipients.created_at",
"updated_at": "campaign_recipients.updated_at",
"send_at": "campaign_recipients.send_at",
"sent_at": "campaign_recipients.sent_at",
"cancelled_at": "campaign_recipients.cancelled_at",
"status": "campaign_recipients.notable_event_id",
"first_name": "recipients.first_name",
"last_name": "recipients.last_name",
"email": "recipients.email",
}
// Campaign is a Campaign controller
type Campaign struct {
Common
CampaignService *service.Campaign
}
// CloseCampaignByID closes campaign
func (c *Campaign) CloseCampaignByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// close campaigns
err := c.CampaignService.CloseCampaignByID(
g.Request.Context(),
session,
id,
)
// handle responses
if errors.Is(err, errs.ErrCampaignAlreadyClosed) {
c.Response.ValidationFailed(g, "", err)
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// Create creates a new campaign
func (c *Campaign) Create(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.Campaign
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// create and schedule the campaign
id, err := c.CampaignService.Create(g.Request.Context(), session, &req)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"id": id.String(),
})
}
// GetAllEventTypes gets all event types
func (c *Campaign) GetAllEventTypes(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
_ = handleServerError(g, c.Response, err)
return
}
if !isAuthorized {
c.Response.Unauthorized(g)
return
}
// get all event names
// we pick them out from the in memory cache
ev := []gin.H{}
for name, id := range cache.EventIDByName {
ev = append(ev, gin.H{
"id": id,
"name": name,
})
}
c.Response.OK(g, ev)
}
// GetByID gets a campaign by its id
func (c *Campaign) GetByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get the campaign that needs to be updated
campaign, err := c.CampaignService.GetByID(
g.Request.Context(),
session,
id,
&repository.CampaignOption{
WithRecipientGroups: true,
WithAllowDeny: true,
WithDenyPage: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaign)
}
// GetByName gets a campaign by name
func (c *Campaign) GetByName(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
name := g.Param("name")
if !ok {
return
}
// get the campaign that needs to be updated
campaign, err := c.CampaignService.GetByName(
g,
session,
name,
companyID,
&repository.CampaignOption{
WithRecipientGroups: true,
WithAllowDeny: true,
WithDenyPage: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaign)
}
// GetResultStats get campaign result stats
func (c *Campaign) GetResultStats(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get
stats, err := c.CampaignService.GetResultStats(
g.Request.Context(),
session,
id,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetCampaignStats get campaign stats
// if no company id is provided it gets the global stats including all companies
func (c *Campaign) GetStats(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// get
stats, err := c.CampaignService.GetStats(
g.Request.Context(),
session,
companyID,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetAll gets all campaigns with pagination
func (c *Campaign) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
queryArgs.DefaultSortByUpdatedAt()
// get all campaigns
campaigns, err := c.CampaignService.GetAll(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAll gets all campaigns within dates
func (c *Campaign) GetAllWithinDates(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
queryArgs.DefaultSortByUpdatedAt()
// get start and end date for query
startDate, err := time.Parse(time.RFC3339Nano, g.Query("start"))
if err != nil {
c.Response.ValidationFailed(g, "start", err)
return
}
endDate, err := time.Parse(time.RFC3339Nano, g.Query("end"))
if err != nil {
c.Response.ValidationFailed(g, "end", err)
return
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllWithinDates(
g.Request.Context(),
session,
startDate,
endDate,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllActive gets all active campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllActive(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = false
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllActive(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllUpcoming gets all upcoming campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllUpcoming(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = false
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllUpcoming(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetAllFinished gets all finished campaigns with pagination
// if no company id is given it gets all globals including company
func (c *Campaign) GetAllFinished(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
if queryArgs.OrderBy == "" {
queryArgs.OrderBy = "send_start_at"
queryArgs.Desc = true
}
// get all campaigns
campaigns, err := c.CampaignService.GetAllFinished(
g.Request.Context(),
session,
companyID,
&repository.CampaignOption{
QueryArgs: queryArgs,
WithCompany: true,
WithCampaignTemplate: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaigns)
}
// GetEventsByCampaignID gets events by campaign id
func (c *Campaign) GetEventsByCampaignID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
// remap query args
queryArgs.RemapOrderBy(campaignEventColumns)
// set default sort order to desc
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
var since *time.Time
s, err := time.Parse(time.RFC3339Nano, g.Query("since"))
if err == nil {
since = &s
}
// get events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
since,
nil,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, events)
}
// ExportEventsAsCSV exports a all campaign events as a CSV
func (c *Campaign) ExportEventsAsCSV(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
queryArgs.RemapOrderBy(campaignEventColumns)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
nil,
nil,
)
if ok := c.handleErrors(g, err); !ok {
return
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Recipient name",
"Recipient email",
"Event name",
"Event Details",
"User-Agent",
"IP",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
row := []string{}
// if the recipient has been deleted or anonymized
if event.Recipient == nil {
row = []string{
utils.CSVFromDate(event.CreatedAt),
"anonymized",
"anonymized",
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
} else {
row = []string{
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(event.Recipient.FirstName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.LastName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.Email.MustGet().String()),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.responseWithCSV(g, buffer, writer, "campaign_events.csv")
}
// ExportSubmissionsAsCSV exports all campaign submissions as a CSV
func (c *Campaign) ExportSubmissionsAsCSV(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
queryArgs.RemapOrderBy(campaignEventColumns)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// filter for submission events only
submissionEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA]
eventTypeFilter := []string{submissionEventID.String()}
// get submission events by campaign id
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
id,
queryArgs,
nil,
eventTypeFilter,
)
if ok := c.handleErrors(g, err); !ok {
return
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Submitted at",
"Recipient first name",
"Recipient last name",
"Recipient email",
"Submitted data",
"User-Agent",
"IP",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
row := []string{}
// if the recipient has been deleted or anonymized
if event.Recipient == nil {
row = []string{
utils.CSVFromDate(event.CreatedAt),
"anonymized",
"anonymized",
"anonymized",
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
} else {
row = []string{
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(event.Recipient.FirstName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.LastName.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Recipient.Email.MustGet().String()),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.responseWithCSV(g, buffer, writer, "campaign_submissions.csv")
}
func (c *Campaign) GetCampaignEmail(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get email
email, err := c.CampaignService.GetCampaignEmailBody(
g.Request.Context(),
session,
id,
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, email)
}
// GetCampaignURL gets a recipient landing page URL
func (c *Campaign) GetCampaignURL(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
url, err := c.CampaignService.GetLandingPageURLByCampaignRecipientID(
g.Request.Context(),
session,
id,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, url)
}
// GetRecipientsByCampaignID gets recipients by campaign id
func (c *Campaign) GetRecipientsByCampaignID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// endpoints is handled a bit differently and allows to
// fetch an unlimited amount of rows if no offset and limit is set.
// TODO this endpoint should be changed to a Result<T> so we fetch the rows as needed.
offset := g.DefaultQuery("offset", "")
limit := g.DefaultQuery("limit", "")
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
// special case to retrieve ALL rows
if offset == "" && limit == "" {
queryArgs.Offset = 0
queryArgs.Limit = 0
}
// remap query args
queryArgs.DefaultSortBy("created_at")
queryArgs.RemapOrderBy(allowedCampaignRecipientColumns)
// get recipients by campaign id
recipients, err := c.CampaignService.GetRecipientsByCampaignID(
g.Request.Context(),
session,
id,
&repository.CampaignRecipientOption{
QueryArgs: queryArgs,
WithRecipient: true,
},
)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, recipients)
}
// TrackingPixel returns a tracking pixel
func (c *Campaign) TrackingPixel(g *gin.Context) {
// get the campaign recipient id from the query
campaignRecipientID := g.Query("upn") // expect the campaign recipient id to be in here
if campaignRecipientID == "" {
c.Response.NotFound(g)
return
}
campaignRecipientUUID, err := uuid.Parse(campaignRecipientID)
if err != nil {
c.Logger.Debugw(errs.MsgFailedToParseRequest,
"error", err,
)
c.Response.NotFound(g)
return
}
err = c.CampaignService.SaveTrackingPixelLoaded(
g,
&campaignRecipientUUID,
)
if err != nil {
c.Logger.Debugw("failed to save tracking pixel loaded event",
"error", err,
)
c.Response.NotFound(g)
return
}
g.Header("Content-Type", "image/gif")
if !build.Flags.Production {
g.File("./embedded/tracking-pixel/sendgrid/open.gif")
return
}
_, err = g.Writer.Write(embedded.TrackingPixel)
if err != nil {
c.Logger.Errorw("failed to write tracking pixel", "error", err)
}
}
// UpdateByID updates a campaign by its id
func (c *Campaign) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.Campaign
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update the campaign
err := c.CampaignService.UpdateByID(g.Request.Context(), session, id, &req)
if ok := c.handleErrors(g, err); !ok {
return
}
// handle responses
c.Response.OK(g, gin.H{})
}
// SetSentAtByCampaignRecipientID sets the sent at time for a campaign recipient
func (c *Campaign) SetSentAtByCampaignRecipientID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// set sent at time
err := c.CampaignService.SetSentAtByCampaignRecipientID(g.Request.Context(), session, id)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// DeleteByID deletes a campaign by its id
func (c *Campaign) DeleteByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.CampaignService.DeleteByID(g, session, id)
// handle responses
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// AnonymizeByID anonymizes a campaign by its id
func (c *Campaign) AnonymizeByID(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// anonymize
err := c.CampaignService.AnonymizeByID(g, session, id)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// GetCampaignStats gets campaign statistics by campaign ID
func (c *Campaign) GetCampaignStats(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get stats
stats, err := c.CampaignService.GetCampaignStats(g.Request.Context(), session, id)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetAllCampaignStats gets all campaign statistics with pagination
func (c *Campaign) GetAllCampaignStats(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(allowedCampaignColumns)
companyID := companyIDFromRequestQuery(g)
// get stats
stats, err := c.CampaignService.GetAllCampaignStats(g.Request.Context(), session, queryArgs, companyID)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
+199
View File
@@ -0,0 +1,199 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// CampaignTemplateColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var CampaignTemplateColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "updated_at"),
"name": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "name"),
"after_landing_page_redirect_url": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "after_landing_page_redirect_url"),
"is_complete": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "is_usable"),
"domain": repository.TableColumn(database.DOMAIN_TABLE, "name"),
"before_landing_page": repository.TableColumn("before_landing_page", "name"),
"landing_page": repository.TableColumn("landing_page", "name"),
"after_landing_page": repository.TableColumn("after_landing_page", "name"),
"smtp": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "name"),
"api_sender": repository.TableColumn(database.API_SENDER_TABLE, "name"),
"email": repository.TableColumn(database.EMAIL_TABLE, "name"),
}
// CampaignTemplate is a campaign template controller
type CampaignTemplate struct {
Common
CampaignTemplateService *service.CampaignTemplate
}
// Create creates a campaign template
func (c *CampaignTemplate) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.CampaignTemplate
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save
ctx := g.Request.Context()
id, err := c.CampaignTemplateService.Create(ctx, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetByID gets a campaign template by id
func (c *CampaignTemplate) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// check if full data set should be loaded
options := &repository.CampaignTemplateOption{}
_, ok = g.GetQuery("full")
if ok {
options = &repository.CampaignTemplateOption{
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithEmail: true,
WithLandingPage: true,
WithBeforeLandingPage: true,
WithAfterLandingPage: true,
WithIdentifier: true,
}
}
// get
ctx := g.Request.Context()
campaignTemplate, err := c.CampaignTemplateService.GetByID(
ctx,
session,
id,
options,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, campaignTemplate)
}
// GetAll gets all campaign templates
func (c *CampaignTemplate) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
pagination, ok := c.handlePagination(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
usableOnlyQuery := g.Query("usableOnly")
usableOnly := false
if usableOnlyQuery == "true" {
usableOnly = true
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(CampaignTemplateColumnsMap)
columns := repository.SelectTable(database.CAMPAIGN_TEMPLATE_TABLE)
templates, err := c.CampaignTemplateService.GetAll(
g,
session,
companyID,
pagination,
&repository.CampaignTemplateOption{
QueryArgs: queryArgs,
Columns: columns,
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithEmail: true,
WithLandingPage: true,
WithBeforeLandingPage: true,
WithAfterLandingPage: true,
UsableOnly: usableOnly,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, templates)
}
// UpdateByID updates a campaign template by id
func (c *CampaignTemplate) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.CampaignTemplate
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update
err := c.CampaignTemplateService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// DeleteByID deletes a campaign template by id
func (c *CampaignTemplate) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.CampaignTemplateService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
+594
View File
@@ -0,0 +1,594 @@
package controller
import (
"archive/zip"
"bytes"
"encoding/csv"
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/api"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
)
// DomainColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var CompanyColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.COMPANY_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.COMPANY_TABLE, "updated_at"),
"name": repository.TableColumn(database.COMPANY_TABLE, "name"),
}
// Company is a Company controller
type Company struct {
Common
CompanyService *service.Company
CampaignService *service.Campaign
RecipientService *service.Recipient
}
// GetByID gets a company by id
func (c *Company) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
companyID, err := uuid.Parse(g.Param("id"))
if err != nil {
// ignore err as caused by bad user input
_ = err
c.Response.BadRequestMessage(g, api.InvalidCompanyID)
return
}
// get company
ctx := g.Request.Context()
company, err := c.CompanyService.GetByID(
ctx,
session,
&companyID,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, company)
}
// ExportByCompanyID outputs a CSV with all events related to the recipient
func (c *Company) ExportByCompanyID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
companyID, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get the company exported
company, err := c.CompanyService.GetByID(
g,
session,
companyID,
)
// create ZIP file in memory
zipBuffer := new(bytes.Buffer)
zipWriter := zip.NewWriter(zipBuffer)
zipFileName := fmt.Sprintf("company_export_%s.zip", company.Name.MustGet().String())
// add company data to zip
{
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Updated at",
"Name",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
row := []string{
utils.CSVFromDate(company.CreatedAt),
utils.CSVFromDate(company.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(company.Name)),
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
writer.Flush()
// add to zip
f, err := zipWriter.Create("company.csv")
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
// add recipients to zip
{
// get the recipients
recipients, err := c.RecipientService.GetByCompanyID(
g,
session,
companyID,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
if ok := c.handleErrors(g, err); !ok {
return
}
// write a csv buffer with all recipient and their groups
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Updated at",
"Email",
"Phone",
"Extra Identifier",
"Name",
"Position",
"Department",
"City",
"Country",
"Misc",
}
// find the recipient with the most groups and add that number of
// extra headers for groups
maxGroups := 0
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
if groupLen := len(groups); groupLen > maxGroups {
maxGroups = groupLen
}
}
for i := 1; i <= maxGroups; i++ {
headers = append(headers, fmt.Sprintf("Group %d", i))
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
row := []string{
utils.CSVFromDate(recipient.CreatedAt),
utils.CSVFromDate(recipient.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Email)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Phone)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.ExtraIdentifier)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.FirstName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.LastName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Position)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Department)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.City)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Country)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Misc)),
}
for _, group := range groups {
row = append(row, group.Name.MustGet().String())
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
writer.Flush()
}
// add to zip
f, err := zipWriter.Create("recipients.csv")
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
// get all campaigns all recipient events
{
campaigns, err := c.CampaignService.GetByCompanyID(
g,
session,
companyID,
&repository.CampaignOption{},
)
for _, campaign := range campaigns.Rows {
headers := []string{
"Campaign",
"Created at",
"Recipient name",
"Recipient email",
"Event name",
"Event Details",
"User-Agent",
"IP",
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
queryArgs := vo.QueryArgs{}
queryArgs.OrderBy = repository.TableColumn(
database.CAMPAIGN_EVENT_TABLE,
"created_at",
)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events by campaign id
cid := campaign.ID.MustGet()
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
&cid,
&queryArgs,
nil,
nil,
)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
firstName := "anonymized"
lastName := "anonymized"
recpEmail := "anonymized"
if event.Recipient != nil {
firstName = event.Recipient.FirstName.MustGet().String()
lastName = event.Recipient.LastName.MustGet().String()
recpEmail = event.Recipient.Email.MustGet().String()
}
row := []string{
utils.CSVRemoveFormulaStart(campaign.Name.MustGet().String()),
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(firstName),
utils.CSVRemoveFormulaStart(lastName),
utils.CSVRemoveFormulaStart(recpEmail),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
// add a new subdirectory wit the event file in the zip
writer.Flush()
// add to zip
filename := fmt.Sprintf("campaign_events/%s.csv", campaign.Name.MustGet().String())
f, err := zipWriter.Create(filename)
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// close zip
err = zipWriter.Close()
if ok := c.handleErrors(g, err); !ok {
return
}
c.responseWithZIP(g, zipBuffer, zipFileName)
}
// ExportShared outputs a CSV with all shared recipients and events
func (c *Company) ExportShared(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// create ZIP file in memory
zipBuffer := new(bytes.Buffer)
zipWriter := zip.NewWriter(zipBuffer)
zipFileName := "shared_export_%s.zip"
// add recipients to zip
{
// get the recipients
recipients, err := c.RecipientService.GetByCompanyID(
g,
session,
nil,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
if ok := c.handleErrors(g, err); !ok {
return
}
// write a csv buffer with all recipient and their groups
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
headers := []string{
"Created at",
"Updated at",
"Email",
"Phone",
"Extra Identifier",
"Name",
"Position",
"Department",
"City",
"Country",
"Misc",
}
// find the recipient with the most groups and add that number of
// extra headers for groups
maxGroups := 0
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
if groupLen := len(groups); groupLen > maxGroups {
maxGroups = groupLen
}
}
for i := 1; i <= maxGroups; i++ {
headers = append(headers, fmt.Sprintf("Group %d", i))
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, recipient := range recipients.Rows {
groups, _ := recipient.Groups.Get()
row := []string{
utils.CSVFromDate(recipient.CreatedAt),
utils.CSVFromDate(recipient.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Email)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Phone)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.ExtraIdentifier)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.FirstName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.LastName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Position)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Department)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.City)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Country)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recipient.Misc)),
}
for _, group := range groups {
row = append(row, group.Name.MustGet().String())
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
writer.Flush()
}
// add to zip
f, err := zipWriter.Create("recipients.csv")
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
// get all campaigns all recipient events
{
campaigns, err := c.CampaignService.GetByCompanyID(
g,
session,
nil,
&repository.CampaignOption{},
)
for _, campaign := range campaigns.Rows {
headers := []string{
"Campaign",
"Created at",
"Recipient name",
"Recipient email",
"Event name",
"Event Details",
"User-Agent",
"IP",
}
buffer := &bytes.Buffer{}
writer := csv.NewWriter(buffer)
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
return
}
queryArgs := vo.QueryArgs{}
queryArgs.OrderBy = repository.TableColumn(
database.CAMPAIGN_EVENT_TABLE,
"created_at",
)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events by campaign id
cid := campaign.ID.MustGet()
events, err := c.CampaignService.GetEventsByCampaignID(
g.Request.Context(),
session,
&cid,
&queryArgs,
nil,
nil,
)
if ok := c.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
firstName := "anonymized"
lastName := "anonymized"
recpEmail := "anonymized"
if event.Recipient != nil {
firstName = event.Recipient.FirstName.MustGet().String()
lastName = event.Recipient.LastName.MustGet().String()
recpEmail = event.Recipient.Email.MustGet().String()
}
row := []string{
utils.CSVRemoveFormulaStart(campaign.Name.MustGet().String()),
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(firstName),
utils.CSVRemoveFormulaStart(lastName),
utils.CSVRemoveFormulaStart(recpEmail),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.IP.String()),
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
return
}
}
// add a new subdirectory wit the event file in the zip
writer.Flush()
// add to zip
filename := fmt.Sprintf("campaign_events/%s.csv", campaign.Name.MustGet().String())
f, err := zipWriter.Create(filename)
if ok := c.handleErrors(g, err); !ok {
return
}
_, err = f.Write(buffer.Bytes())
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// close zip
err := zipWriter.Close()
if ok := c.handleErrors(g, err); !ok {
return
}
c.responseWithZIP(g, zipBuffer, zipFileName)
}
// ChangeName changes a company name
func (c *Company) ChangeName(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.Company
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// change company name
err := c.CompanyService.UpdateByID(
g,
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, nil)
}
// SoftDelete soft deletes a company
func (c *Company) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// TODO company delete should FAIL if it has any relations to anything
// delete company
_, err := c.CompanyService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// Create creates a company
func (c *Company) Create(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.Company
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save company
ctx := g.Request.Context()
company, err := c.CompanyService.Create(
ctx,
session,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"id": company.ID,
})
}
// GetAll gets all companies with pagination
func (c *Company) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(CompanyColumnsMap)
// get companies
ctx := g.Request.Context()
companies, err := c.CompanyService.GetAll(
ctx,
session,
queryArgs,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, companies)
}
+219
View File
@@ -0,0 +1,219 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
)
// DomainColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var DomainColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.DOMAIN_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.DOMAIN_TABLE, "updated_at"),
"hosting_website": repository.TableColumn(database.DOMAIN_TABLE, "host_website"),
"redirects": repository.TableColumn(database.DOMAIN_TABLE, "redirect_url"),
}
// Domain
type Domain struct {
Common
DomainService *service.Domain
}
// Create creates a domain
func (d *Domain) Create(g *gin.Context) {
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
var req model.Domain
if ok := d.handleParseRequest(g, &req); !ok {
return
}
// save domain
id, err := d.DomainService.Create(g, session, &req)
// handle response
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(
g,
gin.H{
"id": id,
},
)
}
// GetAll gets domains
func (d *Domain) GetAll(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := d.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(DomainColumnsMap)
// get domain
domains, err := d.DomainService.GetAll(
companyID,
g.Request.Context(),
session,
queryArgs,
true, // TODO there might not be any reason to retrieve the full relation here - optimize by removing it (false)
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domains)
}
// GetAllOverview gets domains with limited data
func (d *Domain) GetAllOverview(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := d.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(DomainColumnsMap)
// get domains
domains, err := d.DomainService.GetAllOverview(
companyID,
g.Request.Context(),
session,
queryArgs,
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domains)
}
// GetByID gets a domain by id
func (d *Domain) GetByID(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
id, ok := d.handleParseIDParam(g)
if !ok {
return
}
// get domain
ctx := g.Request.Context()
domain, err := d.DomainService.GetByID(
ctx,
session,
id,
&repository.DomainOption{
WithCompany: true,
},
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domain)
}
// GetByName gets a domain by name
func (d *Domain) GetByName(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
name, err := vo.NewString255(g.Param("domain"))
if ok := d.handleErrors(g, err); !ok {
return
}
// get domain
ctx := g.Request.Context()
domain, err := d.DomainService.GetByName(
ctx,
session,
name,
&repository.DomainOption{},
)
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, domain)
}
// UpdateByID updates a domain by id
func (d *Domain) UpdateByID(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
id, ok := d.handleParseIDParam(g)
if !ok {
return
}
var req model.Domain
if ok := d.handleParseRequest(g, &req); !ok {
return
}
// update domain
err := d.DomainService.UpdateByID(
g,
session,
id,
&req,
)
// handle response
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, gin.H{})
}
// DeleteByID deletes a domain by id
func (d *Domain) DeleteByID(g *gin.Context) {
// handle session
session, _, ok := d.handleSession(g)
if !ok {
return
}
// parse request
id, ok := d.handleParseIDParam(g)
if !ok {
return
}
// delete domain
err := d.DomainService.DeleteByID(
g,
session,
id,
)
// handle response
if ok := d.handleErrors(g, err); !ok {
return
}
d.Response.OK(g, gin.H{})
}
+375
View File
@@ -0,0 +1,375 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
)
// EmailOrderByMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var EmailOrderByMap = map[string]string{
"created_at": repository.TableColumn(database.EMAIL_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.EMAIL_TABLE, "created_at"),
"name": repository.TableColumn(database.EMAIL_TABLE, "name"),
"mail_from": repository.TableColumn(database.EMAIL_TABLE, "mail_from"),
"from": repository.TableColumn(database.EMAIL_TABLE, "from"),
"subject": repository.TableColumn(database.EMAIL_TABLE, "subject"),
"tracking_pixel": repository.TableColumn(database.EMAIL_TABLE, "add_tracking_pixel"),
}
// AddAttachmentsToEmailRequest is a request to add attachments to a message
type AddAttachmentsToEmailRequest struct {
Attachments []string `json:"ids"` // attachment IDs
}
// RemoveAttachmentFromEmailRequest is a request to remove an attachment from a message
type RemoveAttachmentFromEmailRequest struct {
AttachmentID string `json:"attachmentID"`
}
// SendTestEmailRequest is a request for sending a test of an e-mail
type SendTestEmailRequest struct {
SMTPID *uuid.UUID
DomainID *uuid.UUID
RecipientID *uuid.UUID
}
// Email is a Email controller
type Email struct {
Common
EmailService *service.Email
TemplateService *service.Template
EmailRepository *repository.Email
}
// AddAttachments adds attachments to a email
func (m *Email) AddAttachments(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
var request AddAttachmentsToEmailRequest
if ok := m.handleParseRequest(g, &request); !ok {
return
}
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
if len(request.Attachments) == 0 {
m.Response.BadRequestMessage(g, "No attachments provided")
return
}
attachmentIDs := []*uuid.UUID{}
for _, idParam := range request.Attachments {
id, err := uuid.Parse(idParam)
if err != nil {
m.Logger.Debugw(errs.MsgFailedToParseUUID,
"error", err,
)
m.Response.BadRequestMessage(g, "Invalid attachment ID")
return
}
attachmentIDs = append(attachmentIDs, &id)
}
// add attachments to email
err := m.EmailService.AddAttachments(
g.Request.Context(),
session,
id,
attachmentIDs,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// RemoveAttachment removes an attachment from a email
func (m *Email) RemoveAttachment(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse req
var req RemoveAttachmentFromEmailRequest
if ok := m.handleParseRequest(g, &req); !ok {
return
}
attachmentID, err := uuid.Parse(req.AttachmentID)
if err != nil {
m.Logger.Debugw(errs.MsgFailedToParseUUID,
"error", err,
)
m.Response.BadRequestMessage(g, "Invalid attachment ID")
return
}
emailID, err := uuid.Parse(g.Param("id"))
if err != nil {
m.Logger.Debugw(errs.MsgFailedToParseUUID, "error", err)
m.Response.BadRequestMessage(g, "Invalid message ID")
return
}
// remove attachment from email
err = m.EmailService.RemoveAttachment(
g.Request.Context(),
session,
&emailID,
&attachmentID,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// Create creates a email
func (m *Email) Create(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse req
var req model.Email
if ok := m.handleParseRequest(g, &req); !ok {
return
}
// save email
id, err := m.EmailService.Create(
g,
session,
&req,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{
"id": id,
})
}
// SendTestEmail
func (m *Email) SendTestEmail(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
var req SendTestEmailRequest
if ok := m.handleParseRequest(g, &req); !ok {
return
}
// send test email
err := m.EmailService.SendTestEmail(
g,
session,
id,
req.SMTPID,
req.DomainID,
req.RecipientID,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// GetByID gets a email by ID
func (m *Email) GetByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
// get email
email, err := m.EmailService.GetByID(
g.Request.Context(),
session,
id,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, email)
}
// GetContentByID gets a email content by ID
func (m *Email) GetContentByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
// get
email, err := m.EmailService.GetByID(
g.Request.Context(),
session,
id,
)
if ok := m.handleErrors(g, err); !ok {
return
}
// build email
domain := &model.Domain{
Name: nullable.NewNullableWithValue(
*vo.NewString255Must("example.test"),
),
}
recipient := model.NewRecipientExample()
campaignRecipient := model.CampaignRecipient{
ID: nullable.NewNullableWithValue(
uuid.New(),
),
Recipient: recipient,
}
apiSender := model.NewAPISenderExample()
emailBody, err := m.TemplateService.CreateMailBody(
"id",
"/foo",
domain,
&campaignRecipient,
email,
apiSender,
)
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, emailBody)
}
// GetAll gets all emails using pagination
func (m *Email) GetAll(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := m.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
queryArgs.RemapOrderBy(EmailOrderByMap)
emails, err := m.EmailService.GetAll(
g.Request.Context(),
session,
companyID,
queryArgs,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, emails)
}
// GetOverviews gets all email overviews using pagination
func (m *Email) GetOverviews(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := m.handleQueryArgs(g)
if !ok {
return
}
queryArgs.RemapOrderBy(EmailOrderByMap)
queryArgs.DefaultSortByName()
emails, err := m.EmailService.GetOverviews(
g.Request.Context(),
session,
companyID,
queryArgs,
)
// handle responses
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, emails)
}
// UpdateByID updates a message by ID
func (m *Email) UpdateByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
var email model.Email
if ok := m.handleParseRequest(g, &email); !ok {
return
}
// update message
err := m.EmailService.UpdateByID(
g.Request.Context(),
session,
id,
&email,
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
// DeleteByID deletes a message by ID
func (m *Email) DeleteByID(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
// delete message
err := m.EmailService.DeleteByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, gin.H{})
}
+15
View File
@@ -0,0 +1,15 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Health is the Health controller
type Health struct{}
// Health returns a 200 OK
func (c *Health) Health(g *gin.Context) {
g.Status(http.StatusOK)
}
+51
View File
@@ -0,0 +1,51 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// IdentifierColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var IdentifierColumnsMap = map[string]string{
"name": repository.TableColumn(database.IDENTIFIER_TABLE, "name"),
}
type Identifier struct {
Common
IdentifierService *service.Identifier
}
// GetAll gets all identifiers
func (c *Identifier) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
// get
identifiers, err := c.IdentifierService.GetAll(
g,
session,
&repository.IdentifierOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
identifiers,
)
}
+47
View File
@@ -0,0 +1,47 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/service"
)
// Import handles import for templates like emails, landing pages and so on
type Import struct {
Common
ImportService *service.Import
}
// Import imports a .zip file
func (im *Import) Import(g *gin.Context) {
session, _, ok := im.handleSession(g)
if !ok {
return
}
// parse request
f, err := g.FormFile("file")
// handle responses
if ok := im.handleErrors(g, err); !ok {
return
}
// Read forCompany flag from form (treat "1" or "true" as true)
forCompany := false
if v := g.PostForm("forCompany"); v == "1" || v == "true" {
forCompany = true
}
// Read companyID from form data if provided
var companyID *uuid.UUID
if companyIDStr := g.PostForm("companyID"); companyIDStr != "" {
if cid, err := uuid.Parse(companyIDStr); err == nil {
companyID = &cid
}
}
summary, err := im.ImportService.Import(g, session, f, forCompany, companyID)
if ok := im.handleErrors(g, err); !ok {
return
}
im.Response.OK(g, summary)
}
+329
View File
@@ -0,0 +1,329 @@
package controller
import (
"fmt"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/cli"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/password"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
"golang.org/x/net/context"
"gorm.io/gorm"
)
// SetupAdminRequest is the request for the install action
type SetupAdminRequest struct {
Username string `json:"username" binding:"required"`
UserFullname string `json:"userFullname" binding:"required"`
NewPassword string `json:"newPassword" binding:"required"`
}
// InitialSetup is a controller used by the CLI in the
// initial setup process - it is not an API controller
type InitialSetup struct {
Common
CLIOutputter cli.Outputter
OptionRepository *repository.Option
InstallService *service.InstallSetup
OptionService *service.Option
}
// IsInstalled checks if the application is installed
// not as a
func (is *InitialSetup) IsInstalled(ctx context.Context) (bool, error) {
isInstalledOption, err := is.OptionRepository.GetByKey(
ctx,
data.OptionKeyIsInstalled,
)
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("could not get '%s' option: %w", data.OptionKeyIsInstalled, err)
}
return isInstalledOption.Value.String() == data.OptionValueIsInstalled, nil
}
// HandleInitialSetup handles the initial setup of the application
// this includes inserting the isInstalled option to not installed
// and making or updating the sacrificial admin account
func (is *InitialSetup) HandleInitialSetup(ctx context.Context) error {
// setup option for is installed
isInstalledOption, err := is.OptionRepository.GetByKey(
ctx,
data.OptionKeyIsInstalled,
)
// if the option does not exist, create it
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("%w: could not get '%s' option", err, data.OptionKeyIsInstalled)
}
key := vo.NewString64Must(data.OptionKeyIsInstalled)
value := vo.NewOptionalString1MBMust(data.OptionValueIsNotInstalled)
isInstalledOptionWithoutID := model.Option{
Key: *key,
Value: *value,
}
_, err = is.OptionRepository.Insert(
ctx,
&isInstalledOptionWithoutID,
)
if err != nil {
return fmt.Errorf("%w: could not insert entity for option '%s'", err, data.OptionKeyIsInstalled)
}
isInstalledOption, err = is.OptionRepository.GetByKey(
ctx,
isInstalledOptionWithoutID.Key.String(),
)
if err != nil {
return fmt.Errorf("%w: could not get created '%s' option", err, data.OptionKeyIsInstalled)
}
}
// if no instance ID exists, add it
instanceIDOption, err := is.OptionRepository.GetByKey(
ctx,
data.OptionKeyInstanceID,
)
// if the instance id option does not exist, create it
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("%w: could not get '%s' option", err, data.OptionKeyInstanceID)
}
key := vo.NewString64Must(data.OptionKeyInstanceID)
instanceID := uuid.New()
value := vo.NewOptionalString1MBMust(instanceID.String())
instanceIDOption = &model.Option{
Key: *key,
Value: *value,
}
_, err = is.OptionRepository.Insert(
ctx,
instanceIDOption,
)
if err != nil {
return fmt.Errorf("could not insert instance ID: %w", err)
}
}
// if installation is already complete, return error
if isInstalledOption.Value.String() == data.OptionValueIsInstalled {
return errs.ErrAlreadyInstalled
}
// setup accounts
admin, password, err := is.InstallService.SetupAccounts(ctx)
if err != nil {
return fmt.Errorf("could not setup initial admin account: %w", err)
}
is.CLIOutputter.PrintInitialAdminAccount(
admin.Username.MustGet().String(),
password.String(),
)
return nil
}
// Install is the Install controller used by the API
type Install struct {
Common
UserRepository *repository.User
CompanyRepository *repository.Company
OptionRepository *repository.Option
DB *gorm.DB
PasswordHasher password.Argon2Hasher
}
// Install completes the installation by setting the initial administrators and options
func (in *Install) Install(g *gin.Context) {
tx := in.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
ok := in.install(g, tx)
if !ok {
if tx.Rollback().Error != nil {
in.Logger.Errorw("failed to install - could not rollback transaction",
"error", tx.Rollback().Error,
)
}
return
}
result := tx.Commit()
if result.Error != nil {
in.Logger.Errorw("failed to install - could not commit transaction",
"error", result.Error,
)
in.Response.ServerError(g)
return
}
// the admin user changed username and password
// however as the install process is a special case, we wont
// require re-authentication
in.Response.OK(g, gin.H{})
}
// Install completes the installation by setting the initial administrators
// username, password, email, name and company name
func (in *Install) install(g *gin.Context, tx *gorm.DB) bool {
// handle session
_, user, ok := in.handleSession(g)
if !ok {
return false
}
role := user.Role
if role == nil {
in.Logger.Error("failed to install - session contain no role")
in.Response.ServerError(g)
return false
}
if !role.IsSuperAdministrator() {
in.Logger.Info("failed to install - not super admin")
// TODO add audit log
in.Response.Forbidden(g)
return false
}
// defer rollback or commit tx
var request SetupAdminRequest
if err := g.ShouldBindJSON(&request); err != nil {
in.Logger.Debugw("failed to parse request",
"error", err,
)
in.Response.BadRequest(g)
return false
}
ctx := g.Request.Context()
// check if already installed
isInstalled, err := in.OptionRepository.GetByKey(ctx, data.OptionKeyIsInstalled)
if err != nil {
in.Logger.Errorw("failed to install - could not get option",
"optionKey", data.OptionKeyIsInstalled,
"error", err,
)
in.Response.ServerError(g)
return false
}
if isInstalled.Value.String() == data.OptionValueIsInstalled {
in.Logger.Info("failed to install - already installed")
in.Response.ServerErrorMessage(
g,
"Installation is already complete",
)
return false
}
// update the username
newUsername, err := vo.NewUsername(request.Username)
if err != nil {
in.Logger.Infow("failed to install - invalid username",
"username", request.Username,
"error", err,
)
in.Response.ValidationFailed(g, "Username", err)
return false
}
if newUsername.String() == user.Username.MustGet().String() {
in.Logger.Infow("failed to install - new username is the same as the current",
"username", newUsername.String(),
"error", err,
)
in.Response.BadRequestMessage(
g,
"Username may not be the same as the current",
)
return false
}
userID := user.ID.MustGet()
err = in.UserRepository.UpdateUsernameByIDWithTransaction(
ctx,
tx,
&userID,
newUsername,
)
if err != nil {
in.Logger.Infow("failed to install - could not update username",
"username", newUsername.String(),
"error", err,
)
in.Response.ServerError(g)
return false
}
// update the password
newPassword, err := vo.NewReasonableLengthPassword(request.NewPassword)
if err != nil {
in.Logger.Infow("failed to install - invalid password",
"error", err,
)
in.Response.BadRequestMessage(g, "invalid password")
return false
}
hash, err := in.PasswordHasher.Hash(newPassword.String())
if err != nil {
in.Logger.Errorw("failed to install - could not hash password",
"error", err,
)
in.Response.ServerError(g)
return false
}
err = in.UserRepository.UpdatePasswordHashByIDWithTransaction(
ctx,
tx,
&userID,
hash,
)
if err != nil {
in.Logger.Errorw("failed to install - could not update password",
"error", err,
)
in.Response.ServerError(g)
return false
}
// update the name
newName, err := vo.NewUserFullname(request.UserFullname)
if err != nil {
in.Logger.Infow("failed to install - invalid name",
"error", err,
)
in.Response.ValidationFailed(g, "Name", err)
return false
}
err = in.UserRepository.UpdateFullNameByIDWithTransaction(
ctx,
tx,
&userID,
newName,
)
if err != nil {
in.Logger.Infow("failed to install - could not update name",
"error", err,
)
in.Response.ServerError(g)
return false
}
// update installed option to installed
option := model.Option{
Key: *vo.NewString64Must(data.OptionKeyIsInstalled),
Value: *vo.NewOptionalString1MBMust(data.OptionValueIsInstalled),
}
err = in.OptionRepository.UpdateByKeyWithTransaction(
ctx,
tx,
&option,
)
if err != nil {
in.Logger.Errorw("failed to install - could not create install option",
"error", err,
)
in.Response.ServerErrorMessage(g, "failed to create install option")
return false
}
return true
}
+214
View File
@@ -0,0 +1,214 @@
package controller
import (
"context"
"time"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type SetLevelRequest struct {
Level string `json:"level"`
DBLevel string `json:"dbLevel"`
}
type Log struct {
Common
OptionService *service.Option
Database *gorm.DB
LoggerAtom *zap.AtomicLevel
}
// Panic is a test utility
func (c *Log) Panic(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
if session == nil {
if ok := c.handleErrors(g, errors.New("no session")); !ok {
return
}
}
c.Deeper()
}
func (c *Log) Deeper() {
panic("panic test")
}
// Slow is a test utility
func (c *Log) Slow(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
if session == nil {
if ok := c.handleErrors(g, errors.New("no session")); !ok {
return
}
}
c.Logger.Debugf("Slow request testing start")
time.Sleep(10 * time.Second)
c.Logger.Debugf("Slow request testing stop")
c.Response.OK(g, gin.H{})
}
// GetLevel gets the log level
func (c *Log) GetLevel(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// get the log levels
logLevelOption, err := c.OptionService.GetOption(
g,
session,
data.OptionKeyLogLevel,
)
// handle errors
if ok := c.handleErrors(g, err); !ok {
return
}
dbLogLevelOption, err := c.OptionService.GetOption(g, session, data.OptionKeyDBLogLevel)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"level": logLevelOption.Value,
"dbLevel": dbLogLevelOption.Value,
})
}
// SetLevel sets the log level
func (c *Log) SetLevel(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var request SetLevelRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
if request.Level == "" && request.DBLevel == "" {
c.Response.BadRequestMessage(g, "level or dbLevel is required")
return
}
if request.DBLevel != "" {
switch request.DBLevel {
case "silent":
c.Database.Logger = c.Database.Logger.LogMode(logger.Silent)
case "info":
c.Database.Logger = c.Database.Logger.LogMode(logger.Info)
case "warn":
c.Database.Logger = c.Database.Logger.LogMode(logger.Warn)
case "error":
c.Database.Logger = c.Database.Logger.LogMode(logger.Error)
default:
c.Logger.Debugw("invalid db log level",
"level", request.DBLevel,
)
c.Response.BadRequestMessage(g, "unknown DB log level")
return
}
// set db log level in database
dbLevel := vo.NewOptionalString1MBMust(request.DBLevel)
dbLogLevelOption := model.Option{
Key: *vo.NewString64Must(data.OptionKeyDBLogLevel),
Value: *dbLevel,
}
err := c.persist(
g,
session,
&dbLogLevelOption,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
}
if request.Level != "" {
switch request.Level {
case "debug":
c.LoggerAtom.SetLevel(zap.DebugLevel)
case "info":
c.LoggerAtom.SetLevel(zap.InfoLevel)
case "warn":
c.LoggerAtom.SetLevel(zap.WarnLevel)
case "error":
c.LoggerAtom.SetLevel(zap.ErrorLevel)
default:
c.Logger.Debugw("invalid log level",
"level", request.Level,
)
c.Response.BadRequestMessage(g, "Unknown log level")
return
}
// set log level in in memory logger struct
logLevel := model.Option{
Key: *vo.NewString64Must(data.OptionKeyLogLevel),
Value: *vo.NewOptionalString1MBMust(request.Level),
}
err := c.persist(
g,
session,
&logLevel,
)
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.Response.OK(g, nil)
}
// TestLog tests the log
// Sends a log message for each log level debug, info, warn, error
func (c *Log) TestLog(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// check permissions
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
handleServerError(g, c.Response, err)
return
}
if !isAuthorized {
// TODO audit log
c.Response.Unauthorized(g)
return
}
c.Logger.Debug("Log: DEBUG Test")
c.Logger.Info("Log: INFO Test")
c.Logger.Warn("Log: WARN Test")
c.Logger.Error("Log: ERROR Test")
c.Response.OK(g, nil)
}
// persit saves the log level
// TODO this has become empty and superflous
func (c *Log) persist(
ctx context.Context,
session *model.Session,
logLevel *model.Option,
) error {
return c.OptionService.SetOptionByKey(
ctx,
session,
logLevel,
)
}
+67
View File
@@ -0,0 +1,67 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
)
// Option is a Option controller
type Option struct {
Common
OptionService *service.Option
}
// Get a update option
func (c *Option) Get(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
key := g.Param("key")
if key == "" {
c.Response.BadRequestMessage(g, "option is required")
return
}
ctx := g.Request.Context()
option, err := c.OptionService.GetOption(
ctx,
session,
key,
)
if ok := handleServerError(g, c.Response, err); !ok {
return
}
if key == data.OptionKeyAdminSSOLogin {
option, err = c.OptionService.MaskSSOSecret(option)
if ok := handleServerError(g, c.Response, err); !ok {
return
}
}
c.Response.OK(g, option)
}
// Update sets a option
func (c *Option) Update(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.Option
if ok := c.handleParseRequest(g, &req); !ok {
return
}
err := c.OptionService.SetOptionByKey(g, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{},
)
}
+230
View File
@@ -0,0 +1,230 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// PageColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var PageColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.PAGE_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.PAGE_TABLE, "updated_at"),
"name": repository.TableColumn(database.PAGE_TABLE, "name"),
}
// Page is a Page controller
type Page struct {
Common
PageService *service.Page
TemplateService *service.Template
}
// Create creates a page
func (p *Page) Create(g *gin.Context) {
// handle session
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse req
var req model.Page
if ok := p.handleParseRequest(g, &req); !ok {
return
}
// save page
id, err := p.PageService.Create(
g.Request.Context(),
session,
&req,
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetContentByID serves a page by id
func (p *Page) GetContentByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
// get page
page, err := p.PageService.GetByID(
g,
session,
id,
&repository.PageOption{},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
content, err := page.Content.Get()
if ok := p.handleErrors(g, err); !ok {
return
}
// build response
phishingPage, err := p.TemplateService.ApplyPageMock(content.String())
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, phishingPage.String())
}
// GetAll gets pages using pagination
func (p *Page) GetAll(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := p.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
companyID := companyIDFromRequestQuery(g)
// get pages
pages, err := p.PageService.GetAll(
g,
session,
companyID,
&repository.PageOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, pages)
}
// GetOverview gets pages overview using pagination
func (p *Page) GetOverview(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := p.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
companyID := companyIDFromRequestQuery(g)
// get pages
pages, err := p.PageService.GetAll(
g,
session,
companyID,
&repository.PageOption{
Fields: []string{"id", "created_at", "updated_at", "name", "company_id"},
QueryArgs: queryArgs,
},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, pages)
}
// GetByID gets a page by id
func (p *Page) GetByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
// get page
page, err := p.PageService.GetByID(
g.Request.Context(),
session,
id,
// do I really need to preload this?
&repository.PageOption{
WithCompany: true,
},
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, page)
}
// UpdateByID updates a page by id
func (p *Page) UpdateByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
var req model.Page
if ok := p.handleParseRequest(g, &req); !ok {
return
}
// update page
err := p.PageService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, gin.H{})
}
// DeleteByID deletes a page by id
func (p *Page) DeleteByID(g *gin.Context) {
session, _, ok := p.handleSession(g)
if !ok {
return
}
// parse request
id, ok := p.handleParseIDParam(g)
if !ok {
return
}
// delete page
err := p.PageService.DeleteByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := p.handleErrors(g, err); !ok {
return
}
p.Response.OK(g, gin.H{})
}
+92
View File
@@ -0,0 +1,92 @@
package controller
import (
"image/png"
"net/http"
"github.com/boombuler/barcode"
"github.com/boombuler/barcode/qr"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/service"
)
// QRCodeRequest is the request to generate a QR code from a TOTP URL
type QRCodeRequest struct {
URL string `json:"url"`
DotSize int `json:"dotSize"`
}
// QRGenerator is the QR controller
type QRGenerator struct {
Common
}
// QRGenerator creates a HTML QR code
// It is returned in an JSON response
func (q *QRGenerator) ToHTML(g *gin.Context) {
_, _, ok := q.handleSession(g)
if !ok {
return
}
// parse request
var req QRCodeRequest
if ok := q.handleParseRequest(g, &req); !ok {
return
}
// generate QR code
qrCodeBuf, err := service.GenerateQRCode(req.URL, req.DotSize)
if err != nil {
q.Logger.Debugw("failed to genereate QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
q.Response.OK(g, qrCodeBuf)
}
// ToTOTPURL generates a QR code from a TOTP URL
func (q *QRGenerator) ToTOTPURL(g *gin.Context) {
_, _, ok := q.handleSession(g)
if !ok {
return
}
// parse request
var req QRCodeRequest
if ok := q.handleParseRequest(g, &req); !ok {
return
}
// generate QR code
qrCode, err := qr.Encode(
req.URL,
qr.M,
qr.Auto,
)
if err != nil {
q.Logger.Debugw("failed to generate QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
qrCode, err = barcode.Scale(qrCode, 200, 200)
if err != nil {
q.Logger.Debugw("failed to scale QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
// output QR code as png
g.Writer.Header().Set("Content-Type", "image/png")
err = png.Encode(g.Writer, qrCode)
if err == nil {
q.Logger.Debugw("failed to encode QR code",
"error", err,
)
q.Response.ServerError(g)
return
}
// respond
g.Status(http.StatusOK)
}
+470
View File
@@ -0,0 +1,470 @@
package controller
import (
"archive/zip"
"bytes"
"encoding/csv"
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
)
// recipientColumnByMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var recipientColumnByMap = map[string]string{
"created_at": repository.TableColumn(database.RECIPIENT_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.RECIPIENT_TABLE, "updated_at"),
"email": repository.TableColumn(database.RECIPIENT_TABLE, "email"),
"phone": repository.TableColumn(database.RECIPIENT_TABLE, "phone"),
"extra identifier": repository.TableColumn(database.RECIPIENT_TABLE, "extra_identifier"),
"first_name": repository.TableColumn(database.RECIPIENT_TABLE, "first_name"),
"last_name": repository.TableColumn(database.RECIPIENT_TABLE, "last_name"),
"position": repository.TableColumn(database.RECIPIENT_TABLE, "position"),
"department": repository.TableColumn(database.RECIPIENT_TABLE, "department"),
"city": repository.TableColumn(database.RECIPIENT_TABLE, "city"),
"country": repository.TableColumn(database.RECIPIENT_TABLE, "country"),
"misc": repository.TableColumn(database.RECIPIENT_TABLE, "misc"),
"repeat_offender": "is_repeat_offender", // Special case - don't use TableColumn
}
var recipientCampaignEventColumnMap = utils.MergeStringMaps(
campaignEventColumns,
map[string]string{
"event": repository.TableColumnName(database.EVENT_TABLE),
"created": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "created_at"),
"campaign": repository.TableColumn(database.CAMPAIGN_TABLE, "name"),
},
)
// Recipient is a Recipient controller
type Recipient struct {
Common
RecipientService *service.Recipient
}
// Create inserts a new recipient
func (r *Recipient) Create(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
var req model.Recipient
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// save recipient
id, err := r.RecipientService.Create(
g.Request.Context(),
session,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetCampaignEvents gets all campaign events by recipient id and campaign id
// gets all events if campaign id is nil
func (r *Recipient) GetCampaignEvents(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
recipientID, ok := r.handleParseIDParam(g)
if !ok {
return
}
// optional param
var campaignID *uuid.UUID
cid, err := uuid.Parse(g.Query("campaignID"))
if err == nil {
campaignID = &cid
}
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
// remap query args
queryArgs.RemapOrderBy(recipientCampaignEventColumnMap)
// get events
events, err := r.RecipientService.GetAllCampaignEvents(
g.Request.Context(),
session,
recipientID,
campaignID,
queryArgs,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, events)
}
// Export outputs a zip with recipient, groups and all events related to the recipient
func (r *Recipient) Export(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
recipientID, ok := r.handleParseIDParam(g)
if !ok {
return
}
// get the recipient
recp, err := r.RecipientService.GetByID(
g,
session,
recipientID,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
if ok := r.handleErrors(g, err); !ok {
return
}
recipientBuffer := &bytes.Buffer{}
recipientWriter := csv.NewWriter(recipientBuffer)
recpHeaders := []string{
"Created at",
"Updated at",
"Email",
"Phone",
"Extra Identifier",
"Name",
"Position",
"Department",
"City",
"Country",
"Misc",
}
groups, _ := recp.Groups.Get()
for i := range groups {
recpHeaders = append(recpHeaders, fmt.Sprintf("Group %d", i+1))
}
err = recipientWriter.Write(recpHeaders)
if ok := r.handleErrors(g, err); !ok {
return
}
row := []string{
utils.CSVFromDate(recp.CreatedAt),
utils.CSVFromDate(recp.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Email)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Phone)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.ExtraIdentifier)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.FirstName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.LastName)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Position)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Department)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.City)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Country)),
utils.CSVRemoveFormulaStart(utils.NullableToString(recp.Misc)),
}
for _, group := range groups {
row = append(row, group.Name.MustGet().String())
}
err = recipientWriter.Write(row)
if ok := r.handleErrors(g, err); !ok {
return
}
recipientWriter.Flush()
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByCreatedAt()
// remap query args
queryArgs.RemapOrderBy(recipientCampaignEventColumnMap)
sortOrder := g.DefaultQuery("sortOrder", "desc")
if sortOrder == "desc" {
queryArgs.Desc = true
}
// get all rows
queryArgs.Limit = 0
queryArgs.Offset = 0
// get events
events, err := r.RecipientService.GetAllCampaignEvents(
g.Request.Context(),
session,
recipientID,
nil,
queryArgs,
)
// handle response
eventsBuffer := &bytes.Buffer{}
eventsWriter := csv.NewWriter(eventsBuffer)
headers := []string{
"Created at",
"Campaign",
"IP",
"User-Agent",
"Event Details",
"Event",
}
err = eventsWriter.Write(headers)
if ok := r.handleErrors(g, err); !ok {
return
}
for _, event := range events.Rows {
row := []string{}
row = []string{
utils.CSVFromDate(event.CreatedAt),
utils.CSVRemoveFormulaStart(event.CampaignName),
utils.CSVRemoveFormulaStart(event.IP.String()),
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
utils.CSVRemoveFormulaStart(event.Data.String()),
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
}
err = eventsWriter.Write(row)
if ok := r.handleErrors(g, err); !ok {
return
}
}
eventsWriter.Flush()
// create ZIP file in memory
zipBuffer := new(bytes.Buffer)
zipWriter := zip.NewWriter(zipBuffer)
zipFileName := fmt.Sprintf("recipient_export_%s.zip", recp.Email.MustGet().String())
// add events to zip
{
f, err := zipWriter.Create("recipient.csv")
if ok := r.handleErrors(g, err); !ok {
return
}
_, err = f.Write(recipientBuffer.Bytes())
if ok := r.handleErrors(g, err); !ok {
return
}
}
// add events to zip
{
f, err := zipWriter.Create("events.csv")
if ok := r.handleErrors(g, err); !ok {
return
}
_, err = f.Write(eventsBuffer.Bytes())
if ok := r.handleErrors(g, err); !ok {
return
}
}
// close zip
err = zipWriter.Close()
if ok := r.handleErrors(g, err); !ok {
return
}
r.responseWithZIP(g, zipBuffer, zipFileName)
}
// GetRepeatOffenderCount gets the repeat offender count
func (r *Recipient) GetRepeatOffenderCount(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
// get count
count, err := r.RecipientService.GetRepeatOffenderCount(
g.Request.Context(),
session,
companyID,
)
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, count)
}
// GetAll gets all recipients
func (r *Recipient) GetAll(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
companyID := companyIDFromRequestQuery(g)
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortBy("first_name")
// remap query args
queryArgs.RemapOrderBy(recipientColumnByMap)
// get recipients
recipients, err := r.RecipientService.GetAll(
g.Request.Context(),
companyID,
session,
&repository.RecipientOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipients)
}
// GetByID gets a recipient by id
func (r *Recipient) GetByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// get recipient
recipient, err := r.RecipientService.GetByID(
g.Request.Context(),
session,
id,
&repository.RecipientOption{
WithCompany: true,
WithGroups: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipient)
}
// GetStatsByID gets a recipient campaign stats by id
func (r *Recipient) GetStatsByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// get recipient stats
stats, err := r.RecipientService.GetStatsByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, stats)
}
// UpdateByID updates a recipient by id
func (r *Recipient) UpdateByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
var req model.Recipient
if ok := r.handleParseRequest(g, &req); !ok {
return
}
err := r.RecipientService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, gin.H{})
}
// Import imports recipients
func (r *Recipient) Import(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
var req struct {
Recipients []*model.Recipient `json:"recipients"`
CompanyID *uuid.UUID `json:"companyID"`
IgnoreOverwriteEmptyFields nullable.Nullable[bool] `json:"ignoreOverwriteEmptyFields"`
}
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// IgnoreOverwriteEmptyFields default value is true
if !req.IgnoreOverwriteEmptyFields.IsSpecified() || req.IgnoreOverwriteEmptyFields.IsNull() {
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
}
_, err := r.RecipientService.Import(
g,
session,
req.Recipients,
req.IgnoreOverwriteEmptyFields.MustGet(),
req.CompanyID,
)
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// DeleteByID deletes a recipient by id
func (r *Recipient) DeleteByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// delete recipient
err := r.RecipientService.DeleteByID(g, session, id)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, gin.H{})
}
+351
View File
@@ -0,0 +1,351 @@
package controller
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// RecipientGroupColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var RecipientGroupColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.RECIPIENT_GROUP_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.RECIPIENT_GROUP_TABLE, "updated_at"),
"name": repository.TableColumn(database.RECIPIENT_GROUP_TABLE, "name"),
}
// AddRecipientRequest is a request to add recipients to a recipient group
type AddRecipientRequest struct {
RecipientIDs []string `json:"recipientIDs"`
}
// RemoveRecipientRequest is a request to remove recipients from a recipient group
type RemoveRecipientRequest struct {
RecipientIDs []string `json:"recipientIDs"`
}
// RecipientGroup is a recipient group controller
type RecipientGroup struct {
Common
RecipientGroupService *service.RecipientGroup
}
// Create creates a new recipient group
func (r *RecipientGroup) Create(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
var req model.RecipientGroup
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// save recipient group
recipientGroupID, err := r.RecipientGroupService.Create(
g.Request.Context(),
session,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(
g,
&gin.H{
"id": recipientGroupID.String(),
},
)
}
// GetAll returns all recipient groups using pagination
func (r *RecipientGroup) GetAll(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByName()
queryArgs.RemapOrderBy(RecipientGroupColumnsMap)
companyContextID := companyIDFromRequestQuery(g)
// get recipient groups
recipientGroups, err := r.RecipientGroupService.GetAll(
g,
session,
companyContextID,
&repository.RecipientGroupOption{
QueryArgs: queryArgs,
WithCompany: true,
WithRecipientCount: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipientGroups)
}
// GetByID gets a recipient group by id
func (r *RecipientGroup) GetByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
recipientGroup, err := r.RecipientGroupService.GetByID(
g.Request.Context(),
session,
id,
&repository.RecipientGroupOption{
WithCompany: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipientGroup)
}
// GetRecipientsByGroupID gets recipients by recipient group id
func (r *RecipientGroup) GetRecipientsByGroupID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
queryArgs, ok := r.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortBy("email")
// remap query args
queryArgs.RemapOrderBy(recipientColumnByMap)
if !ok {
return
}
// get recipients
ctx := g.Request.Context()
recipients, err := r.RecipientGroupService.GetRecipientsByGroupID(
ctx,
session,
id,
&repository.RecipientOption{
QueryArgs: queryArgs,
WithCompany: true,
},
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, recipients)
}
// UpdateByID updates a recipient group by id
// updates only the name and company relations
func (r *RecipientGroup) UpdateByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req model.RecipientGroup
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// check if recipient group exists already exists
err := r.RecipientGroupService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// Import imports recipients to a recipient group
func (r *RecipientGroup) Import(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse request
groupID, ok := r.handleParseIDParam(g)
if !ok {
return
}
var req struct {
Recipients []*model.Recipient `json:"recipients"`
CompanyID *uuid.UUID `json:"companyID"`
IgnoreOverwriteEmptyFields nullable.Nullable[bool] `json:"ignoreOverwriteEmptyFields"`
}
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// IgnoreOverwriteEmptyFields default value is true
if !req.IgnoreOverwriteEmptyFields.IsSpecified() || req.IgnoreOverwriteEmptyFields.IsNull() {
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
}
err := r.RecipientGroupService.Import(
g,
session,
req.Recipients,
req.IgnoreOverwriteEmptyFields.MustGet(),
groupID,
req.CompanyID,
)
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// AddRecipients adds recipients to a recipient group
func (r *RecipientGroup) AddRecipients(g *gin.Context) {
// handle session
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse group ID
groupID, ok := r.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req AddRecipientRequest
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// parse recipient ids
recipientIDs := []*uuid.UUID{}
for _, id := range req.RecipientIDs {
rid, err := uuid.Parse(id)
if err != nil {
r.Logger.Debugw("failed to add recipients to recipient group",
"error", fmt.Errorf("failed to parse recipient id: %w", err),
)
r.Response.BadRequestMessage(g, "invalid recipient id")
return
}
recipientIDs = append(recipientIDs, &rid)
}
// add recipients
err := r.RecipientGroupService.AddRecipients(
g.Request.Context(),
session,
groupID,
recipientIDs,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// RemoveRecipients removes a recipient from a recipient group
func (r *RecipientGroup) RemoveRecipients(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// parse request
var req RemoveRecipientRequest
if ok := r.handleParseRequest(g, &req); !ok {
return
}
// parse recipient ids
recipientIDs := []*uuid.UUID{}
for _, id := range req.RecipientIDs {
rid, err := uuid.Parse(id)
if err != nil {
r.Logger.Debugw("failed to remove recipients from recipient group",
"error", fmt.Errorf("failed to parse recipient id: %w", err),
)
r.Response.BadRequestMessage(g, "invalid recipient id")
return
}
recipientIDs = append(recipientIDs, &rid)
}
// remove recipients
err := r.RecipientGroupService.RemoveRecipients(
g.Request.Context(),
session,
id,
recipientIDs,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(g, &gin.H{})
}
// DeleteByID deletes a recipient group by id
// deleting a group also deletes all recipients in that group
func (r *RecipientGroup) DeleteByID(g *gin.Context) {
session, _, ok := r.handleSession(g)
if !ok {
return
}
// parse id
id, ok := r.handleParseIDParam(g)
if !ok {
return
}
// delete recipient group
err := r.RecipientGroupService.DeleteByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := r.handleErrors(g, err); !ok {
return
}
r.Response.OK(
g,
&gin.H{},
)
}
+269
View File
@@ -0,0 +1,269 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/api"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
)
// SMTPConfigurationColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var SMTPConfigurationColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "updated_at"),
"name": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "name"),
"host": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "host"),
"port": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "port"),
"username": repository.TableColumn(database.SMTP_CONFIGURATION_TABLE, "username"),
}
// SMTPConfiguration is a controller
type SMTPConfiguration struct {
Common
SMTPConfigurationService *service.SMTPConfiguration
}
// Create creates a new SMTPConfiguration
func (c *SMTPConfiguration) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.SMTPConfiguration
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save SMTP configuration
id, err := c.SMTPConfigurationService.Create(g, session, &req)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetAll gets SMTP configurations
func (c *SMTPConfiguration) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(SMTPConfigurationColumnsMap)
companyID := companyIDFromRequestQuery(g)
// get
smtpConfigs, err := c.SMTPConfigurationService.GetAll(
g.Request.Context(),
session,
companyID,
&repository.SMTPConfigurationOption{
QueryArgs: queryArgs,
WithCompany: true,
WithHeaders: true,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, smtpConfigs)
}
// GetByID gets a SMTP configuration by an ID
func (c *SMTPConfiguration) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get SMTP configuration
smtpConfig, err := c.SMTPConfigurationService.GetByID(
g.Request.Context(),
session,
id,
&repository.SMTPConfigurationOption{
WithCompany: true,
WithHeaders: true,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, smtpConfig)
}
type SMTPConfigurationTestEmailRequest struct {
Email vo.Email `json:"email" binding:"required,email"`
MailFrom vo.Email `json:"mailFrom" binding:"required,mailFrom"`
}
// TestEmail tests the connection to a SMTP configuration
func (c *SMTPConfiguration) TestEmail(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req SMTPConfigurationTestEmailRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// test dial
err := c.SMTPConfigurationService.SendTestEmail(
g,
session,
id,
&req.Email,
&req.MailFrom,
)
// handle any error as a validation error
if err != nil {
err = errs.NewValidationError(err)
}
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// UpdateByID updates a SMTP configuration - but not the headers
func (c *SMTPConfiguration) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.SMTPConfiguration
if ok := c.handleParseRequest(g, &req); !ok {
return
}
err := c.SMTPConfigurationService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// AddHeader adds a header to a SMTP configuration
func (c *SMTPConfiguration) AddHeader(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var req model.SMTPHeader
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// save header
smtpID, ok := c.handleParseIDParam(g)
if !ok {
return
}
createdID, err := c.SMTPConfigurationService.AddHeader(
g.Request.Context(),
session,
smtpID,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{
"id": createdID.String(),
})
}
// RemoveHeader removes a header from a SMTP configuration
func (c *SMTPConfiguration) RemoveHeader(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
headerID, err := uuid.Parse(g.Param("headerID"))
if err != nil {
c.Logger.Debugw("invalid header id",
"headerID", g.Param("headerID"),
"error", err,
)
c.Response.BadRequestMessage(g, api.InvalidSMTPConfigurationID)
return
}
// remove header
err = c.SMTPConfigurationService.RemoveHeader(
g.Request.Context(),
session,
id,
&headerID,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// DeleteByID deletes a SMTP configuration
func (c *SMTPConfiguration) DeleteByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete
err := c.SMTPConfigurationService.DeleteByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
+88
View File
@@ -0,0 +1,88 @@
package controller
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
)
// SSO the single sign on controller
type SSO struct {
Common
*service.SSO
}
// Upsert upserts a SSO configuration
func (s *SSO) Upsert(g *gin.Context) {
session, _, ok := s.handleSession(g)
if !ok {
return
}
// parse request
var request model.SSOOption
if ok := s.handleParseRequest(g, &request); !ok {
return
}
// handle upsert
err := s.SSO.Upsert(
g.Request.Context(),
session,
&request,
)
// handle responses
if ok := s.handleErrors(g, err); !ok {
return
}
s.Response.OK(g, gin.H{})
}
func (s *SSO) IsEnabled(g *gin.Context) {
// if no sso client is setup, then it is not enabled
if s.SSO.MSALClient == nil {
s.Response.OK(g, false)
return
}
s.Response.OK(g, true)
}
func (s *SSO) EntreIDLogin(g *gin.Context) {
authURL, err := s.SSO.EntreIDLogin(g)
if errors.Is(err, errs.ErrSSODisabled) {
s.Response.BadRequest(g)
return
}
if ok := s.handleErrors(g, err); !ok {
s.Response.BadRequest(g)
return
}
g.Redirect(http.StatusTemporaryRedirect, authURL)
}
func (s *SSO) EntreIDCallBack(g *gin.Context) {
code := g.Query("code")
session, err := s.SSO.HandlEntraIDCallback(g, code)
if err != nil {
g.Redirect(http.StatusTemporaryRedirect, "/login?ssoAuthError=1")
return
}
if ok := s.handleErrors(g, err); !ok {
return
}
// Set the session in the cookie
cookie := &http.Cookie{
Name: data.SessionCookieKey,
Value: session.ID.String(),
Path: "/",
SameSite: http.SameSiteStrictMode,
HttpOnly: true,
Secure: true,
Expires: *session.MaxAgeAt,
}
http.SetCookie(g.Writer, cookie)
g.Redirect(http.StatusTemporaryRedirect, "/dashboard")
}
+93
View File
@@ -0,0 +1,93 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/service"
)
type Update struct {
Common
UpdateService *service.Update
OptionService *service.Option
}
// CheckForUpdateCached checks if there is a new update from cache
func (u *Update) CheckForUpdateCached(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
updateAvailable, usingSystemd, err := u.UpdateService.CheckForUpdateCached(g, session)
if ok := u.handleErrors(g, err); !ok {
return
}
u.Response.OK(g, gin.H{
"updateAvailable": updateAvailable,
"updateInApp": usingSystemd,
})
}
// CheckForUpdate checks if there is a new update
func (u *Update) CheckForUpdate(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
updateAvailable, usingSystemd, err := u.UpdateService.CheckForUpdate(g, session)
if ok := u.handleErrors(g, err); !ok {
return
}
u.Response.OK(g, gin.H{
"updateAvailable": updateAvailable,
"updateInApp": usingSystemd,
})
}
// GetUpdateDetails gets details about the newest software update
func (u *Update) GetUpdateDetails(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
opt, err := u.OptionService.GetOption(g, session, data.OptionKeyUsingSystemd)
if ok := u.handleErrors(g, err); !ok {
return
}
details, err := u.UpdateService.GetUpdateDetails(g, session)
if err != nil && !errors.Is(err, errs.ErrNoUpdateAvailable) {
if ok := u.handleErrors(g, err); !ok {
return
}
}
if errors.Is(err, errs.ErrNoUpdateAvailable) {
u.Response.OK(g, gin.H{
"updateAvailable": false,
"updateInApp": opt.Value.String() == data.OptionValueUsingSystemdYes,
"downloadURL": "",
"latestVersion": "",
})
return
}
u.Response.OK(g, gin.H{
"updateAvailable": true,
"updateInApp": opt.Value.String() == data.OptionValueUsingSystemdYes,
"downloadURL": details.DownloadURL,
"latestVersion": details.LatestVersion,
})
}
// RunUpdate performs an update
func (u *Update) RunUpdate(g *gin.Context) {
session, _, ok := u.handleSession(g)
if !ok {
return
}
err := u.UpdateService.RunUpdate(g, session)
if ok := u.handleErrors(g, err); !ok {
return
}
u.Response.OK(g, gin.H{})
}
+922
View File
@@ -0,0 +1,922 @@
package controller
import (
"net/http"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/vo"
"gorm.io/gorm"
)
var SessionColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.SESSION_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.SESSION_TABLE, "updated_at"),
"ip_address": repository.TableColumn(database.SESSION_TABLE, "ip_address"),
}
var UserColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.USER_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.USER_TABLE, "updated_at"),
"name": repository.TableColumn(database.USER_TABLE, "name"),
"username": repository.TableColumn(database.USER_TABLE, "username"),
"email": repository.TableColumn(database.USER_TABLE, "email"),
}
// UserLoginRequest is a request for login with username and password
type UserLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTP string `json:"totp"`
MFARecoveryCode string `json:"recoveryCode"`
}
// UserSetupTOTPRequest is a request for setting up TOTP
type UserSetupTOTPRequest struct {
Password string `json:"password"`
}
// UserSetupDisableTOTPRequest is a request for disabling TOTP
type UserDisableTOTPRequest struct {
Token string `json:"token"`
}
// UserVerifyTOTPRequest is a request for verifying TOTP
type UserVerifyTOTPRequest struct {
TOTP string `json:"token"`
}
// UserLoginWithMFARecoveryCodeRequest is a request for login with MFA recovery code
type UserLoginWithMFARecoveryCodeRequest struct {
RecoveryCode string `json:"recoveryCode"`
Username string `json:"username"`
Password string `json:"password"`
}
// User is the change email controller
type User struct {
Common
UserService *service.User
}
// Create creates a new user
func (c *User) Create(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserUpsertRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// create user
newUserID, err := c.UserService.Create(
g,
session,
&req,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"id": newUserID.String(),
},
)
}
// GetMaskedAPIKey gets logged-in users masked API key
func (c *User) GetMaskedAPIKey(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
if user == nil {
c.handleErrors(g, errors.New("no user in session"))
}
// get
cid := user.ID.MustGet()
apiKey, err := c.UserService.GetMaskedAPIKey(
g,
session,
&cid,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"apiKey": apiKey,
},
)
}
// UpsertAPIKey create/update API key
func (c *User) UpsertAPIKey(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
if user == nil {
c.handleErrors(g, errors.New("no user in session"))
}
// create user
uid := user.ID.MustGet()
apiKey, err := c.UserService.UpsertAPIKey(
g,
session,
&uid,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"apiKey": apiKey,
},
)
}
// RemoveAPIKey removes a api key
func (c *User) RemoveAPIKey(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
if user == nil {
c.handleErrors(g, errors.New("no user in session"))
}
// create user
uid := user.ID.MustGet()
err := c.UserService.RemoveAPIKey(
g,
session,
&uid,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{},
)
}
// UpdateByID updates a user by ID
func (c *User) UpdateByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
var req model.User
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// update user
err := c.UserService.Update(
g,
session,
id,
&req,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// Delete deletes a user
func (c *User) Delete(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// delete user
err := c.UserService.Delete(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// GetAll gets all users using pagination
func (c *User) GetAll(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(UserColumnsMap)
// get user
users, err := c.UserService.GetAll(g, session, &repository.UserOption{
QueryArgs: queryArgs,
WithRole: true,
WithCompany: true,
})
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, users)
}
// GetByID gets a user by ID
func (c *User) GetByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get user
user, err := c.UserService.GetByID(g, session, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, user)
}
// ChangeEmailOnLoggedInUser changes email on logged in user
// this is an administrator action
func (c *User) ChangeEmailOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse and validate request
var request model.UserChangeEmailRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
// change email
userID := sessionUser.ID.MustGet()
changedEmail, err := c.UserService.ChangeEmailAsAdministrator(
g,
session,
&userID,
&request.Email,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
gin.H{"email": changedEmail.String()},
)
}
// ChangeFullnameOnLoggedInUser is the handler for change fullname
func (c *User) ChangeFullnameOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserChangeFullnameRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// change fullname
userID := sessionUser.ID.MustGet()
_, err := c.UserService.ChangeFullname(
g,
session,
&userID,
&req.NewFullname,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// ChangePasswordOnLoggedInUser changes the password on the logged in user
func (c *User) ChangePasswordOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserChangePasswordRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
// change password
err := c.UserService.ChangePassword(
g,
session,
&req.CurrentPassword,
&req.NewPassword,
)
// handle response
if errors.Is(err, errs.ErrUserWrongPasword) {
c.Response.BadRequestMessage(g, "Invalid current password")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
// invalidate all currently running sessions
userID := sessionUser.ID.MustGet()
err = c.SessionService.ExpireAllByUserID(g, session, &userID)
// partial error, the password is changed but the sessions are not invalidated
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"password changed - all sessions have been invalidated",
)
}
// ChangeUsernameOnLoggedInUser changes the username
func (c *User) ChangeUsernameOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req model.UserChangeUsernameOnLoggedInRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
userID := sessionUser.ID.MustGet()
// change username
err := c.UserService.ChangeUsername(
g.Request.Context(),
session,
&userID,
&req.NewUsername,
)
// handle error
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// ExpireSessionByID expires a session by ID
// a administrator can expire any session
// a user can expire their own sessions
func (c *User) ExpireSessionByID(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if ok := c.handleErrors(g, err); !ok {
return
}
if !isAuthorized {
c.Response.Forbidden(g)
return
}
err = c.SessionService.Expire(g, id)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"session expired",
)
}
// GetSessionsByUserID gets all sessions by user ID
func (c *User) GetSessionsOnLoggedInUser(g *gin.Context) {
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := c.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
queryArgs.RemapOrderBy(SessionColumnsMap)
userID := sessionUser.ID.MustGet()
sessions, err := c.SessionService.GetSessionsByUserID(
g,
session,
&userID,
&repository.SessionOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
data := []map[string]interface{}{}
for _, sess := range sessions.Rows {
idStr := sess.ID.String()
data = append(data, map[string]interface{}{
"id": idStr,
"current": idStr == session.ID.String(),
"ip": sess.IP,
"createdAt": sess.CreatedAt,
"updatedAt": sess.UpdatedAt,
})
}
c.Response.OK(
g,
gin.H{"sessions": data},
)
}
// Login logs in a user
func (c *User) Login(g *gin.Context) {
// parse req
var req UserLoginRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
user, err := c.UserService.AuthenticateUsernameWithPassword(
g,
req.Username,
req.Password,
g.ClientIP(),
)
if errors.Is(err, errs.ErrUserWrongPasword) {
c.Response.BadRequestMessage(g, "Invalid password")
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.Response.BadRequestMessage(g, "Invalid credentials")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
// if the user has MFA enabled then we check the MFA flow
// if the user has MFA enabled, we must check if there is a
// valid MFA or a valid recovery code
userID := user.ID.MustGet()
MFATokenSupplied := len(req.TOTP) > 0
MFARecoveryCodeSupplied := len(req.MFARecoveryCode) > 0
mfaEnabled, err := c.UserService.IsTOTPEnabledByUserID(
g,
&userID,
)
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid TOTP")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
if mfaEnabled {
// if tokens or recovery codes are supplied
// return mfa is required
if !MFATokenSupplied && !MFARecoveryCodeSupplied {
c.Response.OK(
g,
gin.H{
"mfa": true,
},
)
return
}
// if the client has given both a TOTP and a recovery code
// we return a bad request
if MFATokenSupplied && MFARecoveryCodeSupplied {
c.Response.BadRequestMessage(g, "Cannot supply both MFA token and MFA recovery code")
return
}
// verify the TOTP MFA token
userID := user.ID.MustGet()
if MFATokenSupplied && !MFARecoveryCodeSupplied {
// if MFA is enabled, verify the TOTP
totpToken, err := vo.NewString64(req.TOTP)
if err != nil {
c.Logger.Debugw("failed to create TOTP",
"error", err,
)
c.Response.ValidationFailed(g, "TOTP", err)
return
}
err = c.UserService.CheckTOTP(
g,
&userID,
totpToken,
)
if err != nil {
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid TOTP")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// if the user has MFA enabled and the client has supplied a recovery code
// we verify the recovery code
if !MFATokenSupplied && MFARecoveryCodeSupplied {
recoveryCode, err := vo.NewString64(req.MFARecoveryCode)
if err != nil {
c.Logger.Debugw("failed to create recovery code",
"error", err,
)
c.Response.ValidationFailed(g, "RecoveryCode", err)
return
}
verifiedMFA, err := c.UserService.CheckMFARecoveryCode(
g,
&userID,
recoveryCode,
)
if err != nil {
if errors.Is(err, errs.ErrUserWrongRecoveryCode) {
c.Response.BadRequestMessage(g, "Invalid recovery code")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
if !verifiedMFA {
c.Response.BadRequestMessage(g, "Invalid recovery code")
return
}
// as the recovery code is valid, we can now disable MFA
err = c.UserService.DisableTOTP(g, &userID)
if ok := c.handleErrors(g, err); !ok {
return
}
}
}
// create a new session
session, err := c.SessionService.Create(
g,
user,
g.ClientIP(),
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
// Set the session in the cookie
cookie := &http.Cookie{
Name: data.SessionCookieKey,
Value: session.ID.String(),
Path: "/",
SameSite: http.SameSiteStrictMode,
HttpOnly: true,
Secure: true,
Expires: *session.MaxAgeAt,
}
http.SetCookie(g.Writer, cookie)
c.Response.OK(g, session)
}
// expireCookieAndStatusOK expires the cookie and returns a 200 OK
func (c *User) expireCookieAndStatusOK(g *gin.Context) {
g.SetCookie(
data.SessionCookieKey,
"",
-1,
"/",
"",
false,
true,
)
c.logoutOK(g)
}
// logoutOK returns a 200 OK
func (c *User) logoutOK(g *gin.Context) {
c.Response.OK(
g,
gin.H{"message": "logged out"},
)
}
// Logout logs out the user
// only invalidates the session if the session cookie is
// in the request, this should reduce the risk of CSRF logout
func (c *User) Logout(g *gin.Context) {
sessionCookie, err := g.Cookie(data.SessionCookieKey)
if err != nil {
c.logoutOK(g)
return
}
sessionID, err := uuid.Parse(sessionCookie)
if err != nil {
c.logoutOK(g)
return
}
ctx := g.Request.Context()
err = c.SessionService.Expire(ctx, &sessionID)
if err != nil {
c.expireCookieAndStatusOK(g)
return
}
c.expireCookieAndStatusOK(g)
}
// SessionPing pings the session
func (c *User) SessionPing(g *gin.Context) {
// handle session
session, sessionUser, ok := c.handleSession(g)
if !ok {
return
}
c.Logger.Debugw("pinged session for user",
"userID", sessionUser.ID.MustGet().String(),
)
sessionRole := sessionUser.Role
if sessionRole == nil {
c.Logger.Error("failed to load role from session user")
c.Response.ServerError(g)
return
}
sessionCompany := sessionUser.Company
companyName := ""
if sessionCompany != nil {
companyName = sessionCompany.Name.MustGet().String()
}
c.Response.OK(
g,
gin.H{
"userID": sessionUser.ID,
"username": sessionUser.Username.MustGet().String(),
"name": sessionUser.Name.MustGet().String(),
"role": sessionRole.Name,
"company": companyName,
"ip": session.IP,
},
)
}
// InvalidateAllSessionByUserID is the nuclear session button for a user
func (c *User) InvalidateAllSessionByUserID(g *gin.Context) {
session, user, ok := c.handleSession(g)
if !ok {
return
}
var userID *uuid.UUID
// parse req
var req model.InvalidateAllSessionRequest
err := g.ShouldBindJSON(&req)
if err != nil {
if user == nil || !user.ID.IsSpecified() {
c.Response.BadRequest(g)
return
}
uid := user.ID.MustGet()
userID = &uid
} else {
if req.UserID == nil {
c.Response.BadRequest(g)
return
}
userID = req.UserID
}
// invalidate
err = c.SessionService.ExpireAllByUserID(g, session, userID)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// SetupTOTP generates a new TOTP MFA secrets
func (c *User) SetupTOTP(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var request UserSetupTOTPRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
passwd, err := vo.NewReasonableLengthPassword(request.Password)
if err != nil {
c.Logger.Debugw("failed to create password",
"error", err,
)
c.Response.ValidationFailed(g, "Password", err)
return
}
// get and save TOTP for user
totpValues, err := c.UserService.SetupTOTP(
g.Request.Context(),
session,
passwd,
)
// handle response
if errors.Is(err, errs.ErrAuthenticationFailed) {
c.Response.BadRequestMessage(g, "Incorrect password")
return
}
if ok := handleServerError(g, c.Response, err); !ok {
return
}
c.Response.OK(
g,
gin.H{
"base32": totpValues.Secret,
"url": totpValues.URL,
"recoveryCode": totpValues.RecoveryCode,
},
)
}
// SetupVerifyTOTP verifies a TOTP
func (c *User) SetupVerifyTOTP(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req UserVerifyTOTPRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
totp, err := vo.NewString64(req.TOTP)
if err != nil {
c.Logger.Debugw("failed to create TOTP",
"error", err,
)
c.Response.ValidationFailed(g, "TOTP", err)
return
}
// verify TOTP
err = c.UserService.SetupCheckTOTP(
g.Request.Context(),
session,
totp,
)
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"TOTP verified",
)
}
// IsTOTPEnabled checks if TOTP is enabled
func (c *User) IsTOTPEnabled(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
// check if TOTP is enabled
isEnabled, err := c.UserService.IsTOTPEnabled(
g.Request.Context(),
session,
)
// handle response
if ok := handleServerError(g, c.Response, err); !ok {
return
}
c.Response.OK(
g,
gin.H{"enabled": isEnabled},
)
}
// DisableTOTP disables TOTP
func (c *User) DisableTOTP(g *gin.Context) {
_, user, ok := c.handleSession(g)
if !ok {
return
}
// parse request
var request UserDisableTOTPRequest
if ok := c.handleParseRequest(g, &request); !ok {
return
}
token, err := vo.NewString64(request.Token)
if err != nil {
c.Logger.Debugw("failed to create token",
"error", err,
)
c.Response.ValidationFailed(g, "Token", err)
return
}
// check TOTP
userID := user.ID.MustGet()
err = c.UserService.CheckTOTP(
g.Request.Context(),
&userID,
token,
)
if err != nil {
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
// disable TOTP
err = c.UserService.DisableTOTP(
g.Request.Context(),
&userID,
)
// handle response
if err != nil {
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
if ok := c.handleErrors(g, err); !ok {
return
}
}
c.Response.OK(
g,
"TOTP disabled",
)
}
// VerifyTOTP verifies a TOTP
func (c *User) VerifyTOTP(g *gin.Context) {
_, user, ok := c.handleSession(g)
if !ok {
return
}
// parse req
var req UserVerifyTOTPRequest
if ok := c.handleParseRequest(g, &req); !ok {
return
}
totp, err := vo.NewString64(req.TOTP)
if err != nil {
c.Logger.Debugw("failed to create TOTP",
"error", err,
)
c.Response.ValidationFailed(g, "TOTP", err)
return
}
// verify TOTP
userID := user.ID.MustGet()
err = c.UserService.CheckTOTP(
g.Request.Context(),
&userID,
totp,
)
if errors.Is(err, errs.ErrUserWrongTOTP) {
c.Response.BadRequestMessage(g, "Invalid token")
return
}
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(
g,
"TOTP verified",
)
}
+358
View File
@@ -0,0 +1,358 @@
package controller
import (
"bytes"
"encoding/csv"
"fmt"
"io"
"mime"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/api"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/service"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Common is a common controller base struct it holds common operations on the
// common dependencies
type Common struct {
Response api.JSONResponseHandler
Logger *zap.SugaredLogger
SessionService *service.Session
}
// handleSession handles the session and returns the session and user
// if the session is not valid, a 401 response is sent
func (c *Common) handleSession(
g *gin.Context,
) (*model.Session, *model.User, bool) {
s, ok := g.Get("session")
if !ok {
c.Logger.Debug("session not found in context")
c.Response.Unauthorized(g)
return nil, nil, false
}
session, ok := s.(*model.Session)
if !ok {
c.Logger.Error("session in context is not of type model.Session")
c.Response.Unauthorized(g)
return nil, nil, false
}
user := session.User
if user == nil {
c.Logger.Error("user not found in session")
c.Response.Unauthorized(g)
return nil, nil, false
}
return session, user, true
}
// HandleParseRequest parses the request and returns true if successful
// if the request is not parsable, a 400 response is sent
func (c *Common) handleParseRequest(
g *gin.Context,
req any,
) bool {
body, err := io.ReadAll(g.Request.Body)
if err != nil {
c.Logger.Debugw("failed to read request body",
"error", err,
)
c.Response.BadRequest(g)
return false
}
if err := utils.Unmarshal(body, &req); err != nil {
c.Logger.Debugw("failed to parse request",
"error", err,
)
c.Response.BadRequestMessage(g, err.Error())
return false
}
return true
}
// handleParseIDParam parses the id parameter from the request
// and returns it if successful
// if the id is not parsable, a 400 response is sent
func (c *Common) handleParseIDParam(
g *gin.Context,
) (*uuid.UUID, bool) {
id, err := uuid.Parse(g.Param("id"))
if err != nil {
c.Logger.Debugw("failed to parse id",
"error", err,
)
c.Response.BadRequestMessage(g, errs.MsgFailedToParseUUID)
return nil, false
}
return &id, true
}
// handlePagination parses the pagination from the request and returns it
// if the pagination is not valid, a 400 response is sent
func (c *Common) handlePagination(
g *gin.Context,
) (*vo.Pagination, bool) {
pagination, err := vo.NewPaginationFromRequest(g)
if err != nil {
c.Logger.Debugw("invalid offset or limit",
"error", err,
)
c.Response.ValidationFailed(g, "pagination", err)
return nil, false
}
return pagination, true
}
// handleQueryArgs parses the query from the request and returns it
func (c *Common) handleQueryArgs(g *gin.Context) (*vo.QueryArgs, bool) {
q, err := vo.QueryFromRequest(g)
if err != nil {
c.Logger.Debugw("failed to parse query",
"error", err,
)
c.Response.ValidationFailed(g, "query args", err)
return nil, false
}
return q, true
}
// handleErrors is a helper function to handle common handleErrors
// it most often checks for more than what is needed, but is
// useful to avoid missing any error handling and saving time
// it returns true if no errors are found
// it returns false if an error is found and a response is sent
func (c *Common) handleErrors(
g *gin.Context,
err error,
) bool {
if err != nil {
if ok := handleAuthorizationError(g, c.Response, err); !ok {
c.Logger.Debugw("authorization error",
"auth_error", err,
)
return false
}
if ok := handleValidationError(g, c.Response, err); !ok {
c.Logger.Debugw("validation error",
"validation_error", err,
)
return false
}
if ok := handleCustomError(g, c.Response, err); !ok {
c.Logger.Debugw("custom error",
"custom_error", err,
)
return false
}
if ok := handleDBRowNotFound(g, c.Response, err); !ok {
c.Logger.Debugw("DB row not found error",
"error", err,
)
return false
}
c.Logger.Errorw("API unknown error type", "error", err)
_ = handleServerError(g, c.Response, err)
return false
}
return true
}
// responseWithCSV
func (c *Common) responseWithCSV(
g *gin.Context,
buffer *bytes.Buffer,
writer *csv.Writer,
name string,
) {
writer.Flush()
if err := writer.Error(); err != nil {
c.handleErrors(g, err)
return
}
// Set CSV response headers
setSecureContentDisposition(g, name)
g.Header("Content-Type", "text/csv")
g.Header("Content-Length", fmt.Sprint(buffer.Len()))
// Write the CSV buffer to the response
_, err := g.Writer.Write(buffer.Bytes())
if err != nil {
c.handleErrors(g, err)
}
}
// responseWithZIP
func (c *Common) responseWithZIP(
g *gin.Context,
buffer *bytes.Buffer,
name string,
) {
g.Header("Content-Type", "application/zip")
setSecureContentDisposition(g, name)
g.Header("Content-Transfer-Encoding", "binary")
g.Header("Expires", "0")
g.Header("Cache-Control", "must-revalidate")
g.Header("Pragma", "public")
g.Header("Content-Length", fmt.Sprintf("%d", buffer.Len()))
_, err := g.Writer.Write(buffer.Bytes())
if err != nil {
c.handleErrors(g, err)
}
}
// companyIDFromRequestQuery returns the companyID as a UUID from the query
// or nil if not found
func companyIDFromRequestQuery(g *gin.Context) *uuid.UUID {
companyID := g.Query("companyID")
if companyID != "" {
cid, err := uuid.Parse(companyID)
if err != nil {
return nil
}
return &cid
}
return nil
}
// SetSessionInGinContext sets the session in the gin context
func SetSessionInGinContext(c *gin.Context, s *model.Session) {
c.Set("session", s)
}
// handleDBRowNotFound checks if the error is a not found error
// if it is, a 404 response is sent
// if it is not, true is returned
func handleDBRowNotFound(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.Is(err, gorm.ErrRecordNotFound) {
// error is logged in service
_ = err
responseHandler.NotFound(g)
return false
}
return true
}
// handleAuthorizationError checks if the error is an authorization error
// if it is, a 403 response is sent
// if it is not, true is returned
func handleAuthorizationError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.Is(err, errs.ErrAuthorizationFailed) {
// error is logged in service
_ = err
responseHandler.Forbidden(g)
return false
}
return true
}
// handleValidationError checks if the error is a validation error
// if it is, a 400 response is sent
// if it is not, true is returned
func handleValidationError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.As(err, &errs.ValidationError{}) {
// error is logged in service
_ = err
responseHandler.BadRequestMessage(g, err.Error())
return false
}
return true
}
// handleCustomError checks if the error is a custom error
// if it is a 400 response is sent
// if it is not, true is returned
func handleCustomError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if errors.As(err, &errs.CustomError{}) {
// error is logged in service
_ = err
responseHandler.BadRequestMessage(g, err.Error())
return false
}
return true
}
// handleServerError checks if the error is a server error
// if it is, a 500 response is sent
// if it is not, true is returned
func handleServerError(
g *gin.Context,
responseHandler api.JSONResponseHandler,
err error,
) bool {
if err != nil {
// error is logged in service
_ = err
responseHandler.ServerError(g)
return false
}
return true
}
func setSecureContentDisposition(c *gin.Context, filename string) {
// Strip any directory components
filename = filepath.Base(filename)
// Remove any potentially problematic characters
filename = strings.Map(func(r rune) rune {
// Keep only alphanumeric, space, dash, underscore and dot
if (r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
(r == ' ' || r == '-' || r == '_' || r == '.') {
return r
}
return -1
}, filename)
// Ensure we still have a valid filename
if filename == "" || filename == "." || filename == ".." {
filename = time.Now().UTC().Format("20060102150405")
}
// Properly encode the filename for Content-Disposition
encodedFilename := mime.QEncoding.Encode("utf-8", filename)
c.Header("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s";`,
encodedFilename,
),
)
}
func (c *Common) requiresFlag(g *gin.Context, featureFlag string) {
// handle session
_, _, ok := c.handleSession(g)
if !ok {
return
}
c.Response.ServerErrorMessage(g, "requires "+featureFlag+" edition")
}
+26
View File
@@ -0,0 +1,26 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/service"
)
// Version is a controller
type Version struct {
Common
versionService *service.Version
}
// Get application version
func (c *Version) Get(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
version, err := c.versionService.Get(g.Request.Context(), session)
if ok := handleServerError(g, c.Response, err); !ok {
return
}
c.Response.OK(g, version)
}
+171
View File
@@ -0,0 +1,171 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// WebhookColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var WebhookColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.WEBHOOK_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.WEBHOOK_TABLE, "updated_at"),
"name": repository.TableColumn(database.WEBHOOK_TABLE, "name"),
}
// Webhook is a controller
type Webhook struct {
Common
WebhookService *service.Webhook
}
// Create creates a new webhook
func (w *Webhook) Create(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
var req model.Webhook
if ok := w.handleParseRequest(g, &req); !ok {
return
}
// save webhook
id, err := w.WebhookService.Create(g.Request.Context(), session, &req)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(
g,
gin.H{
"id": id.String(),
},
)
}
// GetAll gets the webhooks
func (w *Webhook) GetAll(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := w.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
companyID := companyIDFromRequestQuery(g)
// get
webhooks, err := w.WebhookService.GetAll(
g.Request.Context(),
session,
companyID,
&repository.WebhookOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(
g,
webhooks,
)
}
// GetByID gets a webhook by id
func (w *Webhook) GetByID(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
// get
webhook, err := w.WebhookService.GetByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, webhook)
}
// Update updates a webhook
func (w *Webhook) UpdateByID(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
var req model.Webhook
if ok := w.handleParseRequest(g, &req); !ok {
return
}
// save
err := w.WebhookService.Update(g.Request.Context(), session, id, &req)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, nil)
}
// DeleteByID deletes a webhook by id
func (w *Webhook) DeleteByID(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
// delete
err := w.WebhookService.DeleteByID(g, session, id)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, nil)
}
// SendTest sends a test webhook
func (w *Webhook) SendTest(g *gin.Context) {
session, _, ok := w.handleSession(g)
if !ok {
return
}
// parse request
id, ok := w.handleParseIDParam(g)
if !ok {
return
}
// send
data, err := w.WebhookService.SendTest(g.Request.Context(), session, id)
// handle response
if ok := w.handleErrors(g, err); !ok {
return
}
w.Response.OK(g, data)
}