package service import ( "context" "fmt" "os" "path/filepath" "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/errs" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/repository" "github.com/phishingclub/phishingclub/vo" "gorm.io/gorm" ) // Attachment is a Attachment service type Attachment struct { Common RootFolder string FileService *File AttachmentRepository *repository.Attachment EmailRepository *repository.Email } // Create creates and stores a new attachments func (a *Attachment) Create( g *gin.Context, session *model.Session, attachments []*model.Attachment, ) ([]*uuid.UUID, error) { ae := NewAuditEvent("Attachment.Create", session) createdIDs := []*uuid.UUID{} // check permissions isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { a.LogAuthError(err) return createdIDs, errs.Wrap(err) } if !isAuthorized { a.AuditLogNotAuthorized(ae) return createdIDs, errs.ErrAuthorizationFailed } // @TODO for now we allow dublicate names - should we? // without no dubs it is easier to reason between attachments // with dubs it is easier to import a collection of files and etc // upload the files contextFolder := "" // ensure that all attachments have the same context // and map attachments to files // @TODO move out of here differentContextError := fmt.Errorf( "all attachments must have the same context '%s'", contextFolder, ) files := []*RootFileUpload{} filePaths := []string{} for _, attachment := range attachments { // ensure context is the same across all files if attachment.CompanyID.IsSpecified() && attachment.CompanyID.IsNull() { if contextFolder == "" { contextFolder = data.ASSET_GLOBAL_FOLDER } else if contextFolder != data.ASSET_GLOBAL_FOLDER { a.Logger.Error(differentContextError) return createdIDs, differentContextError } } else { companyID, err := attachment.CompanyID.Get() if err != nil { a.Logger.Debugw("failed to get company id", "error", err) return createdIDs, errs.Wrap(err) } if contextFolder == "" { contextFolder = companyID.String() } else if contextFolder != companyID.String() { a.Logger.Error(differentContextError) return createdIDs, differentContextError } } // map attachments to files attachmentFilename := filepath.Clean(attachment.File.Filename) // relative path is used in the DB relativePath, err := vo.NewRelativeFilePath(attachmentFilename) if err != nil { a.Logger.Debugw("failed to make file path", "path", attachmentFilename, err, ) return createdIDs, errs.Wrap(err) } // ensure base attachment directory exists if err := os.MkdirAll(a.RootFolder, 0755); err != nil { a.Logger.Debugw("failed to create attachment root directory", "error", err) return createdIDs, errs.Wrap(err) } // create root filesystem for the full context path (controlled paths only) fullContextPath := filepath.Join(a.RootFolder, contextFolder) // ensure context folder exists before opening it if err := os.MkdirAll(fullContextPath, 0755); err != nil { a.Logger.Debugw("failed to create context directory", "error", err) return createdIDs, errs.Wrap(err) } contextRoot, err := os.OpenRoot(fullContextPath) if err != nil { a.Logger.Infow("failed to open context folder", "error", err) return createdIDs, errs.Wrap(err) } defer contextRoot.Close() // validate that the filename is accessible within the context // this prevents directory traversal in the filename itself _, err = contextRoot.Stat(attachmentFilename) if err != nil && !os.IsNotExist(err) { a.Logger.Infow("invalid filename", "filename", attachmentFilename, "error", err) return createdIDs, errs.Wrap(err) } // build final path for logging pathWithRootAndDomainContext := filepath.Join(fullContextPath, attachmentFilename) a.Logger.Debugw("secure file path", "relative", relativePath.String(), "contextPath", fullContextPath, "filename", attachmentFilename, ) filePaths = append(filePaths, pathWithRootAndDomainContext) files = append(files, NewRootFileUpload(contextRoot, attachmentFilename, attachment.File)) } // upload files to the file system using secure method _, err = a.FileService.Upload( g, files, ) if err != nil { a.Logger.Debugw("failed to upload files", "error", err) return createdIDs, errs.Wrap(err) } // save uploaded files to the database for _, attachment := range attachments { _, err := a.AttachmentRepository.Insert( g, attachment, ) if err != nil { a.Logger.Debugw("failed to save attachment", "error", err) // TODO remove all previously uploaded files // buut maybe not, it would be annoying if there is a multi user system // and a user uploads a huge amount of files and one fails and does this // repeatedly to burn the server return createdIDs, errs.Wrap(err) } } strIds := []string{} for _, id := range createdIDs { strIds = append(strIds, id.String()) } ae.Details["paths"] = filePaths ae.Details["ids"] = strIds a.AuditLogAuthorized(ae) return createdIDs, nil } // GetAll gets all attachments func (a *Attachment) GetAll( ctx context.Context, session *model.Session, companyID *uuid.UUID, query *vo.QueryArgs, ) (*model.Result[model.Attachment], error) { ae := NewAuditEvent("Attachment.GetAll", session) result := model.NewEmptyResult[model.Attachment]() // check permissions isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { a.LogAuthError(err) return result, errs.Wrap(err) } if !isAuthorized { a.AuditLogNotAuthorized(ae) return result, errs.ErrAuthorizationFailed } // if there is no companyID then the scope is 'shared' if companyID == nil { result, err = a.AttachmentRepository.GetAllByGlobalContext( ctx, query, ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { a.Logger.Errorw("failed to get global attachments", "error", err) return nil, errs.Wrap(err) } } else { result, err = a.AttachmentRepository.GetAllByContext( ctx, companyID, query, ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { a.Logger.Errorw("failed to get company attachments", "error", err) return nil, errs.Wrap(err) } } for _, attachment := range result.Rows { path, err := a.GetPath(attachment) if err != nil { a.Logger.Debugw("failed to get path", "error", err) return nil, errs.Wrap(err) } attachment.Path = nullable.NewNullableWithValue(*path) } // no audit on read return result, nil } // GetByID gets an attachment by id func (a *Attachment) GetByID( ctx context.Context, session *model.Session, id *uuid.UUID, ) (*model.Attachment, error) { ae := NewAuditEvent("Attachment.GetById", session) ae.Details["id"] = id.String() // check permissions isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { a.LogAuthError(err) return nil, errs.Wrap(err) } if !isAuthorized { a.AuditLogNotAuthorized(ae) return nil, errs.ErrAuthorizationFailed } // get the attachment attachment, err := a.AttachmentRepository.GetByID( ctx, id, ) if err != nil { a.Logger.Debugw("attachment not found", "error", err) return nil, errs.Wrap(err) } // path path, err := a.GetPath(attachment) if err != nil { a.Logger.Debugw("failed to get path", "error", err) return nil, errs.Wrap(err) } attachment.Path = nullable.NewNullableWithValue(*path) // no audit log on read return attachment, nil } // UpdateByID updates an attachment by id func (a *Attachment) UpdateByID( ctx context.Context, session *model.Session, id *uuid.UUID, attachment *model.Attachment, ) error { ae := NewAuditEvent("Attachment.UpdateById", session) ae.Details["id"] = id.String() // check permissions isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { a.LogAuthError(err) return err } if !isAuthorized { a.AuditLogNotAuthorized(ae) return errs.ErrAuthorizationFailed } // get the attachment current, err := a.AttachmentRepository.GetByID( ctx, id, ) if err != nil { a.Logger.Debugw("attachment not found", "error", err) return err } // update the attachment if attachment.Name.IsSpecified() { current.Name = attachment.Name } if attachment.Description.IsSpecified() { current.Description = attachment.Description } if attachment.EmbeddedContent.IsSpecified() { current.EmbeddedContent = attachment.EmbeddedContent } // validate if err := attachment.Validate(); err != nil { a.Logger.Debugw("failed to validate attachment", "error", err) return err } // save the change err = a.AttachmentRepository.UpdateByID( ctx, id, current, ) if err != nil { a.Logger.Errorw("failed to update attachment", "error", err) return err } a.AuditLogAuthorized(ae) return nil } // DeleteByID deletes an attachment by id func (a *Attachment) DeleteByID( ctx context.Context, session *model.Session, id *uuid.UUID, ) error { ae := NewAuditEvent("Attachment.DeleteById", session) ae.Details["id"] = id.String() // check permissions isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { a.LogAuthError(err) return err } if !isAuthorized { a.AuditLogNotAuthorized(ae) return errs.ErrAuthorizationFailed } // get the attachment attachment, err := a.AttachmentRepository.GetByID( ctx, id, ) if err != nil { a.Logger.Debugw("attachment not found", "error", err) return err } // delete any references to the attachments in emails err = a.EmailRepository.RemoveAttachmentsByAttachmentID( ctx, id, ) if err != nil { a.Logger.Debugw("failed to delete attachment references", "error", err) return err } // delete the file companyContext := data.ASSET_GLOBAL_FOLDER if attachment.CompanyID.IsSpecified() && !attachment.CompanyID.IsNull() { companyContext = attachment.CompanyID.MustGet().String() } // create root filesystem for secure file operations root, err := os.OpenRoot(a.RootFolder) if err != nil { a.Logger.Debugw("failed to open root folder", "error", err) return err } defer root.Close() // validate that company context exists and is accessible companyRoot, err := root.OpenRoot(companyContext) if err != nil { a.Logger.Debugw("failed to open company context", "error", err) return err } defer companyRoot.Close() attachmentFileName := attachment.FileName.MustGet() // validate that the file exists and is accessible within the company context _, err = companyRoot.Stat(attachmentFileName.String()) if err != nil { a.Logger.Debugw("attachment file not found in context", "error", err) return err } // build full path for file service (validated as safe by OpenRoot) filename := filepath.Join(a.RootFolder, companyContext, attachmentFileName.String()) err = a.FileService.Delete( filename, ) if err != nil { a.Logger.Debugw("failed to delete attachment file", "error", err) return err } // delete the attachment from the database err = a.AttachmentRepository.DeleteByID( ctx, id, ) if err != nil { a.Logger.Errorw("failed to delete attachment from database but the file is deleted", "error", err, ) return err } a.AuditLogAuthorized(ae) return nil } func (a *Attachment) GetPath(attachment *model.Attachment) (*vo.RelativeFilePath, error) { // path contextFolder := "" if !attachment.CompanyID.IsSpecified() || attachment.CompanyID.IsNull() { contextFolder = data.ASSET_GLOBAL_FOLDER } else { companyID := attachment.CompanyID.MustGet().String() contextFolder = companyID } // map attachments to files attachmentFilename := filepath.Clean(attachment.FileName.MustGet().String()) // create root filesystem for secure path operations root, err := os.OpenRoot(a.RootFolder) if err != nil { a.Logger.Infow("failed to open root folder", "error", err) return nil, errs.Wrap(err) } defer root.Close() // validate that the context folder and filename are accessible within root contextRoot, err := root.OpenRoot(contextFolder) if err != nil { a.Logger.Infow("failed to open context folder", "error", err) return nil, errs.Wrap(err) } defer contextRoot.Close() // verify the file exists and is accessible _, err = contextRoot.Stat(attachmentFilename) if err != nil { a.Logger.Debugw("file not accessible", "error", err) return nil, errs.Wrap(err) } // build full path for return value pathWithRootAndDomainContext := filepath.Join(a.RootFolder, contextFolder, attachmentFilename) path, err := vo.NewRelativeFilePath(pathWithRootAndDomainContext) if err != nil { a.Logger.Debugw("failed to make file path", "error", err) return nil, errs.Wrap(err) } return path, nil }