Files
phishingclub/backend/controller/attachment.go
Ronni Skansing 6c3c695941 Added support for random recipient variable
Random recipient can not be the recipient
Added support for variables in email subject

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2025-11-27 00:41:14 +01:00

407 lines
10 KiB
Go

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(
g.Request.Context(),
"id",
"/foo",
domain,
&campaignRecipient,
email,
apiSender,
nil, // no company context for attachment preview
)
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{})
}